Compare commits

..

24 Commits
main ... main

Author SHA1 Message Date
db3a3e8851 Update readme.md 2024-10-19 18:36:41 -05:00
57c782f5f3 include dep licenses 2024-10-12 21:57:38 -07:00
effb1a75d1 animated emojis 2024-10-12 21:48:16 -07:00
3e8fa8d7eb change max size to 2048
it seems the game doesn't resize it to 512 anymore
2024-10-11 01:51:49 -07:00
b5f7208bc8 remove unused permission 2024-10-10 13:49:07 -07:00
b2054992b2 Merge branch 'wip' of gitea.moe:lamp/vrchat-emoji-manager into wip 2024-10-09 21:29:28 -07:00
8b682f35bb update readme 2024-10-09 21:29:18 -07:00
a74163b29a Merge branch 'wip' of gitea.moe:lamp/vrchat-emoji-manager into wip 2024-10-09 21:12:31 -07:00
349c7bd551 keep in sync with db 2024-10-09 21:12:25 -07:00
779624a722 add nsfw hint 2024-10-09 17:38:15 -07:00
a31550e205 fix auto import 2024-10-09 17:34:11 -07:00
d8167a3a3d fix delete 2024-10-09 17:33:58 -07:00
4ed59e5b66 fix db backcompat upgrade 2024-10-09 17:18:49 -07:00
0a6a6df29b display ratelimit seconds 2024-10-09 16:45:06 -07:00
2f76dd3df2 stickers 2024-10-09 16:14:42 -07:00
15198ca301 version 2024-10-08 21:45:17 -07:00
f19ce27f2b backward compat 2024-10-08 20:27:11 -07:00
9f020332cc arrange code 2024-10-08 17:45:55 -07:00
5f1b0db47c consolidate redundant enable/disable code 2024-10-08 15:32:31 -07:00
ebed204c9d zip import use addFiles 2024-10-08 14:49:00 -07:00
9ec6b8d9cd import export zip 2024-10-08 02:14:56 -07:00
c5c47c111b basic indexeddb working 2024-10-08 00:41:57 -07:00
321cc93cf5 Update readme.md 2024-10-07 17:25:27 -05:00
fcfe877437 Don't stretch images
Pad to square with transparency. No more restriction on aspect ratio
2024-10-07 15:14:26 -07:00
16 changed files with 1162 additions and 344 deletions

View File

@ -1,50 +1,36 @@
import { getDb, getKnownEmojiIds, getKnownStickerIds, storeImage } from "./db.js";
getDb();
var functions = { var functions = {
async storeEmojis(emojis) { async storeFiles(files, type) {
console.debug("storeEmojis", emojis); console.debug("storeFiles", files, type);
var emojiStore = (await chrome.storage.local.get(["emojis"])).emojis || []; var knownIds = type == "sticker" ? await getKnownStickerIds() : await getKnownEmojiIds();
console.debug("knownIds", knownIds);
loop1: for (let emoji of emojis) { for (let file of files) {
for (let existingEmoji of emojiStore) { if (knownIds.includes(file.id)) continue;
if (existingEmoji.currentId == emoji.id) continue loop1; console.log("store", file.id);
} let e = {
console.log("store", emoji.id); date: file.versions[1].created_at,
var blob = await fetch(emoji.versions[1].file.url).then(res => res.blob()); currentId_emoji: file.tags.includes("emoji") ? file.id : null,
var dataString = await blobToDataURL(blob); currentId_sticker: file.tags.includes("sticker") ? file.id : null,
var emojiDoc = { internalId: file.id,
date: emoji.versions[1].created_at, animationStyle: file.animationStyle,
currentId: emoji.id, data: await fetch(file.versions[1].file.url).then(res => res.blob())
internalId: emoji.id,
animationStyle: emoji.animationStyle
}; };
await chrome.storage.local.set({[`data-${emojiDoc.internalId}`]: dataString}); if (file.tags.includes("animated")) {
emojiStore.push(emojiDoc); e.animated = true;
e.data_spritesheet = e.data; //todo, convert spritesheet back to gif???? or make spritesheet player??
e.framecount = file.frames;
e.fps = file.framesOverTime;
}
await storeImage(e);
} }
chrome.storage.local.set({emojis: emojiStore});
} }
}; };
chrome.runtime.onMessage.addListener(function([method, ...args], sender, sendResponse) { chrome.runtime.onMessage.addListener(function([method, ...args], sender, sendResponse) {
console.debug(arguments); console.debug(arguments);
functions[method]?.apply(null, args).then(sendResponse).catch(error => sendResponse({error: error.toString()}));; functions[method].apply(null, args).then(sendResponse);
return true; return true;
}); });
function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
var reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
}

View File

@ -1,32 +1,54 @@
fetch("https://vrchat.com/api/1/files?tag=emoji&n=100&offset=0").then(res => res.json()).then(data => {
if (!data.error)
chrome.runtime.sendMessage(["storeEmojis", data]);
});
var functions = { var functions = {
async getEmojis() { async getFiles(tag = "emoji") {
return await fetch("https://vrchat.com/api/1/files?tag=emoji&n=100&offset=0").then(res => res.json()); var res = await fetch(`https://vrchat.com/api/1/files?tag=${tag}&n=100&offset=0`);
return {
status: res.status,
body: await res.json(),
retryAfter: res.headers.get("Retry-After") || undefined
};
}, },
async deleteEmoji(id) { async deleteFile(id) {
return await fetch(`https://vrchat.com/api/1/file/${id}`, {method: "DELETE"}).then(res => res.json()); var res = await fetch(`https://vrchat.com/api/1/file/${id}`, {method: "DELETE"});
return {
status: res.status,
body: await res.json(),
retryAfter: res.headers.get("Retry-After") || undefined
};
}, },
async createEmoji(url, animationStyle) { async createFile({url, tag = "emoji", animationStyle, frames, framesOverTime}) {
var blob = await fetch(url).then(res => res.blob()); var blob = await fetch(url).then(res => res.blob());
var form = new FormData(); var form = new FormData();
form.append("tag", "emoji"); form.append("tag", tag);
form.append("animationStyle", animationStyle); if (animationStyle) form.append("animationStyle", animationStyle);
if (frames) form.append("frames", frames);
if (framesOverTime) form.append("framesOverTime", framesOverTime);
form.append("maskTag", "square"); form.append("maskTag", "square");
form.append("file", blob); form.append("file", blob);
return await fetch("https://vrchat.com/api/1/file/image", { var res = await fetch("https://vrchat.com/api/1/file/image", {
method: "POST", method: "POST",
body: form body: form
}).then(res => res.json()); });
return {
status: res.status,
body: await res.json(),
retryAfter: res.headers.get("Retry-After") || undefined
};
} }
}; };
functions.getFiles("emoji").then(data => {
if (!data.error) chrome.runtime.sendMessage(["storeFiles", data.body, "emoji"]);
else console.error(data.error);
});
functions.getFiles("sticker").then(data => {
if (!data.error) chrome.runtime.sendMessage(["storeFiles", data.body, "sticker"]);
else console.error(data.error);
});
chrome.runtime.onMessage.addListener(function([method, ...args], sender, sendResponse) { chrome.runtime.onMessage.addListener(function([method, ...args], sender, sendResponse) {
console.debug(arguments); console.debug(arguments);
functions[method]?.apply(null, args).then(sendResponse).catch(error => sendResponse({error: error.toString()})); functions[method].apply(null, args)
.then(response => sendResponse({response}))
.catch(error => sendResponse({error: error.toString()}));
return true; return true;
}); });

