From 58ef6585af5a915113f96d779018d0e452f0a1f5 Mon Sep 17 00:00:00 2001 From: u32 Date: Sun, 28 Dec 2025 00:39:24 +0000 Subject: [PATCH] PetPet: fix dark colours being transparent in the output (#3571) Co-authored-by: V --- src/plugins/petpet/index.ts | 57 +++++++++++++++++++++++++++++++------ src/utils/constants.ts | 6 +++- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/plugins/petpet/index.ts b/src/plugins/petpet/index.ts index 087bca1c3..cc4605c81 100644 --- a/src/plugins/petpet/index.ts +++ b/src/plugins/petpet/index.ts @@ -22,7 +22,7 @@ import { makeLazy } from "@utils/lazy"; import definePlugin from "@utils/types"; import { CommandArgument, CommandContext } from "@vencord/discord-types"; import { DraftType, UploadAttachmentStore, UploadHandler, UploadManager, UserUtils } from "@webpack/common"; -import { applyPalette, GIFEncoder, quantize } from "gifenc"; +import { GIFEncoder, nearestColorIndex, quantize } from "gifenc"; const DEFAULT_DELAY = 20; const DEFAULT_RESOLUTION = 128; @@ -46,7 +46,7 @@ function loadImage(source: File | string) { URL.revokeObjectURL(url); resolve(img); }; - img.onerror = (event, _source, _lineno, _colno, err) => reject(err || event); + img.onerror = _event => reject(Error(`An error occurred while loading ${url}. Check the console for more info.`)); img.crossOrigin = "Anonymous"; img.src = url; }); @@ -82,10 +82,33 @@ async function resolveImage(options: CommandArgument[], ctx: CommandContext, noS return null; } +function rgb888_to_rgb565(r: number, g: number, b: number): number { + return ((r << 8) & 0xf800) | ((g << 3) & 0x07e0) | (b >> 3); +} + +function applyPaletteTransparent(data: Uint8Array | Uint8ClampedArray, palette: number[][], cache: number[], threshold: number): Uint8Array { + const index = new Uint8Array(Math.floor(data.length / 4)); + + for (let i = 0; i < index.length; i += 1) { + const r = data[4 * i]; + const g = data[4 * i + 1]; + const b = data[4 * i + 2]; + const a = data[4 * i + 3]; + + if (a < threshold) { + index[i] = 255; + } else { + const key = rgb888_to_rgb565(r, g, b); + index[i] = key in cache ? cache[key] : (cache[key] = nearestColorIndex(palette, [r, g, b])); + } + } + return index; +} + export default definePlugin({ name: "petpet", description: "Adds a /petpet slash command to create headpet gifs from any image", - authors: [Devs.Ven], + authors: [Devs.Ven, Devs.u32], commands: [ { inputType: ApplicationCommandInputType.BUILT_IN, @@ -94,7 +117,7 @@ export default definePlugin({ options: [ { name: "delay", - description: "The delay between each frame. Defaults to 20.", + description: "The delay between each frame in ms. Rounded to nearest 10ms. Defaults to the minimum value of 20.", type: ApplicationCommandOptionType.INTEGER }, { @@ -141,16 +164,32 @@ export default definePlugin({ const avatar = await loadImage(url); const delay = findOption(opts, "delay", DEFAULT_DELAY); + // Frame delays < 20ms don't function correctly on chromium and firefox + if (delay < 20) return sendBotMessage(cmdCtx.channel.id, { content: "Delay must be at least 20." }); + const resolution = findOption(opts, "resolution", DEFAULT_RESOLUTION); const gif = GIFEncoder(); + const paletteImageSize = Math.min(120, resolution); + const canvas = document.createElement("canvas"); - canvas.width = canvas.height = resolution; - const ctx = canvas.getContext("2d")!; + canvas.width = resolution; + // Ensure there is sufficient space for the palette generation image + canvas.height = Math.max(resolution, 2 * paletteImageSize); + + const ctx = canvas.getContext("2d", { willReadFrequently: true })!; UploadManager.clearAll(cmdCtx.channel.id, DraftType.SlashCommand); + // Generate palette from an image where hand and avatar are fully visible + ctx.drawImage(avatar, 0, paletteImageSize, 0.8 * paletteImageSize, 0.8 * paletteImageSize); + ctx.drawImage(frames[0], 0, 0, paletteImageSize, paletteImageSize); + const { data } = ctx.getImageData(0, 0, paletteImageSize, 2 * paletteImageSize); + const palette = quantize(data, 255); + + const cache = new Array(2 ** 16); + for (let i = 0; i < FRAMES; i++) { ctx.clearRect(0, 0, canvas.width, canvas.height); @@ -164,13 +203,13 @@ export default definePlugin({ ctx.drawImage(frames[i], 0, 0, resolution, resolution); const { data } = ctx.getImageData(0, 0, resolution, resolution); - const palette = quantize(data, 256); - const index = applyPalette(data, palette); + const index = applyPaletteTransparent(data, palette, cache, 1); gif.writeFrame(index, resolution, resolution, { transparent: true, - palette, + transparentIndex: 255, delay, + palette: i === 0 ? palette : undefined, }); } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 85a1e79af..03f5dd6be 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -620,7 +620,11 @@ export const Devs = /* #__PURE__*/ Object.freeze({ vv: { name: "VV", id: 254866377087778816n - } + }, + u32: { + name: "u32", + id: 1063237286818488351n, + }, } satisfies Record); // iife so #__PURE__ works correctly