Compare commits

..

47 Commits

Author SHA1 Message Date
lamp 7da3622437 streamline refresh 2025-05-03 22:31:38 -07:00
lamp db3a3e8851 Update readme.md 2024-10-19 18:36:41 -05:00
lamp 57c782f5f3 include dep licenses 2024-10-12 21:57:38 -07:00
lamp effb1a75d1 animated emojis 2024-10-12 21:48:16 -07:00
lamp 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
lamp b5f7208bc8 remove unused permission 2024-10-10 13:49:07 -07:00
lamp b2054992b2 Merge branch 'wip' of gitea.moe:lamp/vrchat-emoji-manager into wip 2024-10-09 21:29:28 -07:00
lamp 8b682f35bb update readme 2024-10-09 21:29:18 -07:00
lamp a74163b29a Merge branch 'wip' of gitea.moe:lamp/vrchat-emoji-manager into wip 2024-10-09 21:12:31 -07:00
lamp 349c7bd551 keep in sync with db 2024-10-09 21:12:25 -07:00
lamp 779624a722 add nsfw hint 2024-10-09 17:38:15 -07:00
lamp a31550e205 fix auto import 2024-10-09 17:34:11 -07:00
lamp d8167a3a3d fix delete 2024-10-09 17:33:58 -07:00
lamp 4ed59e5b66 fix db backcompat upgrade 2024-10-09 17:18:49 -07:00
lamp 0a6a6df29b display ratelimit seconds 2024-10-09 16:45:06 -07:00
lamp 2f76dd3df2 stickers 2024-10-09 16:14:42 -07:00
lamp 15198ca301 version 2024-10-08 21:45:17 -07:00
lamp f19ce27f2b backward compat 2024-10-08 20:27:11 -07:00
lamp 9f020332cc arrange code 2024-10-08 17:45:55 -07:00
lamp 5f1b0db47c consolidate redundant enable/disable code 2024-10-08 15:32:31 -07:00
lamp ebed204c9d zip import use addFiles 2024-10-08 14:49:00 -07:00
lamp 9ec6b8d9cd import export zip 2024-10-08 02:14:56 -07:00
lamp c5c47c111b basic indexeddb working 2024-10-08 00:41:57 -07:00
lamp 321cc93cf5 Update readme.md 2024-10-07 17:25:27 -05:00
lamp fcfe877437 Don't stretch images
Pad to square with transparency. No more restriction on aspect ratio
2024-10-07 15:14:26 -07:00
lamp 672b7b3fd0 version 2023-11-02 00:27:25 -07:00
lamp cd660e6ff4 fix bug 2023-11-01 23:34:58 -07:00
lamp 891fe17f48 resize to 512x512 2023-10-29 02:50:19 -07:00
lamp 031f9ead1f drop 2023-10-28 22:47:52 -07:00
lamp 1ebd2708e3 Merge branch 'main' of gitea.moe:lamp/vrchat-emoji-manager 2023-10-28 02:00:13 -07:00
lamp a2ca2065cd paste 2023-10-28 02:00:03 -07:00
lamp d66b7300f8 Update readme.md 2023-10-27 16:53:07 -05:00
lamp f520f8e460 2023-10-27 14:48:55 -07:00
lamp 4d9bb310e8 2023-10-27 14:11:45 -07:00
lamp 6adaa29ff1 Merge branch 'main' of gitea.moe:lamp/vrchat-emoji-manager 2023-10-27 13:53:59 -07:00
lamp e0e6cb70c4 '' 2023-10-27 13:53:46 -07:00
lamp b3d05bba21 Update readme.md 2023-10-26 23:18:01 -05:00
lamp 2b3b0cecaf 2023-10-26 21:13:33 -07:00
lamp 0122d67367 2023-10-26 19:55:13 -07:00
lamp 1022a0af34 2023-10-26 18:18:45 -07:00
lamp a8fc8881cf 2023-10-26 15:33:06 -07:00
lamp c3e8dd4c1d 2023-10-26 15:04:34 -07:00
lamp 715219e679 flexbox is ebic 2023-10-26 14:06:56 -07:00
lamp f9945c3647 2023-10-26 02:34:25 -07:00
lamp f748d0b771 2023-10-26 02:29:00 -07:00
lamp d1de5350e1 2023-10-26 01:14:49 -07:00
lamp 826c7ccb73 . 2023-10-25 23:47:19 -07:00
25 changed files with 1493 additions and 366 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

