Plugin
petpet
Adds a /petpet slash command to create headpet gifs from any image
1
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, sendBotMessage } from "@api/Commands";2
import { Devs } from "@utils/constants";3
import { makeLazy } from "@utils/lazy";4
import definePlugin from "@utils/types";5
import { CommandArgument, CommandContext } from "@vencord/discord-types";6
import { DraftType, UploadAttachmentStore, UploadHandler, UploadManager, UserUtils } from "@webpack/common";7
import { GIFEncoder, nearestColorIndex, quantize } from "gifenc";8
9
const DEFAULT_DELAY = 20;10
const DEFAULT_RESOLUTION = 128;11
const FRAMES = 10;12
13
const getFrames = makeLazy(() => Promise.all(14
Array.from(15
{ length: FRAMES },16
(_, i) => loadImage(`https:class="ts-cmt">//raw.githubusercontent.com/VenPlugs/petpet/main/frames/pet${i}.gif`)17
))18
);19
20
function loadImage(source: File | string) {21
const isFile = source instanceof File;22
const url = isFile ? URL.createObjectURL(source) : source;23
24
return new Promise<HTMLImageElement>((resolve, reject) => {25
const img = new Image();26
img.onload = () => {27
if (isFile)28
URL.revokeObjectURL(url);29
resolve(img);30
};31
img.onerror = _event => reject(Error(`An error occurred while loading ${url}. Check the console for more info.`));32
img.crossOrigin = "Anonymous";33
img.src = url;34
});35
}36
37
async function resolveImage(options: CommandArgument[], ctx: CommandContext, noServerPfp: boolean): Promise<File | string | null> {38
for (const opt of options) {39
switch (opt.name) {40
case "image":41
const upload = UploadAttachmentStore.getUpload(ctx.channel.id, opt.name, DraftType.SlashCommand);42
if (upload) {43
if (!upload.isImage) {44
UploadManager.clearAll(ctx.channel.id, DraftType.SlashCommand);45
throw "Upload is not an image";46
}47
return upload.item.file;48
}49
break;50
case "url":51
return opt.value;52
case "user":53
try {54
const user = await UserUtils.getUser(opt.value);55
return user.getAvatarURL(noServerPfp ? void 0 : ctx.guild?.id, 2048).replace(/\?size=\d+$/, "?size=2048");56
} catch (err) {57
console.error("[petpet] Failed to fetch user\n", err);58
UploadManager.clearAll(ctx.channel.id, DraftType.SlashCommand);59
throw "Failed to fetch user. Check the console for more info.";60
}61
}62
}63
UploadManager.clearAll(ctx.channel.id, DraftType.SlashCommand);64
return null;65
}66
67
function rgb888_to_rgb565(r: number, g: number, b: number): number {68
return ((r << 8) & 0xf800) | ((g << 3) & 0x07e0) | (b >> 3);69
}70
71
function applyPaletteTransparent(data: Uint8Array | Uint8ClampedArray, palette: number[][], cache: number[], threshold: number): Uint8Array {72
const index = new Uint8Array(Math.floor(data.length / 4));73
74
for (let i = 0; i < index.length; i += 1) {75
const r = data[4 * i];76
const g = data[4 * i + 1];77
const b = data[4 * i + 2];78
const a = data[4 * i + 3];79
80
if (a < threshold) {81
index[i] = 255;82
} else {83
const key = rgb888_to_rgb565(r, g, b);84
index[i] = key in cache ? cache[key] : (cache[key] = nearestColorIndex(palette, [r, g, b]));85
}86
}87
return index;88
}89
90
export default definePlugin({91
name: "petpet",92
description: "Adds a /petpet slash command to create headpet gifs from any image",93
tags: ["Fun", "Commands"],94
authors: [Devs.Ven, Devs.u32],95
commands: [96
{97
inputType: ApplicationCommandInputType.BUILT_IN,98
name: "petpet",99
description: "Create a petpet gif. You can only specify one of the image options",100
options: [101
{102
name: "delay",103
description: "The delay between each frame in ms. Rounded to nearest 10ms. Defaults to the minimum value of 20.",104
type: ApplicationCommandOptionType.INTEGER105
},106
{107
name: "resolution",108
description: "Resolution for the gif. Defaults to 120. If you enter an insane number and it freezes Discord that039;s your fault.",109
type: ApplicationCommandOptionType.INTEGER110
},111
{112
name: "image",113
description: "Image attachment to use",114
type: ApplicationCommandOptionType.ATTACHMENT115
},116
{117
name: "url",118
description: "URL to fetch image from",119
type: ApplicationCommandOptionType.STRING120
},121
{122
name: "user",123
description: "User whose avatar to use as image",124
type: ApplicationCommandOptionType.USER125
},126
{127
name: "no-server-pfp",128
description: "Use the normal avatar instead of the server specific one when using the 039;user039; option",129
type: ApplicationCommandOptionType.BOOLEAN130
}131
],132
execute: async (opts, cmdCtx) => {133
const frames = await getFrames();134
135
const noServerPfp = findOption(opts, "no-server-pfp", false);136
try {137
var url = await resolveImage(opts, cmdCtx, noServerPfp);138
if (!url) throw "No Image specified!";139
} catch (err) {140
UploadManager.clearAll(cmdCtx.channel.id, DraftType.SlashCommand);141
sendBotMessage(cmdCtx.channel.id, {142
content: String(err),143
});144
return;145
}146
147
const avatar = await loadImage(url);148
149
const delay = findOption(opts, "delay", DEFAULT_DELAY);150
// Frame delays < 20ms don't function correctly on chromium and firefox151
if (delay < 20) return sendBotMessage(cmdCtx.channel.id, { content: "Delay must be at least 20." });152
153
const resolution = findOption(opts, "resolution", DEFAULT_RESOLUTION);154
155
const gif = GIFEncoder();156
157
const paletteImageSize = Math.min(120, resolution);158
159
const canvas = document.createElement("canvas");160
canvas.width = resolution;161
// Ensure there is sufficient space for the palette generation image162
canvas.height = Math.max(resolution, 2 * paletteImageSize);163
164
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;165
166
UploadManager.clearAll(cmdCtx.channel.id, DraftType.SlashCommand);167
168
// Generate palette from an image where hand and avatar are fully visible169
ctx.drawImage(avatar, 0, paletteImageSize, 0.8 * paletteImageSize, 0.8 * paletteImageSize);170
ctx.drawImage(frames[0], 0, 0, paletteImageSize, paletteImageSize);171
const { data } = ctx.getImageData(0, 0, paletteImageSize, 2 * paletteImageSize);172
const palette = quantize(data, 255);173
174
const cache = new Array(2 ** 16);175
176
for (let i = 0; i < FRAMES; i++) {177
ctx.clearRect(0, 0, canvas.width, canvas.height);178
179
const j = i < FRAMES / 2 ? i : FRAMES - i;180
const width = 0.8 + j * 0.02;181
const height = 0.8 - j * 0.05;182
const offsetX = (1 - width) * 0.5 + 0.1;183
const offsetY = 1 - height - 0.08;184
185
ctx.drawImage(avatar, offsetX * resolution, offsetY * resolution, width * resolution, height * resolution);186
ctx.drawImage(frames[i], 0, 0, resolution, resolution);187
188
const { data } = ctx.getImageData(0, 0, resolution, resolution);189
const index = applyPaletteTransparent(data, palette, cache, 1);190
191
gif.writeFrame(index, resolution, resolution, {192
transparent: true,193
transparentIndex: 255,194
delay,195
palette: i === 0 ? palette : undefined,196
});197
}198
199
gif.finish();200
// @ts-ignore This causes a type error on *only some* typescript versions.201
// usage adheres to mdn https://developer.mozilla.org/en-US/docs/Web/API/File/File#parameters202
const file = new File([gif.bytesView()], "petpet.gif", { type: "image/gif" });203
// Immediately after the command finishes, Discord clears all input, including pending attachments.204
// Thus, setTimeout is needed to make this execute after Discord cleared the input205
setTimeout(() => UploadHandler.promptToUpload([file], cmdCtx.channel, DraftType.ChannelMessage), 10);206
},207
},208
]209
});210