Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

16 changed files with 349 additions and 1167 deletions

View File

@ -1,36 +1,50 @@
import { getDb, getKnownEmojiIds, getKnownStickerIds, storeImage } from "./db.js";
getDb();
var functions = {
async storeFiles(files, type) {
console.debug("storeFiles", files, type);
var knownIds = type == "sticker" ? await getKnownStickerIds() : await getKnownEmojiIds();
console.debug("knownIds", knownIds);
for (let file of files) {
if (knownIds.includes(file.id)) continue;
console.log("store", file.id);
let e = {
date: file.versions[1].created_at,
currentId_emoji: file.tags.includes("emoji") ? file.id : null,
currentId_sticker: file.tags.includes("sticker") ? file.id : null,
internalId: file.id,
animationStyle: file.animationStyle,
data: await fetch(file.versions[1].file.url).then(res => res.blob())
async storeEmojis(emojis) {
console.debug("storeEmojis", emojis);
var emojiStore = (await chrome.storage.local.get(["emojis"])).emojis || [];
loop1: for (let emoji of emojis) {
for (let existingEmoji of emojiStore) {
if (existingEmoji.currentId == emoji.id) continue loop1;
}
console.log("store", emoji.id);
var blob = await fetch(emoji.versions[1].file.url).then(res => res.blob());
var dataString = await blobToDataURL(blob);
var emojiDoc = {
date: emoji.versions[1].created_at,
currentId: emoji.id,
internalId: emoji.id,
animationStyle: emoji.animationStyle
};
if (file.tags.includes("animated")) {
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);
await chrome.storage.local.set({[`data-${emojiDoc.internalId}`]: dataString});
emojiStore.push(emojiDoc);
}
chrome.storage.local.set({emojis: emojiStore});
}
};
chrome.runtime.onMessage.addListener(function([method, ...args], sender, sendResponse) {
console.debug(arguments);
functions[method].apply(null, args).then(sendResponse);
functions[method]?.apply(null, args).then(sendResponse).catch(error => sendResponse({error: error.toString()}));;
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,54 +1,32 @@
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 = {
async getFiles(tag = "emoji") {
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 getEmojis() {
return await fetch("https://vrchat.com/api/1/files?tag=emoji&n=100&offset=0").then(res => res.json());
},
async deleteFile(id) {
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 deleteEmoji(id) {
return await fetch(`https://vrchat.com/api/1/file/${id}`, {method: "DELETE"}).then(res => res.json());
},
async createFile({url, tag = "emoji", animationStyle, frames, framesOverTime}) {
async createEmoji(url, animationStyle) {
var blob = await fetch(url).then(res => res.blob());
var form = new FormData();
form.append("tag", tag);
if (animationStyle) form.append("animationStyle", animationStyle);
if (frames) form.append("frames", frames);
if (framesOverTime) form.append("framesOverTime", framesOverTime);
form.append("tag", "emoji");
form.append("animationStyle", animationStyle);
form.append("maskTag", "square");
form.append("file", blob);
var res = await fetch("https://vrchat.com/api/1/file/image", {
return await fetch("https://vrchat.com/api/1/file/image", {
method: "POST",
body: form
});
return {
status: res.status,
body: await res.json(),
retryAfter: res.headers.get("Retry-After") || undefined
};
}).then(res => res.json());
}
};
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) {
console.debug(arguments);
functions[method].apply(null, args)
.then(response => sendResponse({response}))
.catch(error => sendResponse({error: error.toString()}));
functions[method]?.apply(null, args).then(sendResponse).catch(error => sendResponse({error: error.toString()}));
return true;
});

139
db.js
View File

@ -1,139 +0,0 @@
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);
});
}

View File

@ -1,21 +0,0 @@
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.

View File

@ -1,26 +0,0 @@
/**
* 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
}

View File

@ -1,85 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,78 +0,0 @@
// 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

@ -1,201 +0,0 @@
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

View File

@ -1,118 +0,0 @@
/**
* 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
}

View File

@ -1,8 +0,0 @@
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

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
<!DOCTYPE html><html><head>
<meta charset="UTF-8" />
<title>VRChat Emoji and Sticker Manager</title>
<title>VRChat Emoji Manager</title>
<style>
@ -67,20 +67,18 @@
flex-grow: 1;
}
</style>
<style id="dynamicstyle"></style>
</head><body>
<div id="emojigrid"></div>
<div id="bottompanel">
<div id="errorDiv"></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="modebtn">Sticker Mode</button></div>
<div><button id="addbtn">Add</button></div>
<div><button id="deletebtn">Delete</button></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><button id="addbtn">Add Emojis</button></div>
<div><button id="deletebtn">Delete Emojis</button></div>
<div><button id="importbtn">Import</button></div>
<div><button id="exportbtn">Export</button></div>
<div><button id="clearbtn">Clear</button></div>
</div>
<script src="dep/jszip.min.js"></script>
<script type="module" src="manage.js"></script>
<script src="manage.js"></script>
</body></html>

575
manage.js
View File

@ -1,37 +1,34 @@
import * as db from "../db.js";
import { parseGIF, decompressFrames } from "./dep/gifuct/index.js";
var mode = localStorage.mode ||= "emoji";
if (mode == "sticker") stickerMode();
function stickerMode() {
localStorage.mode = mode = "sticker";
modebtn.innerText = "Emoji Mode";
dynamicstyle.innerHTML = `.emojistuff {display: none}`;
}
function emojiMode() {
localStorage.mode = mode = "emoji";
modebtn.innerText = "Sticker Mode";
dynamicstyle.innerHTML = ``;
}
modebtn.onclick = function switchMode() {
if (mode == "emoji") {
stickerMode();
} else {
emojiMode();
}
loadToggleState();
}
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');
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('\n');
function createAnimationStyleSelect() {
let select = document.createElement("select");
select.classList.add("emojistuff");
ANIMATION_STYLES.forEach(a => {
let option = document.createElement("option");
option.innerText = a;
@ -46,34 +43,24 @@ 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 () => {
try {
var fileHandles = await showOpenFilePicker({
id: "add",
multiple: true,
types: [ { description: "Image", accept: { "image/*": [], } } ]
});
} catch (error) {
return;
types: [
{
description: "Any image supported by browser",
accept: {
"image/*": [],
}
}
]
});
if (!fileHandles.length) return;
var files = [];
for (let fileHandle of fileHandles) {
@ -86,11 +73,13 @@ addbtn.onclick = async () => {
await addFiles(files);
};
document.body.onpaste = event => {
if (event.clipboardData.files.length) {
addFiles(event.clipboardData.files);
}
};
document.body.ondragover = event => event.preventDefault();
document.body.ondrop = event => {
event.preventDefault();
@ -99,49 +88,56 @@ document.body.ondrop = event => {
}
};
async function addFiles(files) {
var newEmojis = [];
var errors = [];
for (let file of files) {
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 = {
internalId: randomId(),
date: new Date().toISOString()
};
if (file.type == "image/gif") {
let ss = await gifToSpritesheet(await file.arrayBuffer());
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);
await chrome.storage.local.set({["data-"+e.internalId]: data});
newEmojis.push(e);
createEmojiSquare(e, data);
errorDiv.innerText = "";
} catch(error) {
console.error(error);
@ -151,7 +147,10 @@ async function addFiles(files) {
if (errors.length) {
displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`);
}
loadToggleState();
var {emojis} = await chrome.storage.local.get("emojis");
emojis ||= [];
emojis = emojis.concat(newEmojis);
await chrome.storage.local.set({emojis});
}
@ -163,7 +162,7 @@ deletebtn.onclick = () => {
if (deleteMode) {
deleteMode = false;
document.body.classList.remove("deletemode");
deletebtn.innerText = "Delete";
deletebtn.innerText = "Delete Emojis";
return;
}
deleteMode = true;
@ -171,35 +170,71 @@ deletebtn.onclick = () => {
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 () => {
try {
try {
var [fileHandle] = await showOpenFilePicker({
id: "import",
types: [ { description: "ZIP or vrcem json", accept: { "application/zip": ".zip", "application/json": ".json" } } ]
});
} catch (error) {
return;
types: [
{
description: "vrcem export file",
accept: {
"application/json": ".json",
}
}
]
});
if (!fileHandle) return;
var file = await fileHandle.getFile();
if (file.name.toLowerCase().endsWith(".json")) {
let text = await file.text();
let store = JSON.parse(text);
var imgs = await Promise.all(store.emojis.map(async emoji => {
var blob = await fetch(store[`data-${emoji.internalId}`]).then(res => res.blob());
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 text = await file.text();
var store = JSON.parse(text);
var set = {};
for (let key in store) {
if (key.startsWith("data-")) {
set[key] = store[key];
}
await addFiles(imgs);
}
var {emojis} = chrome.storage.local.get("emojis");
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) {
displayError(error);
}
@ -207,16 +242,13 @@ importbtn.onclick = async () => {
exportbtn.onclick = async () => {
try {
var images = await db.getAllImages();
var zip = new JSZip();
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 store = await chrome.storage.local.get();
store = JSON.stringify(store);
var blob = new Blob([store],{type: "application/json"});
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = `vrcem-${new Date().toISOString()}.zip`;
a.download = `vrcem-${new Date().toISOString()}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
@ -225,145 +257,130 @@ exportbtn.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 = '';
try {
if (!confirm("Remove all emojis from storage. Are you SURE?????")) return;
await chrome.storage.local.clear();
location.reload();
} catch (error) {
displayError(error);
}
};
onfocus = () => loadToggleState().catch(displayError);
(async function loadEmojis() {
loadEmojis().catch(displayError);
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);
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();
})().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 {
var elements = document.querySelectorAll(".emojisquare");
if (elements.length === 0) return;
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) {
displayError(error);
}
}
onfocus = () => refreshImages().then(loadToggleState);
function createEmojiSquare({internalId, data, data_spritesheet, animationStyle, currentId_emoji, currentId_sticker, animated, fps, framecount}) {
var div = document.createElement("div");
function createEmojiSquare(emoji, url) {
let div = document.createElement("div");
div.className = "emojisquare";
div.dataset.internalId = internalId;
div.blob = data;
if (currentId_emoji) div.dataset.currentId_emoji = currentId_emoji;
if (currentId_sticker) div.dataset.currentId_sticker = currentId_sticker;
if (animated) div.dataset.animated = "yes";
div.dataset.internalId = emoji.internalId;
if (emoji.currentId) div.dataset.currentId = emoji.currentId;
div.onclick = async function onEmojiClick(event) {
if (this.dataset.state == "pending") return;
var selectedState = this.dataset.state;
this.dataset.state = "pending";
div.querySelector("select").disabled = true;
errorDiv.innerText = "";
Object.assign(div, {
async enable() {
var lastState = this.dataset.state;
this.dataset.state = "pending";
select.disabled = true;
errorDiv.innerText = "";
try {
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) {
this.dataset.state = lastState;
select.disabled = false;
displayError(error);
if (deleteMode) {
let {emojis} = await chrome.storage.local.get("emojis");
emojis = emojis.filter(e => e.internalId != this.dataset.internalId);
await chrome.storage.local.set({emojis});
this.remove();
if (this.dataset.currentId) callContentScript("deleteEmoji", this.dataset.currentId);
return;
}
await db.updateImage(this.dataset.internalId, {["currentId_"+mode]: newEmoji.id});
this.dataset["currentId_"+mode] = newEmoji.id;
this.dataset.state = "enabled";
select.disabled = false;
},
async disable() {
var lastState = this.dataset.state;
this.dataset.state = "pending";
select.disabled = true;
errorDiv.innerText = "";
if (emojigrid.querySelector(".emojisquare:not([data-state])")) {
try {
await callContentScript("deleteFile", this.dataset["currentId_"+mode]);
await db.updateImage(this.dataset.internalId, {["currentId_"+mode]: null});
delete this.dataset["currentId_"+mode];
await loadToggleState();
} catch (error) {
this.dataset.state = lastState;
select.disabled = false;
displayError(error);
this.dataset.state = selectedState;
return;
}
}
if (selectedState == "enabled") {
// disable
callContentScript("deleteEmoji", this.dataset.currentId).then(() => {
this.dataset.state = "disabled";
div.querySelector("select").disabled = false;
},
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();
}
}).catch(error => {
this.dataset.state = selectedState;
div.querySelector("select").disabled = false;
displayError(error);
});
return;
}
div.onclick = function onEmojiClick() {
if (deleteMode) this.delete();
else this.toggle();
if (emojigrid.querySelector(".emojisquare:not([data-state])")) loadToggleState();
// 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;
displayError(error);
});
};
var imgdiv = document.createElement("div");
let imgdiv = document.createElement("div");
imgdiv.className = "imgcontainer";
var img = document.createElement("img");
img.src = URL.createObjectURL(data);
let img = document.createElement("img");
img.src = url;
imgdiv.appendChild(img);
div.appendChild(imgdiv);
var select = createAnimationStyleSelect();
select.value = animationStyle || '';
let select = createAnimationStyleSelect();
select.value = emoji.animationStyle || '';
select.onclick = event => event.stopPropagation();
select.onchange = async event => {
await db.updateImage(div.dataset.internalId, {animationStyle: event.target.value});
var {emojis} = await chrome.storage.local.get("emojis");
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") {
await div.disable();
await div.enable();
//recreate
div.dataset.state = "pending";
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);
@ -373,7 +390,17 @@ function createEmojiSquare({internalId, data, data_spritesheet, animationStyle,
}
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) {
console.error(error);
@ -387,8 +414,6 @@ function displayError(error) {
html += ` Click "Add Emojis" to get started.`;
if (error.includes("Could not establish connection. Receiving end does not exist."))
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;
}
@ -400,16 +425,10 @@ async function getVRchatTab() {
async function callContentScript(method, ...args) {
var tab = await getVRchatTab();
var {error, response} = await chrome.tabs.sendMessage(tab.id, [method, ...args]);
if (error) throw error;
var response = await chrome.tabs.sendMessage(tab.id, [method, ...args]);
console.debug(response);
if (response.status == 429) {
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;
if (response.error) throw response.error;
else return response;
};
@ -429,10 +448,10 @@ function loadImage(src) {
});
}
function blobToDataURL(blob) {
function fileToDataURL(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsDataURL(blob);
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
@ -448,85 +467,3 @@ function randomId() {
}
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,15 +1,16 @@
{
"manifest_version": 3,
"name": "VRChat Emoji and Sticker Manager",
"version": "1.2024.10.12",
"description": "Store more than 9 emoji or stickers, toggle them on and off and change their animation styles.",
"name": "VRChat Emoji Manager",
"version": "1.2023.11.1",
"description": "Store more than 5 emoji, toggle them on and off and change their animation styles.",
"homepage_url": "https://gitea.moe/lamp/vrchat-emoji-manager",
"icons": {
"128": "icon128.png"
},
"permissions": [
"storage",
"unlimitedStorage"
"unlimitedStorage",
"webRequest"
],
"host_permissions": [
"https://vrchat.com/home*",

View File

@ -1,27 +1,19 @@
# VRChat Emoji and Sticker Manager
# VRChat Emoji Manager
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
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.
![image](2023-10-26_20-50-20-123%20VRChat_Emoji_Manager_-_Google_Chrome.png)
## Manual install from source
## Install
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.
Is it worth the $5 to publish this to Chrome Web Store??
1. [Download](https://gitea.moe/lamp/vrchat-emoji-manager/archive/main.zip) and unzip repository
2. Navigate to [chrome://extensions/](chrome://extensions/)
3. Enable developer mode
4. Click "Load unpacked"
5. Select the cloned or unzipped folder
5. Select the unzipped folder
6. Open https://vrchat.com/home, existing emojis should be imported
7. Click extension icon to launch in tab or window
### 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)
IF ANY ISSUES REPORT HERE