+13 -65
View File
@@ -1,74 +1,22 @@
import { getDb, getKnownEmojiIds, getKnownStickerIds, storeImage } from "./db.js";
import { importVrcFile } from "./common.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(["emoji"])).emoji || []; 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; await importVrcFile(file);
}
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 = {
currentId: emoji.id,
internalId: emoji.id,
animationStyle: emoji.animationStyle
};
await chrome.storage.local.set({[`data-${emojiDoc.internalId}`]: dataString});
emojiStore.push(emojiDoc);
} }
chrome.storage.local.set({emoji: 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.onloadend = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
}
async function listEmoji() {
return await fetch("https://vrchat.com/api/1/files?tag=emoji&n=100&offset=0").then(res => res.json());
}
async function getEmojiFile(id) {
return await fetch(`https://api.vrchat.cloud/api/1/file/${id}/1/file`);
}
async function deleteEmoji(id) {
return await fetch(`https://vrchat.com/api/1/file/${id}`, {method: "DELETE"});
}
async function createEmoji(file, animationStyle) {
var form = new FormData();
form.append("tag", "emoji");
form.append("animationStyle", animationStyle);
form.append("maskTag", "square");
form.append("file", file);
return await fetch("https://vrchat.com/api/1/file/image", {
method: "POST"
});
}
+21
View File
@@ -0,0 +1,21 @@
import { storeImage } from "./db.js";
export async function importVrcFile(file) {
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())
};
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);
return e;
}
+38 -16
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;
}); });
+144
View File
@@ -0,0 +1,144 @@
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);
if (storage.emojis) 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);
}
}
if (storage.defaultAnimationStyle) 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() {
console.time("getAllImages");
try {
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);
});
} finally {
console.timeEnd("getAllImages");
}
}
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
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
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
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))
}
@@ -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
}
@@ -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
}, {})
}
@@ -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
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
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
View File
File diff suppressed because one or more lines are too long
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html><html><head>
<style>
img {
width: 64px;
height: 64px;
}
</style>
</head><body>
<h1>Manage emoji</h1>
<p>Note: Changes made here do not affect VRChat. To commit animation style to VRChat you must toggle emoji off and back on again. If you a delete an enabled emoji, it will reappear.</p>
<table id="table">
<tr>
<th>Emoji</th>
<th>Animation Style</th>
<th>Delete</th>
</tr>
</table>
<h1>Add emoji</h1>
<input type="file" id="fileinput" accept="image/*" multiple /><input type="submit" id="submit" />
<script src="edit.js" charset="UTF-8"></script>
</body></html>
-114
View File
@@ -1,114 +0,0 @@
var animationStyles = `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');
load();
async function load() {
var store = await chrome.storage.local.get();
if (!store.emoji) throw new Error("No emoji.");
for (let emoji of store.emoji) {
await instantiateRow(store["data-" + emoji.internalId], emoji.internalId, emoji.animationStyle);
}
}
async function instantiateRow(url, internalId, animationStyle) {
let row = table.insertRow();
row.dataset.emojiId = internalId;
let [c0, c1, c2] = [row.insertCell(), row.insertCell(), row.insertCell()];
let img = document.createElement("img");
img.src = url;
c0.appendChild(img);
let select = document.createElement("select");
animationStyles.forEach(a => {
let option = document.createElement("option");
option.innerText = a;
option.value = a.toLowerCase();
select.appendChild(option);
});
select.value = animationStyle;
select.onchange = event => {
setAnimationStyle(internalId, event.target.value);
};
c1.appendChild(select);
let button = document.createElement("button");
button.innerText = "🗑️";
button.onclick = () => {
deleteEmoji(internalId);
row.remove();
};
c2.appendChild(button);
}
async function deleteEmoji(internalId) {
if (!confirm("Delete emoji from storage. Are you sure?")) return;
var {emoji} = await chrome.storage.local.get("emoji");
emoji = emoji.filter(e => e.internalId != internalId);
await chrome.storage.local.set({emoji});
}
async function setAnimationStyle(internalId, animationStyle) {
var {emoji} = await chrome.storage.local.get("emoji");
var e = emoji.find(e => e.internalId == internalId);
e.animationStyle = animationStyle;
await chrome.storage.local.set({emoji});
}
submit.onclick = async () => {
submit.disabled = true;
var newEmoji = [];
for (let file of fileinput.files) {
let e = {
internalId: Math.random().toString(),
animationStyle: "aura",
};
let data = await fileToDataURL(file);
//todo convert/resize
await chrome.storage.local.set({["data-"+e.internalId]: data});
newEmoji.push(e);
instantiateRow(data, e.internalId, e.animationStyle);
}
var {emoji} = await chrome.storage.local.get("emoji");
emoji = emoji.concat(newEmoji);
await chrome.storage.local.set({emoji});
submit.disabled = false;
};
function fileToDataURL(file) {
return new Promise(function(resolve, reject){
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html><html><head>
<meta charset="UTF-8" />
<title>VRChat Emoji and Sticker Manager</title>
<style>
body {
margin: 0px;
height: 100vh;
display: flex;
flex-direction: column;
}
.deletemode {
cursor: crosshair;
}
#emojigrid {
flex-grow: 1;
display: flex;
flex-wrap: wrap;
overflow: auto;
padding: 8px;
align-content: flex-start;
}
.emojisquare {
border: 1px solid gray;
margin: 1px;
padding: 4px;
}
.emojisquare .imgcontainer {
width: 64px;
height: 64px;
margin: auto;
display: flex;
}
.emojisquare .imgcontainer img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
[data-state=enabled] {
background-color:rgba(0, 255, 0, 0.5);
}
[data-state=disabled] {
background-color:rgba(255, 0, 0, 0.5);
}
[data-state=pending] {
background-color:rgba(255, 255, 0, 0.5);
}
#bottompanel {
border: 1px solid gray;
display: flex;
flex-wrap: wrap;
align-items: center;
}
#bottompanel div {
margin: 8px;
}
#errorDiv {
color: red;
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><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>
</body></html>
+527
View File
@@ -0,0 +1,527 @@
import * as db from "../db.js";
import { parseGIF, decompressFrames } from "./dep/gifuct/index.js";
import { importVrcFile } from "./common.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();
}
refresh();
}
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() {
let select = document.createElement("select");
select.classList.add("emojistuff");
ANIMATION_STYLES.forEach(a => {
let option = document.createElement("option");
option.innerText = a;
if (a == "default") {
option.value = '';
} else {
option.value = a.toLowerCase();
}
select.appendChild(option);
});
return select;
}
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;
}
if (!fileHandles.length) return;
var files = [];
for (let fileHandle of fileHandles) {
try {
files.push(await fileHandle.getFile());
} catch (error) {
displayError(error);
}
}
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();
if (event.dataTransfer.files.length) {
addFiles(event.dataTransfer.files);
}
};
async function addFiles(files) {
var errors = [];
for (let file of files) {
try {
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);
errorDiv.innerText = "";
} catch(error) {
console.error(error);
errors.push(error.message);
}
}
await refresh();
if (errors.length) {
displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`);
}
}
var deleteMode = false;
deletebtn.onclick = () => {
if (deleteMode) {
deleteMode = false;
document.body.classList.remove("deletemode");
deletebtn.innerText = "Delete";
return;
}
deleteMode = true;
document.body.classList.add("deletemode");
deletebtn.innerText = "Stop Delete";
};
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;
}
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'});
}));
}
await addFiles(imgs);
} catch(error) {
displayError(error);
}
};
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 url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = `vrcem-${new Date().toISOString()}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
displayError(error);
}
};
clearbtn.onclick = async () => {
if (!confirm("Remove all images from manager? (This will not delete any on VRChat)")) return;
await db.deleteAllImages();
emojigrid.innerHTML = '';
};
refresh();
onfocus = refresh;
async function refresh() {
try {
var vrc_files = await callContentScript("getFiles", mode);
var images = await db.getAllImages();
if (!images.length) throw new Error("No emoji.");
var currentIds = images.map(i => i["currentId_"+mode]);
for (let f of vrc_files) {
if (!currentIds.includes(f.id)) {
let i = await importVrcFile(f);
images.push(i);
}
}
for (let image of images) {
if (!document.querySelector(`[data-internal-id="${image.internalId}"]`)) createEmojiSquare(image);
}
var internalIds = images.map(i => i.internalId);
var vrc_file_ids = vrc_files.map(e => e.id);
for (let emojisquare of document.querySelectorAll(".emojisquare")) {
if (!internalIds.includes(emojisquare.dataset.internalId)) emojisquare.delete();
else {
emojisquare.dataset.state = vrc_file_ids.includes(emojisquare.dataset["currentId_"+mode]) ? "enabled" : "disabled";
}
}
errorDiv.innerText = "";
} catch (error) {
displayError(error);
}
}
function createEmojiSquare({internalId, data, data_spritesheet, animationStyle, currentId_emoji, currentId_sticker, animated, fps, framecount}) {
var 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";
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);
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 = "";
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);
return;
}
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();
}
});
div.onclick = function onEmojiClick() {
if (deleteMode) this.delete();
else this.toggle();
if (emojigrid.querySelector(".emojisquare:not([data-state])")) refresh();
};
var imgdiv = document.createElement("div");
imgdiv.className = "imgcontainer";
var img = document.createElement("img");
img.src = URL.createObjectURL(data);
imgdiv.appendChild(img);
div.appendChild(imgdiv);
var select = createAnimationStyleSelect();
select.value = animationStyle || '';
select.onclick = event => event.stopPropagation();
select.onchange = async event => {
await db.updateImage(div.dataset.internalId, {animationStyle: event.target.value});
if (div.dataset.state == "enabled") {
await div.disable();
await div.enable();
}
};
div.appendChild(select);
emojigrid.appendChild(div);
return div;
}
function displayError(error) {
console.error(error);
error = error.message || String(error);
var html = error;
if (error.includes("VRChat tab not found."))
html += `<br>Please open <a href=\"https://vrchat.com/home\" target=\"_blank\">https://vrchat.com/home</a>`;
if (error.includes("Missing Credentials"))
html += `<br>Please log in to VRChat.com`;
if (error.includes("No emoji."))
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;
}
async function getVRchatTab() {
var tab = (await chrome.tabs.query({url:"https://vrchat.com/home*"}))?.[0];
if (!tab) throw new Error("VRChat tab not found.");
return tab;
}
async function callContentScript(method, ...args) {
var tab = await getVRchatTab();
var {error, response} = await chrome.tabs.sendMessage(tab.id, [method, ...args]);
if (error) throw error;
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;
};
function loadImage(src) {
return new Promise((resolve, reject) => {
var image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = src;
});
}
function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
}
function randomId() {
let id = "";
const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
for (var i = 0; i < 16; i++) {
id += CHARS[Math.floor(Math.random() * CHARS.length)];
}
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;
}
+10 -5
View File
@@ -1,11 +1,15 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "VRChat Emoji Manager", "name": "VRChat Emoji and Sticker Manager",
"version": "0.0.1", "version": "1.2025.5.3",
"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",
"icons": {
"128": "icon128.png"
},
"permissions": [ "permissions": [
"storage", "storage",
"unlimitedStorage", "unlimitedStorage"
"webRequest"
], ],
"host_permissions": [ "host_permissions": [
"https://vrchat.com/home*", "https://vrchat.com/home*",
@@ -26,7 +30,8 @@
"service_worker": "background.js", "service_worker": "background.js",
"type": "module" "type": "module"
}, },
"options_page": "manage.html",
"action": { "action": {
"default_popup": "toggle.html" "default_popup": "popup.html"
} }
} }
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html><html><head>
<style>
body {
white-space: nowrap;
text-align: center;
}
body div {
margin: 2px;
}
</style>
</head><body>
<h2>VRChat Emoji & Sticker Manager</h2>
<div><button id="btn1">Launch in new tab</button></div>
<div><button id="btn2">Launch in pop up window</button></div>
<script src="popup.js"></script>
</body></html>
+12
View File
@@ -0,0 +1,12 @@
btn1.onclick = () => open("manage.html");
btn2.onclick = () => {
chrome.windows.create({
url: "manage.html",
focused: true,
type: "popup"
});
};
var width = Math.max(btn1.clientWidth, btn2.clientWidth);
btn1.style.width = width + "px";
btn2.style.width = width + "px";
+27
View File
@@ -0,0 +1,27 @@
# VRChat Emoji and Sticker 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
![image](2023-10-26_20-50-20-123%20VRChat_Emoji_Manager_-_Google_Chrome.png)
## Manual install from source
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.
2. Navigate to [chrome://extensions/](chrome://extensions/)
3. Enable developer mode
4. Click "Load unpacked"
5. Select the cloned or 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)
-39
View File
@@ -1,39 +0,0 @@
<!DOCTYPE html><html><head>
<style>
#emojigrid {
display: flex;
flex-wrap: wrap;
width: 269px;
}
.emojisquare {
width: 50px;
height: 50px;
border: 1px solid gray;
margin: 1px;
display: flex;
}
.emojisquare img {
width: 48px;
height: 48px;
margin: auto;
}
.enabled {
background-color:rgba(0, 255, 0, 0.5);
}
.disabled {
background-color:rgba(255, 0, 0, 0.5);
}
.pending {
background-color:rgba(255, 255, 0, 0.5);
}
#errorDiv {
color: red;
}
</style>
</head><body>
<div id="emojigrid"></div>
<div id="errorDiv"></div>
<script src="toggle.js"></script>
</body></html>
-104
View File
@@ -1,104 +0,0 @@
loadEmojis().catch(displayError);
async function loadEmojis() {
errorDiv.innerText = "";
var store = await chrome.storage.local.get();
if (!store.emoji) throw new Error("No emoji.");
for (let emoji of store.emoji) {
let div = document.createElement("div");
div.dataset.animationStyle = emoji.animationStyle;
div.dataset.internalId = emoji.internalId;
div.dataset.currentId = emoji.currentId;
div.classList.add("emojisquare");
div.onclick = toggleEmoji;
let img = document.createElement("img");
img.src = store["data-" + emoji.internalId];
div.appendChild(img);
emojigrid.appendChild(div);
}
await loadToggleState();
}
async function 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 => {
var yes = active.includes(e.dataset.currentId);
e.classList.add(yes ? "enabled" : "disabled");
e.classList.remove(yes ? "disabled" : "enabled");
});
}
function displayError(error) {
console.error(error);
error = error.message || String(error);
var html = error;
if (error.includes("VRChat tab not found."))
html += `<br>Please open <a href=\"https://vrchat.com/home\" target=\"_blank\">https://vrchat.com/home</a>`;
if (error.includes("Missing Credentials"))
html += `<br>Please log in to VRChat.com`;
if (error.includes("No emoji."))
html += ` Log in to <a href=\"https://vrchat.com/home\" target=\"_blank\">https://vrchat.com/home</a> and then reload.`;
errorDiv.innerHTML = html;
}
async function getVRchatTab() {
var tab = (await chrome.tabs.query({url:"https://vrchat.com/home*"}))?.[0];
if (!tab) throw new Error("VRChat tab not found.");
return tab;
}
async function callContentScript(method, ...args) {
var tab = await getVRchatTab();
var response = await chrome.tabs.sendMessage(tab.id, [method, ...args]);
console.debug(response);
if (response.error) throw response.error;
else return response;
};
async function toggleEmoji(event) {
if (this.classList.contains("pending")) return;
this.classList.add("pending");
errorDiv.innerText = "";
if (document.querySelectorAll(".enabled").length + document.querySelectorAll(".disabled").length < document.querySelectorAll(".emojisquare").length) {
try {
await loadToggleState();
} catch (error) {
displayError(error);
this.classList.remove("pending");
return;
}
}
if (this.classList.contains("enabled")) {
// disable
callContentScript("deleteEmoji", this.dataset.currentId).then(() => {
this.classList.remove("pending");
this.classList.remove("enabled");
this.classList.add("disabled");
}).catch(error => {
this.classList.remove("pending");
displayError(error);
});
} else {
// enable
callContentScript("createEmoji", this.querySelector("img").src, this.dataset.animationStyle).then(newEmoji => {
chrome.storage.local.get("emoji").then(({emoji}) => {
emoji.find(e => e.internalId == this.dataset.internalId).currentId = newEmoji.id;
chrome.storage.local.set({emoji});
this.dataset.currentId = newEmoji.id;
this.classList.remove("pending");
this.classList.remove("disabled");
this.classList.add("enabled");
});
}).catch(error => {
this.classList.remove("pending");
displayError(error);
});
}
}