139
db.js Normal file
View File

@ -0,0 +1,139 @@
var db;
export function getDb() {
return db ||= initDb();
}
function initDb() {
return new Promise((resolve, reject) => {
var request = indexedDB.open("database");
request.onerror = () => reject(request.error);
request.onsuccess = event => {
console.debug("onsuccess");
resolve(event.target.result);
};
request.onupgradeneeded = event => {
console.debug("onupgradeneeded");
//request.onsuccess = null;
var db = event.target.result;
var objectStore = db.createObjectStore("images", { keyPath: "internalId", autoIncrement: true });
objectStore.createIndex("currentId_emoji", "currentId_emoji", {unique: true});
objectStore.createIndex("currentId_sticker", "currentId_sticker", {unique: true});
db.createObjectStore("settings");
chrome.storage.local.get().then(async storage => {
console.debug("storage", storage);
for (let emoji of storage.emojis) {
try {
emoji.data = await fetch(storage[`data-${emoji.internalId}`]).then(res => res.blob());
await storeImage(emoji);
} catch (error) {
console.error(error);
}
}
await setSetting("defaultAnimationStyle", storage.defaultAnimationStyle);
//resolve(db);
console.debug("finished upgrade");
});
};
request.onversionchange = () => {
db = undefined; // idk
};
});
}
export async function getKnownEmojiIds() {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readonly").objectStore("images").index("currentId_emoji").getAllKeys();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function getKnownStickerIds() {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readonly").objectStore("images").index("currentId_sticker").getAllKeys();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function getAllImages() {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readonly").objectStore("images").getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function storeImage(entry) {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readwrite").objectStore("images").add(entry);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function getImage(internalId) {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readonly").objectStore("images").get(internalId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function updateImage(internalId, update) {
var db = await getDb();
return await new Promise((resolve, reject) => {
var objectStore = db.transaction(["images"], "readwrite").objectStore("images");
var request = objectStore.get(internalId);
request.onsuccess = event => {
var request = objectStore.put(Object.assign(event.target.result, update));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
};
request.onerror = () => reject(request.error);
});
}
export async function deleteImage(internalId) {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readwrite").objectStore("images").delete(internalId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function deleteAllImages() {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readwrite").objectStore("images").clear();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function setSetting(key, value) {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["settings"], "readwrite").objectStore("settings").put(value, key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function getSetting(key) {
var db = await getDb();
return await new Promise((resolve, reject) => {
var request = db.transaction(["settings"], "readonly").objectStore("settings").get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

21
dep/gifuct/LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Matt Way
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

26
dep/gifuct/deinterlace.js Normal file
View File

@ -0,0 +1,26 @@
/**
* Deinterlace function from https://github.com/shachaf/jsgif
*/
export const deinterlace = (pixels, width) => {
const newPixels = new Array(pixels.length)
const rows = pixels.length / width
const cpRow = function(toRow, fromRow) {
const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width)
newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels))
}
// See appendix E.
const offsets = [0, 4, 2, 1]
const steps = [8, 8, 4, 2]
var fromRow = 0
for (var pass = 0; pass < 4; pass++) {
for (var toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) {
cpRow(toRow, fromRow)
fromRow++
}
}
return newPixels
}

85
dep/gifuct/index.js Normal file
View File

@ -0,0 +1,85 @@
import GIF from './js-binary-schema-parser/schemas/gif.js'
import { parse } from './js-binary-schema-parser/index.js'
import { buildStream } from './js-binary-schema-parser/parsers/uint8.js'
import { deinterlace } from './deinterlace.js'
import { lzw } from './lzw.js'
export const parseGIF = arrayBuffer => {
const byteData = new Uint8Array(arrayBuffer)
return parse(buildStream(byteData), GIF)
}
const generatePatch = image => {
const totalPixels = image.pixels.length
const patchData = new Uint8ClampedArray(totalPixels * 4)
for (var i = 0; i < totalPixels; i++) {
const pos = i * 4
const colorIndex = image.pixels[i]
const color = image.colorTable[colorIndex] || [0, 0, 0]
patchData[pos] = color[0]
patchData[pos + 1] = color[1]
patchData[pos + 2] = color[2]
patchData[pos + 3] = colorIndex !== image.transparentIndex ? 255 : 0
}
return patchData
}
export const decompressFrame = (frame, gct, buildImagePatch) => {
if (!frame.image) {
console.warn('gif frame does not have associated image.')
return
}
const { image } = frame
// get the number of pixels
const totalPixels = image.descriptor.width * image.descriptor.height
// do lzw decompression
var pixels = lzw(image.data.minCodeSize, image.data.blocks, totalPixels)
// deal with interlacing if necessary
if (image.descriptor.lct.interlaced) {
pixels = deinterlace(pixels, image.descriptor.width)
}
const resultImage = {
pixels: pixels,
dims: {
top: frame.image.descriptor.top,
left: frame.image.descriptor.left,
width: frame.image.descriptor.width,
height: frame.image.descriptor.height
}
}
// color table
if (image.descriptor.lct && image.descriptor.lct.exists) {
resultImage.colorTable = image.lct
} else {
resultImage.colorTable = gct
}
// add per frame relevant gce information
if (frame.gce) {
resultImage.delay = (frame.gce.delay || 10) * 10 // convert to ms
resultImage.disposalType = frame.gce.extras.disposal
// transparency
if (frame.gce.extras.transparentColorGiven) {
resultImage.transparentIndex = frame.gce.transparentColorIndex
}
}
// create canvas usable imagedata if desired
if (buildImagePatch) {
resultImage.patch = generatePatch(resultImage)
}
return resultImage
}
export const decompressFrames = (parsedGif, buildImagePatches) => {
return parsedGif.frames
.filter(f => f.image)
.map(f => decompressFrame(f, parsedGif.gct, buildImagePatches))
}

View File

@ -0,0 +1,49 @@
export const parse = (stream, schema, result = {}, parent = result) => {
if (Array.isArray(schema)) {
schema.forEach(partSchema => parse(stream, partSchema, result, parent))
} else if (typeof schema === 'function') {
schema(stream, result, parent, parse)
} else {
const key = Object.keys(schema)[0]
if (Array.isArray(schema[key])) {
parent[key] = {}
parse(stream, schema[key], result, parent[key])
} else {
parent[key] = schema[key](stream, result, parent, parse)
}
}
return result
}
export const conditional = (schema, conditionFunc) => (
stream,
result,
parent,
parse
) => {
if (conditionFunc(stream, result, parent)) {
parse(stream, schema, result, parent)
}
}
export const loop = (schema, continueFunc) => (
stream,
result,
parent,
parse
) => {
const arr = []
let lastStreamPos = stream.pos;
while (continueFunc(stream, result, parent)) {
const newParent = {}
parse(stream, schema, result, newParent)
// cases when whole file is parsed but no termination is there and stream position is not getting updated as well
// it falls into infinite recursion, null check to avoid the same
if(stream.pos === lastStreamPos) {
break
}
lastStreamPos = stream.pos
arr.push(newParent)
}
return arr
}

View File

@ -0,0 +1,78 @@
// Default stream and parsers for Uint8TypedArray data type
export const buildStream = uint8Data => ({
data: uint8Data,
pos: 0
})
export const readByte = () => stream => {
return stream.data[stream.pos++]
}
export const peekByte = (offset = 0) => stream => {
return stream.data[stream.pos + offset]
}
export const readBytes = length => stream => {
return stream.data.subarray(stream.pos, (stream.pos += length))
}
export const peekBytes = length => stream => {
return stream.data.subarray(stream.pos, stream.pos + length)
}
export const readString = length => stream => {
return Array.from(readBytes(length)(stream))
.map(value => String.fromCharCode(value))
.join('')
}
export const readUnsigned = littleEndian => stream => {
const bytes = readBytes(2)(stream)
return littleEndian ? (bytes[1] << 8) + bytes[0] : (bytes[0] << 8) + bytes[1]
}
export const readArray = (byteSize, totalOrFunc) => (
stream,
result,
parent
) => {
const total =
typeof totalOrFunc === 'function'
? totalOrFunc(stream, result, parent)
: totalOrFunc
const parser = readBytes(byteSize)
const arr = new Array(total)
for (var i = 0; i < total; i++) {
arr[i] = parser(stream)
}
return arr
}
const subBitsTotal = (bits, startIndex, length) => {
var result = 0
for (var i = 0; i < length; i++) {
result += bits[startIndex + i] && 2 ** (length - i - 1)
}
return result
}
export const readBits = schema => stream => {
const byte = readByte()(stream)
// convert the byte to bit array
const bits = new Array(8)
for (var i = 0; i < 8; i++) {
bits[7 - i] = !!(byte & (1 << i))
}
// convert the bit array to values based on the schema
return Object.keys(schema).reduce((res, key) => {
const def = schema[key]
if (def.length) {
res[key] = subBitsTotal(bits, def.index, def.length)
} else {
res[key] = bits[def.index]
}
return res
}, {})
}

View File

@ -0,0 +1,201 @@
import { conditional, loop } from '../index.js'
import {
readByte,
peekByte,
readBytes,
peekBytes,
readString,
readUnsigned,
readArray,
readBits,
} from '../parsers/uint8.js'
// a set of 0x00 terminated subblocks
var subBlocksSchema = {
blocks: (stream) => {
const terminator = 0x00
const chunks = []
const streamSize = stream.data.length
var total = 0
for (
var size = readByte()(stream);
size !== terminator;
size = readByte()(stream)
) {
// size becomes undefined for some case when file is corrupted and terminator is not proper
// null check to avoid recursion
if(!size) break;
// catch corrupted files with no terminator
if (stream.pos + size >= streamSize) {
const availableSize = streamSize - stream.pos
chunks.push(readBytes(availableSize)(stream))
total += availableSize
break
}
chunks.push(readBytes(size)(stream))
total += size
}
const result = new Uint8Array(total)
var offset = 0
for (var i = 0; i < chunks.length; i++) {
result.set(chunks[i], offset)
offset += chunks[i].length
}
return result
},
}
// global control extension
const gceSchema = conditional(
{
gce: [
{ codes: readBytes(2) },
{ byteSize: readByte() },
{
extras: readBits({
future: { index: 0, length: 3 },
disposal: { index: 3, length: 3 },
userInput: { index: 6 },
transparentColorGiven: { index: 7 },
}),
},
{ delay: readUnsigned(true) },
{ transparentColorIndex: readByte() },
{ terminator: readByte() },
],
},
(stream) => {
var codes = peekBytes(2)(stream)
return codes[0] === 0x21 && codes[1] === 0xf9
}
)
// image pipeline block
const imageSchema = conditional(
{
image: [
{ code: readByte() },
{
descriptor: [
{ left: readUnsigned(true) },
{ top: readUnsigned(true) },
{ width: readUnsigned(true) },
{ height: readUnsigned(true) },
{
lct: readBits({
exists: { index: 0 },
interlaced: { index: 1 },
sort: { index: 2 },
future: { index: 3, length: 2 },
size: { index: 5, length: 3 },
}),
},
],
},
conditional(
{
lct: readArray(3, (stream, result, parent) => {
return Math.pow(2, parent.descriptor.lct.size + 1)
}),
},
(stream, result, parent) => {
return parent.descriptor.lct.exists
}
),
{ data: [{ minCodeSize: readByte() }, subBlocksSchema] },
],
},
(stream) => {
return peekByte()(stream) === 0x2c
}
)
// plain text block
const textSchema = conditional(
{
text: [
{ codes: readBytes(2) },
{ blockSize: readByte() },
{
preData: (stream, result, parent) =>
readBytes(parent.text.blockSize)(stream),
},
subBlocksSchema,
],
},
(stream) => {
var codes = peekBytes(2)(stream)
return codes[0] === 0x21 && codes[1] === 0x01
}
)
// application block
const applicationSchema = conditional(
{
application: [
{ codes: readBytes(2) },
{ blockSize: readByte() },
{ id: (stream, result, parent) => readString(parent.blockSize)(stream) },
subBlocksSchema,
],
},
(stream) => {
var codes = peekBytes(2)(stream)
return codes[0] === 0x21 && codes[1] === 0xff
}
)
// comment block
const commentSchema = conditional(
{
comment: [{ codes: readBytes(2) }, subBlocksSchema],
},
(stream) => {
var codes = peekBytes(2)(stream)
return codes[0] === 0x21 && codes[1] === 0xfe
}
)
const schema = [
{ header: [{ signature: readString(3) }, { version: readString(3) }] },
{
lsd: [
{ width: readUnsigned(true) },
{ height: readUnsigned(true) },
{
gct: readBits({
exists: { index: 0 },
resolution: { index: 1, length: 3 },
sort: { index: 4 },
size: { index: 5, length: 3 },
}),
},
{ backgroundColorIndex: readByte() },
{ pixelAspectRatio: readByte() },
],
},
conditional(
{
gct: readArray(3, (stream, result) =>
Math.pow(2, result.lsd.gct.size + 1)
),
},
(stream, result) => result.lsd.gct.exists
),
// content frames
{
frames: loop(
[gceSchema, applicationSchema, commentSchema, imageSchema, textSchema],
(stream) => {
var nextCode = peekByte()(stream)
// rather than check for a terminator, we should check for the existence
// of an ext or image block to avoid infinite loops
//var terminator = 0x3B;
//return nextCode !== terminator;
return nextCode === 0x21 || nextCode === 0x2c
}
),
},
]
export default schema

118
dep/gifuct/lzw.js Normal file
View File

@ -0,0 +1,118 @@
/**
* javascript port of java LZW decompression
* Original java author url: https://gist.github.com/devunwired/4479231
*/
export const lzw = (minCodeSize, data, pixelCount) => {
const MAX_STACK_SIZE = 4096
const nullCode = -1
const npix = pixelCount
var available,
clear,
code_mask,
code_size,
end_of_information,
in_code,
old_code,
bits,
code,
i,
datum,
data_size,
first,
top,
bi,
pi
const dstPixels = new Array(pixelCount)
const prefix = new Array(MAX_STACK_SIZE)
const suffix = new Array(MAX_STACK_SIZE)
const pixelStack = new Array(MAX_STACK_SIZE + 1)
// Initialize GIF data stream decoder.
data_size = minCodeSize
clear = 1 << data_size
end_of_information = clear + 1
available = clear + 2
old_code = nullCode
code_size = data_size + 1
code_mask = (1 << code_size) - 1
for (code = 0; code < clear; code++) {
prefix[code] = 0
suffix[code] = code
}
// Decode GIF pixel stream.
var datum, bits, count, first, top, pi, bi
datum = bits = count = first = top = pi = bi = 0
for (i = 0; i < npix; ) {
if (top === 0) {
if (bits < code_size) {
// get the next byte
datum += data[bi] << bits
bits += 8
bi++
continue
}
// Get the next code.
code = datum & code_mask
datum >>= code_size
bits -= code_size
// Interpret the code
if (code > available || code == end_of_information) {
break
}
if (code == clear) {
// Reset decoder.
code_size = data_size + 1
code_mask = (1 << code_size) - 1
available = clear + 2
old_code = nullCode
continue
}
if (old_code == nullCode) {
pixelStack[top++] = suffix[code]
old_code = code
first = code
continue
}
in_code = code
if (code == available) {
pixelStack[top++] = first
code = old_code
}
while (code > clear) {
pixelStack[top++] = suffix[code]
code = prefix[code]
}
first = suffix[code] & 0xff
pixelStack[top++] = first
// add a new string to the table, but only if space is available
// if not, just continue with current table until a clear code is found
// (deferred clear code implementation as per GIF spec)
if (available < MAX_STACK_SIZE) {
prefix[available] = old_code
suffix[available] = first
available++
if ((available & code_mask) === 0 && available < MAX_STACK_SIZE) {
code_size++
code_mask += available
}
}
old_code = in_code
}
// Pop a pixel off the pixel stack.
top--
dstPixels[pi++] = pixelStack[top]
i++
}
for (i = pi; i < npix; i++) {
dstPixels[i] = 0 // clear missing pixels
}
return dstPixels
}

8
dep/jszip.LICENSE Normal file
View File

@ -0,0 +1,8 @@
The MIT License
Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

13
dep/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
<!DOCTYPE html><html><head> <!DOCTYPE html><html><head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>VRChat Emoji Manager</title> <title>VRChat Emoji and Sticker Manager</title>
<style> <style>
@ -67,18 +67,20 @@
flex-grow: 1; flex-grow: 1;
} }
</style> </style>
<style id="dynamicstyle"></style>
</head><body> </head><body>
<div id="emojigrid"></div> <div id="emojigrid"></div>
<div id="bottompanel"> <div id="bottompanel">
<div id="errorDiv"></div> <div id="errorDiv"></div>
<div>Default Animation Style: <select id="default_animation_style_select"><option value="aura">Aura</option><option value="bats">Bats</option><option value="bees">Bees</option><option value="bounce">Bounce</option><option value="cloud">Cloud</option><option value="confetti">Confetti</option><option value="crying">Crying</option><option value="dislike">Dislike</option><option value="fire">Fire</option><option value="idea">Idea</option><option value="lasers">Lasers</option><option value="like">Like</option><option value="magnet">Magnet</option><option value="mistletoe">Mistletoe</option><option value="money">Money</option><option value="noise">Noise</option><option value="orbit">Orbit</option><option value="pizza">Pizza</option><option value="rain">Rain</option><option value="rotate">Rotate</option><option value="shake">Shake</option><option value="snow">Snow</option><option value="snowball">Snowball</option><option value="spin">Spin</option><option value="splash">Splash</option><option value="stop">Stop</option><option value="zzz">ZZZ</option></select></div> <div class="emojistuff">Default Animation Style: <select id="default_animation_style_select"><option value="aura">Aura</option><option value="bats">Bats</option><option value="bees">Bees</option><option value="bounce">Bounce</option><option value="cloud">Cloud</option><option value="confetti">Confetti</option><option value="crying">Crying</option><option value="dislike">Dislike</option><option value="fire">Fire</option><option value="idea">Idea</option><option value="lasers">Lasers</option><option value="like">Like</option><option value="magnet">Magnet</option><option value="mistletoe">Mistletoe</option><option value="money">Money</option><option value="noise">Noise</option><option value="orbit">Orbit</option><option value="pizza">Pizza</option><option value="rain">Rain</option><option value="rotate">Rotate</option><option value="shake">Shake</option><option value="snow">Snow</option><option value="snowball">Snowball</option><option value="spin">Spin</option><option value="splash">Splash</option><option value="stop">Stop</option><option value="zzz">ZZZ</option></select></div>
<div><button id="addbtn">Add Emojis</button></div> <div><button id="modebtn">Sticker Mode</button></div>
<div><button id="deletebtn">Delete Emojis</button></div> <div><button id="addbtn">Add</button></div>
<div><button id="deletebtn">Delete</button></div>
<div><button id="importbtn">Import</button></div> <div><button id="importbtn">Import</button></div>
<div><button id="exportbtn">Export</button></div> <div><button id="exportbtn">Export</button></div>
<div><button id="clearbtn">Clear</button></div> <div><button id="clearbtn">Clear</button></div>
</div> </div>
<script src="dep/jszip.min.js"></script>
<script src="manage.js"></script> <script type="module" src="manage.js"></script>
</body></html> </body></html>

