Plugin

petpet

Adds a /petpet slash command to create headpet gifs from any image

Fun Commands
index.ts
Download

Source

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