Compare commits

..

11 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
16 changed files with 815 additions and 93 deletions
+2 -9
View File
@@ -1,4 +1,5 @@
import { getDb, getKnownEmojiIds, getKnownStickerIds, storeImage } from "./db.js"; import { getDb, getKnownEmojiIds, getKnownStickerIds, storeImage } from "./db.js";
import { importVrcFile } from "./common.js";
getDb(); getDb();
@@ -9,15 +10,7 @@ var functions = {
console.debug("knownIds", knownIds); console.debug("knownIds", knownIds);
for (let file of files) { for (let file of files) {
if (knownIds.includes(file.id)) continue; if (knownIds.includes(file.id)) continue;
console.log("store", file.id); await importVrcFile(file);
await storeImage({
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())
});
} }
} }
}; };
+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;
}
+4 -2
View File
@@ -15,11 +15,13 @@ var functions = {
retryAfter: res.headers.get("Retry-After") || undefined retryAfter: res.headers.get("Retry-After") || undefined
}; };
}, },
async createFile(url, tag = "emoji", 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", tag); form.append("tag", tag);
if (tag == "emoji") 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);
var res = await fetch("https://vrchat.com/api/1/file/image", { var res = await fetch("https://vrchat.com/api/1/file/image", {
+7 -2
View File
@@ -23,7 +23,7 @@ function initDb() {
db.createObjectStore("settings"); db.createObjectStore("settings");
chrome.storage.local.get().then(async storage => { chrome.storage.local.get().then(async storage => {
console.debug("storage", storage); console.debug("storage", storage);
for (let emoji of storage.emojis) { if (storage.emojis) for (let emoji of storage.emojis) {
try { try {
emoji.data = await fetch(storage[`data-${emoji.internalId}`]).then(res => res.blob()); emoji.data = await fetch(storage[`data-${emoji.internalId}`]).then(res => res.blob());
await storeImage(emoji); await storeImage(emoji);
@@ -31,7 +31,7 @@ function initDb() {
console.error(error); console.error(error);
} }
} }
await setSetting("defaultAnimationStyle", storage.defaultAnimationStyle); if (storage.defaultAnimationStyle) await setSetting("defaultAnimationStyle", storage.defaultAnimationStyle);
//resolve(db); //resolve(db);
console.debug("finished upgrade"); console.debug("finished upgrade");
}); });
@@ -62,12 +62,17 @@ export async function getKnownStickerIds() {
} }
export async function getAllImages() { export async function getAllImages() {
console.time("getAllImages");
try {
var db = await getDb(); var db = await getDb();
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
var request = db.transaction(["images"], "readonly").objectStore("images").getAll(); var request = db.transaction(["images"], "readonly").objectStore("images").getAll();
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} finally {
console.timeEnd("getAllImages");
}
} }
export async function storeImage(entry) { export async function storeImage(entry) {
+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.
+155 -45
View File
@@ -1,4 +1,6 @@
import * as db from "../db.js"; import * as db from "../db.js";
import { parseGIF, decompressFrames } from "./dep/gifuct/index.js";
import { importVrcFile } from "./common.js";
var mode = localStorage.mode ||= "emoji"; var mode = localStorage.mode ||= "emoji";
if (mode == "sticker") stickerMode(); if (mode == "sticker") stickerMode();
@@ -20,7 +22,7 @@ modebtn.onclick = function switchMode() {
} else { } else {
emojiMode(); emojiMode();
} }
loadToggleState(); refresh();
} }
@@ -102,11 +104,22 @@ async function addFiles(files) {
var errors = []; var errors = [];
for (let file of files) { for (let file of files) {
try { 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 image = await loadImage(URL.createObjectURL(file));
let width = image.width; let width = image.width;
let height = image.height; let height = image.height;
const MIN = 64, MAX = 512; // VRChat API will accept and store images up to 2048x2048, but the game seems to resize everything to 512x512. const MIN = 64, MAX = 2048;
let aspectRatio = width / height; let aspectRatio = width / height;
if (height > width) { if (height > width) {
height = Math.max(MIN, Math.min(MAX, height)); height = Math.max(MIN, Math.min(MAX, height));
@@ -118,7 +131,6 @@ async function addFiles(files) {
let largestDim = Math.max(width, height); let largestDim = Math.max(width, height);
let offsetX = (largestDim - width) / 2; let offsetX = (largestDim - width) / 2;
let offsetY = (largestDim - height) / 2; let offsetY = (largestDim - height) / 2;
let canvas = document.createElement("canvas"); let canvas = document.createElement("canvas");
canvas.width = largestDim; canvas.width = largestDim;
canvas.height = largestDim; canvas.height = largestDim;
@@ -126,12 +138,9 @@ async function addFiles(files) {
ctx.drawImage(image, offsetX, offsetY, width, height); ctx.drawImage(image, offsetX, offsetY, width, height);
let data = await new Promise(r => canvas.toBlob(r, "image/png")); let data = await new Promise(r => canvas.toBlob(r, "image/png"));
URL.revokeObjectURL(image.src); URL.revokeObjectURL(image.src);
let e = { e.animationStyle = file.name.match(animationStyleRegex)?.[1] || undefined;
internalId: randomId(), e.data = data;
date: new Date().toISOString(), }
animationStyle: file.name.match(animationStyleRegex)?.[1] || undefined,
data
};
await db.storeImage(e); await db.storeImage(e);
createEmojiSquare(e); createEmojiSquare(e);
errorDiv.innerText = ""; errorDiv.innerText = "";
@@ -140,10 +149,10 @@ async function addFiles(files) {
errors.push(error.message); errors.push(error.message);
} }
} }
await refresh();
if (errors.length) { if (errors.length) {
displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`); displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`);
} }
loadToggleState();
} }
@@ -179,19 +188,19 @@ importbtn.onclick = async () => {
if (file.name.toLowerCase().endsWith(".json")) { if (file.name.toLowerCase().endsWith(".json")) {
let text = await file.text(); let text = await file.text();
let store = JSON.parse(text); let store = JSON.parse(text);
var pngs = await Promise.all(store.emojis.map(async emoji => { var imgs = await Promise.all(store.emojis.map(async emoji => {
var blob = await fetch(store[`data-${emoji.internalId}`]).then(res => res.blob()); var blob = await fetch(store[`data-${emoji.internalId}`]).then(res => res.blob());
return new File([blob], `${emoji.animationStyle}.png`); return new File([blob], `${emoji.animationStyle}.png`);
})); }));
} else { } else {
var zip = await JSZip.loadAsync(file); var zip = await JSZip.loadAsync(file);
var pngs = zip.file(/^[^\/]*\.png$/i); var imgs = zip.file(/^[^\/]*\.(?:png|gif)$/i);
pngs = await Promise.all(pngs.map(async png => { imgs = await Promise.all(imgs.map(async img => {
var blob = await png.async("blob"); var blob = await img.async("blob");
return new File([blob], png.name); return new File([blob], img.name, {type: img.name.toLowerCase().endsWith('.gif') ? 'image/gif' : 'image.png'});
})); }));
} }
await addFiles(pngs); await addFiles(imgs);
} catch(error) { } catch(error) {
displayError(error); displayError(error);
} }
@@ -202,7 +211,7 @@ exportbtn.onclick = async () => {
var images = await db.getAllImages(); var images = await db.getAllImages();
var zip = new JSZip(); var zip = new JSZip();
for (let image of images) { for (let image of images) {
zip.file(`${image.internalId}.${image.animationStyle}.png`, image.data); zip.file(`${image.internalId}.${image.animationStyle}.${image.animated?'gif':'png'}`, image.data);
} }
var blob = await zip.generateAsync({type: "blob"}); var blob = await zip.generateAsync({type: "blob"});
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
@@ -217,30 +226,56 @@ exportbtn.onclick = async () => {
}; };
clearbtn.onclick = async () => { clearbtn.onclick = async () => {
if (!confirm("Delete all images?????")) return; if (!confirm("Remove all images from manager? (This will not delete any on VRChat)")) return;
await db.deleteAllImages(); await db.deleteAllImages();
emojigrid.innerHTML = ''; emojigrid.innerHTML = '';
}; };
(async function loadEmojis() { refresh();
errorDiv.innerText = ""; onfocus = refresh;
async function refresh() {
try {
var vrc_files = await callContentScript("getFiles", mode);
var images = await db.getAllImages(); var images = await db.getAllImages();
if (!images.length) throw new Error("No emoji."); if (!images.length) throw new Error("No emoji.");
for (let image of images) { var currentIds = images.map(i => i["currentId_"+mode]);
createEmojiSquare(image); 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);
}
} }
await loadToggleState();
})().catch(displayError);
function createEmojiSquare({internalId, data, animationStyle, currentId_emoji, currentId_sticker}) {
function createEmojiSquare({internalId, data, data_spritesheet, animationStyle, currentId_emoji, currentId_sticker, animated, fps, framecount}) {
var div = document.createElement("div"); var div = document.createElement("div");
div.className = "emojisquare"; div.className = "emojisquare";
div.dataset.internalId = internalId; div.dataset.internalId = internalId;
div.blob = data; div.blob = data;
if (currentId_emoji) div.dataset.currentId_emoji = currentId_emoji; if (currentId_emoji) div.dataset.currentId_emoji = currentId_emoji;
if (currentId_sticker) div.dataset.currentId_sticker = currentId_sticker; if (currentId_sticker) div.dataset.currentId_sticker = currentId_sticker;
if (animated) div.dataset.animated = "yes";
Object.assign(div, { Object.assign(div, {
async enable() { async enable() {
@@ -249,8 +284,15 @@ function createEmojiSquare({internalId, data, animationStyle, currentId_emoji, c
select.disabled = true; select.disabled = true;
errorDiv.innerText = ""; errorDiv.innerText = "";
try { try {
this.dataurl ||= await blobToDataURL(this.blob); if (animated && mode == "sticker") throw new Error("Stickers can't be animated");
var newEmoji = await callContentScript("createFile", this.dataurl, mode, select.value || default_animation_style_select.value); this.dataurl ||= await blobToDataURL(animated ? data_spritesheet : data);
var newEmoji = await callContentScript("createFile", {
url: this.dataurl,
tag: animated ? "emojianimated" : mode,
animationStyle: mode != "sticker" && (select.value || default_animation_style_select.value),
frames: framecount,
framesOverTime: fps
});
} catch (error) { } catch (error) {
this.dataset.state = lastState; this.dataset.state = lastState;
select.disabled = false; select.disabled = false;
@@ -299,7 +341,7 @@ function createEmojiSquare({internalId, data, animationStyle, currentId_emoji, c
div.onclick = function onEmojiClick() { div.onclick = function onEmojiClick() {
if (deleteMode) this.delete(); if (deleteMode) this.delete();
else this.toggle(); else this.toggle();
if (emojigrid.querySelector(".emojisquare:not([data-state])")) loadToggleState(); if (emojigrid.querySelector(".emojisquare:not([data-state])")) refresh();
}; };
var imgdiv = document.createElement("div"); var imgdiv = document.createElement("div");
@@ -326,22 +368,6 @@ function createEmojiSquare({internalId, data, animationStyle, currentId_emoji, c
} }
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 = loadToggleState;
function displayError(error) { function displayError(error) {
@@ -356,6 +382,8 @@ function displayError(error) {
html += ` Click "Add Emojis" to get started.`; html += ` Click "Add Emojis" to get started.`;
if (error.includes("Could not establish connection. Receiving end does not exist.")) if (error.includes("Could not establish connection. Receiving end does not exist."))
html += `<br>Please reload your VRChat tab.`; html += `<br>Please reload your VRChat tab.`;
if (error.includes("You must upload a valid imageǃ"))
html += `<br> (NSFW images are not allowed)`;
errorDiv.innerHTML = html; errorDiv.innerHTML = html;
} }
@@ -415,3 +443,85 @@ function randomId() {
} }
return id; return id;
} }
async function gifToSpritesheet(arrayBuffer) {
var gif = parseGIF(arrayBuffer);
var frames = decompressFrames(gif, true);
frames = frames.slice(0, 64);
var gifCanvas = document.createElement("canvas");
//gifCanvas.width = frames[0].dims.width;
//gifCanvas.height = frames[0].dims.height;
var gifCanvasCtx = gifCanvas.getContext("2d");
var spritesheetCanvas = document.createElement("canvas");
spritesheetCanvas.width = 1024;
spritesheetCanvas.height = 1024;
var spritesheetCanvasCtx = spritesheetCanvas.getContext('2d');
if (frames.length <= 4) {
var tileSize = 512;
var columns = 2;
} else if (frames.length > 4 && frames.length <= 16) {
var tileSize = 256;
var columns = 4;
} else if (frames.length > 16 && frames.length <= 64) {
var tileSize = 128;
var columns = 8;
}
frames.forEach((frame, index) => {
gifCanvas.width = frame.dims.width;
gifCanvas.height = frame.dims.height;
let imageData = new ImageData(frame.patch, frame.dims.width, frame.dims.height);
gifCanvasCtx.putImageData(imageData, 0, 0);
let x = (index % columns) * tileSize;
let y = Math.floor(index / columns) * tileSize;
if (frame.dims.height > frame.dims.width) {
var fh = tileSize;
var fw = tileSize * (frame.dims.width/frame.dims.height);
x += (tileSize - fw) / 2;
} else {
var fw = tileSize;
var fh = tileSize * (frame.dims.height/frame.dims.width);
y += (tileSize - fh) / 2;
}
spritesheetCanvasCtx.drawImage(gifCanvas, x, y, fw, fh);
});
var frameDelays = frames.map(frame => frame.delay);
var frameDelay = modeOfNumbers(frameDelays);
var fps = 1000 / frameDelay;
var data = await new Promise(r => spritesheetCanvas.toBlob(r, "image/png"));
return {
data,
fps,
framecount: frames.length
}
}
function modeOfNumbers(numbers) {
var counters = {};
for (var number of numbers) {
counters[number] ||= 0;
counters[number]++;
}
counters = Object.entries(counters);
var max = counters.reduce((max, val) => val[1] > max ? val[1] : max, 0);
var mode = Number(counters.find(x => x[1] == max)[0]);
return mode;
}
+2 -3
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "VRChat Emoji and Sticker Manager", "name": "VRChat Emoji and Sticker Manager",
"version": "1.2024.10.9", "version": "1.2025.5.3",
"description": "Store more than 9 emoji or stickers, toggle them on and off and change their animation styles.", "description": "Store more than 9 emoji or stickers, toggle them on and off and change their animation styles.",
"homepage_url": "https://gitea.moe/lamp/vrchat-emoji-manager", "homepage_url": "https://gitea.moe/lamp/vrchat-emoji-manager",
"icons": { "icons": {
@@ -9,8 +9,7 @@
}, },
"permissions": [ "permissions": [
"storage", "storage",
"unlimitedStorage", "unlimitedStorage"
"webRequest"
], ],
"host_permissions": [ "host_permissions": [
"https://vrchat.com/home*", "https://vrchat.com/home*",
+1 -1
View File
@@ -9,7 +9,7 @@
} }
</style> </style>
</head><body> </head><body>
<h2>VRChat Emoji Manager</h2> <h2>VRChat Emoji & Sticker Manager</h2>
<div><button id="btn1">Launch in new tab</button></div> <div><button id="btn1">Launch in new tab</button></div>
<div><button id="btn2">Launch in pop up window</button></div> <div><button id="btn2">Launch in pop up window</button></div>
<script src="popup.js"></script> <script src="popup.js"></script>
+11 -5
View File
@@ -1,10 +1,12 @@
# VRChat Emoji Manager # VRChat Emoji and Sticker Manager
VRChat Plus has a custom emoji feature but it is limited to 9 emojis and you cannot change their animation styles. This Chrome extension allows you to have a much larger collection of emojis and conveniently toggle them on and off when needed. VRChat Plus has a custom emoji and sticker feature but they are limited to 9 emojis or stickers and you cannot change the animation styles of the emojis. This Chrome extension allows you to have a much larger collection of emojis or stickers and conveniently toggle them on and off when needed.
Get it on the Chrome Web Store!!! https://chromewebstore.google.com/detail/vrchat-emoji-and-sticker/obmoelidfamikmdhgjeoacpmkfhohekb
![image](2023-10-26_20-50-20-123%20VRChat_Emoji_Manager_-_Google_Chrome.png) ![image](2023-10-26_20-50-20-123%20VRChat_Emoji_Manager_-_Google_Chrome.png)
## Install ## Manual install from source
1. If you have git, run `git clone https://gitea.moe/lamp/vrchat-emoji-manager.git`. If not, [download](https://gitea.moe/lamp/vrchat-emoji-manager/archive/main.zip) and extract the zip. 1. 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/) 2. Navigate to [chrome://extensions/](chrome://extensions/)
@@ -14,8 +16,12 @@ VRChat Plus has a custom emoji feature but it is limited to 9 emojis and you can
6. Open https://vrchat.com/home, existing emojis should be imported 6. Open https://vrchat.com/home, existing emojis should be imported
7. Click extension icon to launch in tab or window 7. Click extension icon to launch in tab or window
If you use git you can update by running `git clone` and then clicking reload in chrome extensions. Otherwise you have to download the zip again and extract it to the same path. ### 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. 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) IF ANY ISSUES REPORT [HERE](https://gitea.moe/lamp/vrchat-emoji-manager/issues?state=open)