601
manage.js
View File

@ -1,34 +1,37 @@
const ANIMATION_STYLES = `default import * as db from "../db.js";
Aura import { parseGIF, decompressFrames } from "./dep/gifuct/index.js";
Bats
Bees var mode = localStorage.mode ||= "emoji";
Bounce if (mode == "sticker") stickerMode();
Cloud
Confetti function stickerMode() {
Crying localStorage.mode = mode = "sticker";
Dislike modebtn.innerText = "Emoji Mode";
Fire dynamicstyle.innerHTML = `.emojistuff {display: none}`;
Idea }
Lasers function emojiMode() {
Like localStorage.mode = mode = "emoji";
Magnet modebtn.innerText = "Sticker Mode";
Mistletoe dynamicstyle.innerHTML = ``;
Money }
Noise
Orbit modebtn.onclick = function switchMode() {
Pizza if (mode == "emoji") {
Rain stickerMode();
Rotate } else {
Shake emojiMode();
Snow }
Snowball loadToggleState();
Spin }
Splash
Stop
ZZZ`.split('\n');
const ANIMATION_STYLES = `default Aura Bats Bees Bounce Cloud Confetti Crying Dislike Fire Idea Lasers Like Magnet Mistletoe Money Noise Orbit Pizza Rain Rotate Shake Snow Snowball Spin Splash Stop ZZZ`.split(' ');
const animationStyleRegex = new RegExp(`(${ANIMATION_STYLES.join('|')})`, 'i');
function createAnimationStyleSelect() { function createAnimationStyleSelect() {
let select = document.createElement("select"); let select = document.createElement("select");
select.classList.add("emojistuff");
ANIMATION_STYLES.forEach(a => { ANIMATION_STYLES.forEach(a => {
let option = document.createElement("option"); let option = document.createElement("option");
option.innerText = a; option.innerText = a;
@ -43,24 +46,34 @@ function createAnimationStyleSelect() {
} }
db.getSetting("defaultAnimationStyle").then(defaultAnimationStyle => {
default_animation_style_select.value = defaultAnimationStyle || "aura";
});
default_animation_style_select.onchange = async function () {
await db.setSetting("defaultAnimationStyle", this.value);
var enabled = [...document.querySelectorAll("[data-state=enabled]")].filter(e => !e.querySelector("select").value);
if (enabled.length) {
default_animation_style_select.disabled = true;
await Promise.all(enabled.map(div => (async ()=>{
await div.disable();
await div.enable();
})()));
default_animation_style_select.disabled = false;
}
};
addbtn.onclick = async () => { addbtn.onclick = async () => {
var fileHandles = await showOpenFilePicker({ try {
id: "add", var fileHandles = await showOpenFilePicker({
multiple: true, id: "add",
types: [ multiple: true,
{ types: [ { description: "Image", accept: { "image/*": [], } } ]
description: "Any image supported by browser", });
accept: { } catch (error) {
"image/*": [], return;
} }
}
]
});
if (!fileHandles.length) return; if (!fileHandles.length) return;
var files = []; var files = [];
for (let fileHandle of fileHandles) { for (let fileHandle of fileHandles) {
@ -73,13 +86,11 @@ addbtn.onclick = async () => {
await addFiles(files); await addFiles(files);
}; };
document.body.onpaste = event => { document.body.onpaste = event => {
if (event.clipboardData.files.length) { if (event.clipboardData.files.length) {
addFiles(event.clipboardData.files); addFiles(event.clipboardData.files);
} }
}; };
document.body.ondragover = event => event.preventDefault(); document.body.ondragover = event => event.preventDefault();
document.body.ondrop = event => { document.body.ondrop = event => {
event.preventDefault(); event.preventDefault();
@ -88,56 +99,49 @@ document.body.ondrop = event => {
} }
}; };
async function addFiles(files) { async function addFiles(files) {
var newEmojis = [];
var errors = []; var errors = [];
for (let file of files) { for (let file of files) {
try { try {
let image = await loadImage(URL.createObjectURL(file));
/* let width = image.width;
let height = image.height;
const MIN = 64, MAX = 2048;
let aspectRatio = width / height;
if (aspectRatio < (MIN/MAX) || aspectRatio > (MAX/MIN)) throw new Error("Invalid aspect ratio");
if (height > width) {
if (height > MAX) {
height = MAX;
width = height * aspectRatio;
}
if (width < MIN) {
width = MIN;
height = width * (1/aspectRatio);
}
} else {
if (width > MAX) {
width = MAX;
height = width * (1/aspectRatio);
}
if (height < MIN) {
height = MIN;
width = height * aspectRatio;
}
} */
// Although VRChat will store the original image within 64-2048px, it looks like the game resizes it to 512x512.
let width = 512, height = 512;
let canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, width, height);
let data = canvas.toDataURL();
URL.revokeObjectURL(image.src);
let e = { let e = {
internalId: randomId(), internalId: randomId(),
date: new Date().toISOString() date: new Date().toISOString()
}; };
await chrome.storage.local.set({["data-"+e.internalId]: data}); if (file.type == "image/gif") {
newEmojis.push(e); let ss = await gifToSpritesheet(await file.arrayBuffer());
createEmojiSquare(e, data); e.animated = true;
e.data = file;
e.data_spritesheet = ss.data;
e.fps = ss.fps;
e.framecount = ss.framecount;
} else {
let image = await loadImage(URL.createObjectURL(file));
let width = image.width;
let height = image.height;
const MIN = 64, MAX = 2048;
let aspectRatio = width / height;
if (height > width) {
height = Math.max(MIN, Math.min(MAX, height));
width = height * aspectRatio;
} else {
width = Math.max(MIN, Math.min(MAX, width));
height = width * (1/aspectRatio);
}
let largestDim = Math.max(width, height);
let offsetX = (largestDim - width) / 2;
let offsetY = (largestDim - height) / 2;
let canvas = document.createElement("canvas");
canvas.width = largestDim;
canvas.height = largestDim;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, offsetX, offsetY, width, height);
let data = await new Promise(r => canvas.toBlob(r, "image/png"));
URL.revokeObjectURL(image.src);
e.animationStyle = file.name.match(animationStyleRegex)?.[1] || undefined;
e.data = data;
}
await db.storeImage(e);
createEmojiSquare(e);
errorDiv.innerText = ""; errorDiv.innerText = "";
} catch(error) { } catch(error) {
console.error(error); console.error(error);
@ -147,10 +151,7 @@ async function addFiles(files) {
if (errors.length) { if (errors.length) {
displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`); displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`);
} }
var {emojis} = await chrome.storage.local.get("emojis"); loadToggleState();
emojis ||= [];
emojis = emojis.concat(newEmojis);
await chrome.storage.local.set({emojis});
} }
@ -162,7 +163,7 @@ deletebtn.onclick = () => {
if (deleteMode) { if (deleteMode) {
deleteMode = false; deleteMode = false;
document.body.classList.remove("deletemode"); document.body.classList.remove("deletemode");
deletebtn.innerText = "Delete Emojis"; deletebtn.innerText = "Delete";
return; return;
} }
deleteMode = true; deleteMode = true;
@ -170,71 +171,35 @@ deletebtn.onclick = () => {
deletebtn.innerText = "Stop Delete"; deletebtn.innerText = "Stop Delete";
}; };
chrome.storage.local.get("defaultAnimationStyle").then(({defaultAnimationStyle}) => {
default_animation_style_select.value = defaultAnimationStyle || "aura";
});
default_animation_style_select.onchange = async function () {
chrome.storage.local.set({defaultAnimationStyle: this.value});
var enabled = [...document.querySelectorAll("[data-state=enabled]")].filter(e => !e.querySelector("select").value);
if (enabled.length) {
default_animation_style_select.disabled = true;
enabled.forEach(e => e.querySelector("select").disabled = true);
let {emojis} = await chrome.storage.local.get("emojis");
for (let div of enabled) {
div.dataset.state = "pending";
try {
await callContentScript("deleteEmoji", div.dataset.currentId);
} catch(error) {
displayError(error);
}
try {
let newEmoji = await callContentScript("createEmoji", div.querySelector("img").src, div.querySelector("select").value || default_animation_style_select.value);
emojis.find(e => e.internalId == div.dataset.internalId).currentId = newEmoji.id;
div.dataset.currentId = newEmoji.id;
div.dataset.state = "enabled";
} catch (error) {
displayError(error);
}
div.querySelector("select").disabled = false;
}
await chrome.storage.local.set({emojis});
default_animation_style_select.disabled = false;
}
};
importbtn.onclick = async () => { importbtn.onclick = async () => {
try { try {
var [fileHandle] = await showOpenFilePicker({ try {
id: "import", var [fileHandle] = await showOpenFilePicker({
types: [ id: "import",
{ types: [ { description: "ZIP or vrcem json", accept: { "application/zip": ".zip", "application/json": ".json" } } ]
description: "vrcem export file", });
accept: { } catch (error) {
"application/json": ".json", return;
} }
}
]
});
if (!fileHandle) return; if (!fileHandle) return;
var file = await fileHandle.getFile(); var file = await fileHandle.getFile();
var text = await file.text(); if (file.name.toLowerCase().endsWith(".json")) {
var store = JSON.parse(text); let text = await file.text();
var set = {}; let store = JSON.parse(text);
for (let key in store) { var imgs = await Promise.all(store.emojis.map(async emoji => {
if (key.startsWith("data-")) { var blob = await fetch(store[`data-${emoji.internalId}`]).then(res => res.blob());
set[key] = store[key]; return new File([blob], `${emoji.animationStyle}.png`);
} }));
} else {
var zip = await JSZip.loadAsync(file);
var imgs = zip.file(/^[^\/]*\.(?:png|gif)$/i);
imgs = await Promise.all(imgs.map(async img => {
var blob = await img.async("blob");
return new File([blob], img.name, {type: img.name.toLowerCase().endsWith('.gif') ? 'image/gif' : 'image.png'});
}));
} }
var {emojis} = chrome.storage.local.get("emojis"); await addFiles(imgs);
emojis ||= [];
store.emojis.forEach(emoji => {
var existing = emojis.find(e => e.internalId == emoji.internalId);
if (existing) Object.apply(existing, emoji);
else emojis.push(emoji);
});
set.emojis = emojis;
await chrome.storage.local.set(set);
location.reload();
} catch(error) { } catch(error) {
displayError(error); displayError(error);
} }
@ -242,13 +207,16 @@ importbtn.onclick = async () => {
exportbtn.onclick = async () => { exportbtn.onclick = async () => {
try { try {
var store = await chrome.storage.local.get(); var images = await db.getAllImages();
store = JSON.stringify(store); var zip = new JSZip();
var blob = new Blob([store],{type: "application/json"}); for (let image of images) {
zip.file(`${image.internalId}.${image.animationStyle}.${image.animated?'gif':'png'}`, image.data);
}
var blob = await zip.generateAsync({type: "blob"});
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
var a = document.createElement("a"); var a = document.createElement("a");
a.href = url; a.href = url;
a.download = `vrcem-${new Date().toISOString()}.json`; a.download = `vrcem-${new Date().toISOString()}.zip`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
@ -257,130 +225,145 @@ exportbtn.onclick = async () => {
}; };
clearbtn.onclick = async () => { clearbtn.onclick = async () => {
if (!confirm("Remove all images from manager? (This will not delete any on VRChat)")) return;
await db.deleteAllImages();
emojigrid.innerHTML = '';
};
(async function loadEmojis() {
errorDiv.innerText = "";
var images = await db.getAllImages();
if (!images.length) throw new Error("No emoji.");
for (let image of images) {
createEmojiSquare(image);
}
await loadToggleState();
})().catch(displayError);
async function refreshImages() {
console.debug("refreshImages");
var images = await db.getAllImages();
for (let image of images) {
if (!document.querySelector(`[data-internal-id="${image.internalId}"]`)) createEmojiSquare(image);
}
var ids = images.map(i => i.internalId);
for (let emojisquare of document.querySelectorAll(".emojisquare")) {
if (!ids.includes(emojisquare.dataset.internalId)) emojisquare.delete();
}
}
async function loadToggleState() {
console.debug("loadToggleState");
try { try {
if (!confirm("Remove all emojis from storage. Are you SURE?????")) return; var elements = document.querySelectorAll(".emojisquare");
await chrome.storage.local.clear(); if (elements.length === 0) return;
location.reload(); var currentFiles = await callContentScript("getFiles", mode);
var active = currentFiles?.map(e => e.id);
elements.forEach(e => {
e.dataset.state = active.includes(e.dataset["currentId_"+mode]) ? "enabled" : "disabled";
});
errorDiv.innerText = "";
} catch (error) { } catch (error) {
displayError(error); displayError(error);
} }
};
onfocus = () => loadToggleState().catch(displayError);
loadEmojis().catch(displayError);
async function loadEmojis() {
errorDiv.innerText = "";
var store = await chrome.storage.local.get();
if (!store.emojis) throw new Error("No emoji.");
for (let emoji of store.emojis) {
createEmojiSquare(emoji, store["data-" + emoji.internalId]);
}
await loadToggleState();
} }
onfocus = () => refreshImages().then(loadToggleState);
function createEmojiSquare(emoji, url) {
let div = document.createElement("div");
function createEmojiSquare({internalId, data, data_spritesheet, animationStyle, currentId_emoji, currentId_sticker, animated, fps, framecount}) {
var div = document.createElement("div");
div.className = "emojisquare"; div.className = "emojisquare";
div.dataset.internalId = emoji.internalId; div.dataset.internalId = internalId;
if (emoji.currentId) div.dataset.currentId = emoji.currentId; div.blob = data;
div.onclick = async function onEmojiClick(event) { if (currentId_emoji) div.dataset.currentId_emoji = currentId_emoji;
if (this.dataset.state == "pending") return; if (currentId_sticker) div.dataset.currentId_sticker = currentId_sticker;
var selectedState = this.dataset.state; if (animated) div.dataset.animated = "yes";
this.dataset.state = "pending";
div.querySelector("select").disabled = true;
errorDiv.innerText = "";
if (deleteMode) { Object.assign(div, {
let {emojis} = await chrome.storage.local.get("emojis"); async enable() {
emojis = emojis.filter(e => e.internalId != this.dataset.internalId); var lastState = this.dataset.state;
await chrome.storage.local.set({emojis}); this.dataset.state = "pending";
this.remove(); select.disabled = true;
if (this.dataset.currentId) callContentScript("deleteEmoji", this.dataset.currentId); errorDiv.innerText = "";
return;
}
if (emojigrid.querySelector(".emojisquare:not([data-state])")) {
try { try {
await loadToggleState(); if (animated && mode == "sticker") throw new Error("Stickers can't be animated");
this.dataurl ||= await blobToDataURL(animated ? data_spritesheet : data);
var newEmoji = await callContentScript("createFile", {
url: this.dataurl,
tag: animated ? "emojianimated" : mode,
animationStyle: mode != "sticker" && (select.value || default_animation_style_select.value),
frames: framecount,
framesOverTime: fps
});
} catch (error) { } catch (error) {
this.dataset.state = lastState;
select.disabled = false;
displayError(error); displayError(error);
this.dataset.state = selectedState;
return; return;
} }
} await db.updateImage(this.dataset.internalId, {["currentId_"+mode]: newEmoji.id});
this.dataset["currentId_"+mode] = newEmoji.id;
if (selectedState == "enabled") { this.dataset.state = "enabled";
// disable select.disabled = false;
callContentScript("deleteEmoji", this.dataset.currentId).then(() => { },
this.dataset.state = "disabled"; async disable() {
div.querySelector("select").disabled = false; var lastState = this.dataset.state;
}).catch(error => { this.dataset.state = "pending";
this.dataset.state = selectedState; select.disabled = true;
div.querySelector("select").disabled = false; errorDiv.innerText = "";
try {
await callContentScript("deleteFile", this.dataset["currentId_"+mode]);
await db.updateImage(this.dataset.internalId, {["currentId_"+mode]: null});
delete this.dataset["currentId_"+mode];
} catch (error) {
this.dataset.state = lastState;
select.disabled = false;
displayError(error); displayError(error);
}); return;
return; }
} this.dataset.state = "disabled";
// enable
callContentScript("createEmoji", this.querySelector("img").src, this.querySelector("select").value || default_animation_style_select.value).then(newEmoji => {
chrome.storage.local.get("emojis").then(({emojis}) => {
emojis.find(e => e.internalId == this.dataset.internalId).currentId = newEmoji.id;
chrome.storage.local.set({emojis});
this.dataset.currentId = newEmoji.id;
this.dataset.state = "enabled";
div.querySelector("select").disabled = false;
});
}).catch(error => {
this.dataset.state = selectedState;
div.querySelector("select").disabled = false; div.querySelector("select").disabled = false;
displayError(error); },
}); async toggle() {
if (this.dataset.state == "enabled") {
await this.disable();
} else if (this.dataset.state == "disabled") {
await this.enable();
}
},
async delete() {
await db.deleteImage(this.dataset.internalId);
if (this.dataset["currentId_emoji"]) callContentScript("deleteFile", this.dataset["currentId_emoji"]).catch(displayError);
if (this.dataset["currentId_sticker"]) callContentScript("deleteFile", this.dataset["currentId_sticker"]).catch(displayError);
this.remove();
}
});
div.onclick = function onEmojiClick() {
if (deleteMode) this.delete();
else this.toggle();
if (emojigrid.querySelector(".emojisquare:not([data-state])")) loadToggleState();
}; };
let imgdiv = document.createElement("div"); var imgdiv = document.createElement("div");
imgdiv.className = "imgcontainer"; imgdiv.className = "imgcontainer";
let img = document.createElement("img"); var img = document.createElement("img");
img.src = url; img.src = URL.createObjectURL(data);
imgdiv.appendChild(img); imgdiv.appendChild(img);
div.appendChild(imgdiv); div.appendChild(imgdiv);
let select = createAnimationStyleSelect(); var select = createAnimationStyleSelect();
select.value = emoji.animationStyle || ''; select.value = animationStyle || '';
select.onclick = event => event.stopPropagation(); select.onclick = event => event.stopPropagation();
select.onchange = async event => { select.onchange = async event => {
var {emojis} = await chrome.storage.local.get("emojis"); await db.updateImage(div.dataset.internalId, {animationStyle: event.target.value});
var e = emojis.find(e => e.internalId == div.dataset.internalId);
e.animationStyle = event.target.value;
await chrome.storage.local.set({emojis});
if (div.dataset.state == "enabled") { if (div.dataset.state == "enabled") {
//recreate await div.disable();
div.dataset.state = "pending"; await div.enable();
select.disabled = true;
callContentScript("deleteEmoji", div.dataset.currentId).finally(() => {
callContentScript("createEmoji", img.src, select.value || default_animation_style_select.value).then(newEmoji => {
chrome.storage.local.get("emojis").then(({emojis}) => {
emojis.find(e => e.internalId == div.dataset.internalId).currentId = newEmoji.id;
chrome.storage.local.set({emojis});
div.dataset.currentId = newEmoji.id;
div.dataset.state = "enabled";
select.disabled = false;
});
}).catch(error => {
displayError(error);
});
}).catch(error => {
displayError(error);
});
} }
}; };
div.appendChild(select); div.appendChild(select);
@ -390,17 +373,7 @@ function createEmojiSquare(emoji, url) {
} }
async function loadToggleState() {
console.debug("loadToggleState");
var elements = document.querySelectorAll(".emojisquare");
if (elements.length === 0) return;
var currentEmojis = await callContentScript("getEmojis");
var active = currentEmojis?.map(e => e.id);
elements.forEach(e => {
e.dataset.state = active.includes(e.dataset.currentId) ? "enabled" : "disabled";
});
errorDiv.innerText = "";
}
function displayError(error) { function displayError(error) {
console.error(error); console.error(error);
@ -414,6 +387,8 @@ function displayError(error) {
html += ` Click "Add Emojis" to get started.`; html += ` Click "Add Emojis" to get started.`;
if (error.includes("Could not establish connection. Receiving end does not exist.")) if (error.includes("Could not establish connection. Receiving end does not exist."))
html += `<br>Please reload your VRChat tab.`; html += `<br>Please reload your VRChat tab.`;
if (error.includes("You must upload a valid imageǃ"))
html += `<br> (NSFW images are not allowed)`;
errorDiv.innerHTML = html; errorDiv.innerHTML = html;
} }
@ -425,10 +400,16 @@ async function getVRchatTab() {
async function callContentScript(method, ...args) { async function callContentScript(method, ...args) {
var tab = await getVRchatTab(); var tab = await getVRchatTab();
var response = await chrome.tabs.sendMessage(tab.id, [method, ...args]); var {error, response} = await chrome.tabs.sendMessage(tab.id, [method, ...args]);
if (error) throw error;
console.debug(response); console.debug(response);
if (response.error) throw response.error; if (response.status == 429) {
else return response; throw new Error(`VRChat API ratelimited. Please wait ${response.retryAfter} seconds.`);
}
if (response.body.error) {
throw new Error(`VRChat API said: ${response.body.error.message}`);
}
return response.body;
}; };
@ -448,10 +429,10 @@ function loadImage(src) {
}); });
} }
function fileToDataURL(file) { function blobToDataURL(blob) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let reader = new FileReader(); let reader = new FileReader();
reader.readAsDataURL(file); reader.readAsDataURL(blob);
reader.onload = () => { reader.onload = () => {
resolve(reader.result); resolve(reader.result);
}; };
@ -466,4 +447,86 @@ function randomId() {
id += CHARS[Math.floor(Math.random() * CHARS.length)]; id += CHARS[Math.floor(Math.random() * CHARS.length)];
} }
return id; return id;
}
async function gifToSpritesheet(arrayBuffer) {
var gif = parseGIF(arrayBuffer);
var frames = decompressFrames(gif, true);
frames = frames.slice(0, 64);
var gifCanvas = document.createElement("canvas");
//gifCanvas.width = frames[0].dims.width;
//gifCanvas.height = frames[0].dims.height;
var gifCanvasCtx = gifCanvas.getContext("2d");
var spritesheetCanvas = document.createElement("canvas");
spritesheetCanvas.width = 1024;
spritesheetCanvas.height = 1024;
var spritesheetCanvasCtx = spritesheetCanvas.getContext('2d');
if (frames.length <= 4) {
var tileSize = 512;
var columns = 2;
} else if (frames.length > 4 && frames.length <= 16) {
var tileSize = 256;
var columns = 4;
} else if (frames.length > 16 && frames.length <= 64) {
var tileSize = 128;
var columns = 8;
}
frames.forEach((frame, index) => {
gifCanvas.width = frame.dims.width;
gifCanvas.height = frame.dims.height;
let imageData = new ImageData(frame.patch, frame.dims.width, frame.dims.height);
gifCanvasCtx.putImageData(imageData, 0, 0);
let x = (index % columns) * tileSize;
let y = Math.floor(index / columns) * tileSize;
if (frame.dims.height > frame.dims.width) {
var fh = tileSize;
var fw = tileSize * (frame.dims.width/frame.dims.height);
x += (tileSize - fw) / 2;
} else {
var fw = tileSize;
var fh = tileSize * (frame.dims.height/frame.dims.width);
y += (tileSize - fh) / 2;
}
spritesheetCanvasCtx.drawImage(gifCanvas, x, y, fw, fh);
});
var frameDelays = frames.map(frame => frame.delay);
var frameDelay = modeOfNumbers(frameDelays);
var fps = 1000 / frameDelay;
var data = await new Promise(r => spritesheetCanvas.toBlob(r, "image/png"));
return {
data,
fps,
framecount: frames.length
}
}
function modeOfNumbers(numbers) {
var counters = {};
for (var number of numbers) {
counters[number] ||= 0;
counters[number]++;
}
counters = Object.entries(counters);
var max = counters.reduce((max, val) => val[1] > max ? val[1] : max, 0);
var mode = Number(counters.find(x => x[1] == max)[0]);
return mode;
} }

View File

@ -1,16 +1,15 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "VRChat Emoji Manager", "name": "VRChat Emoji and Sticker Manager",
"version": "1.2023.11.1", "version": "1.2024.10.12",
"description": "Store more than 5 emoji, toggle them on and off and change their animation styles.", "description": "Store more than 9 emoji or stickers, toggle them on and off and change their animation styles.",
"homepage_url": "https://gitea.moe/lamp/vrchat-emoji-manager", "homepage_url": "https://gitea.moe/lamp/vrchat-emoji-manager",
"icons": { "icons": {
"128": "icon128.png" "128": "icon128.png"
}, },
"permissions": [ "permissions": [
"storage", "storage",
"unlimitedStorage", "unlimitedStorage"
"webRequest"
], ],
"host_permissions": [ "host_permissions": [
"https://vrchat.com/home*", "https://vrchat.com/home*",

View File

@ -1,19 +1,27 @@
# VRChat Emoji Manager # VRChat Emoji and Sticker Manager
VRChat Plus has a custom emoji feature but it is limited to 5 emojis and you cannot change their animation styles. This Chrome extension allows you to have a much larger collection of emojis and conveniently toggle them on and off when needed. VRChat Plus has a custom emoji and sticker feature but they are limited to 9 emojis or stickers and you cannot change the animation styles of the emojis. This Chrome extension allows you to have a much larger collection of emojis or stickers and conveniently toggle them on and off when needed.
Get it on the Chrome Web Store!!! https://chromewebstore.google.com/detail/vrchat-emoji-and-sticker/obmoelidfamikmdhgjeoacpmkfhohekb
![image](2023-10-26_20-50-20-123%20VRChat_Emoji_Manager_-_Google_Chrome.png) ![image](2023-10-26_20-50-20-123%20VRChat_Emoji_Manager_-_Google_Chrome.png)
## Install ## Manual install from source
Is it worth the $5 to publish this to Chrome Web Store?? 1. If you have git, run `git clone https://gitea.moe/lamp/vrchat-emoji-manager.git`. If not, [download](https://gitea.moe/lamp/vrchat-emoji-manager/archive/main.zip) and extract the zip.
1. [Download](https://gitea.moe/lamp/vrchat-emoji-manager/archive/main.zip) and unzip repository
2. Navigate to [chrome://extensions/](chrome://extensions/) 2. Navigate to [chrome://extensions/](chrome://extensions/)
3. Enable developer mode 3. Enable developer mode
4. Click "Load unpacked" 4. Click "Load unpacked"
5. Select the unzipped folder 5. Select the cloned or unzipped folder
6. Open https://vrchat.com/home, existing emojis should be imported 6. Open https://vrchat.com/home, existing emojis should be imported
7. Click extension icon to launch in tab or window 7. Click extension icon to launch in tab or window
IF ANY ISSUES REPORT HERE ### Update
1. Export your emojis/stickers so you have a backup
2. If you installed with git, run `git pull`. If not, download the zip again, and extract it to the SAME PATH
3. Go to [chrome://extensions/](chrome://extensions/) and reload the extension
Note, do not move or rename the extension folder, otherwise you have to re-add the extension to Chrome, which will change the extension ID, and your emojis will be gone. It is also a good idea to keep an export file as a backup.
IF ANY ISSUES REPORT [HERE](https://gitea.moe/lamp/vrchat-emoji-manager/issues?state=open)