feat(plugin): add gifMaker v2 (#1161)

* feat(plugin): add gifMaker v2

* fix(gifMaker): address code review

* fix(gifMaker): remove unused fps useEffect

* fix(gifMaker): address code review p2

* fix(gifMaker): address code review - error boundary, blob release, fix imports and style

* perf(gifMaker): use worker to offload gif creation task, import gifenc as esm module

* [autofix.ci] apply automated fixes

* fix(gifMaker): lint errors

* fix(gifMaker): detect existing color palette

* worker worker worker worker worker

* api and some fixes

* fix thororen fix

* fix

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Jakub Bednarski 2026-06-16 02:30:30 +02:00 • committed by GitHub
parent 621dfb74de
commit 046281c768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1954 additions and 44 deletions

View file

@ -0,0 +1,68 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { ContextMenuApi, FluxDispatcher, Menu, React } from "@webpack/common";
import type { ReactNode } from "react";
const logger = new Logger("GifPickerContextMenu");
export type GifPickerContextMenuItemFactory = (instance: any, e: React.MouseEvent) => ReactNode | void;
interface HandlerEntry {
render: GifPickerContextMenuItemFactory;
priority: number;
}
const handlers = new Map<string, HandlerEntry>();
/**
* Register a context menu item factory for GIF picker items.
* @param id Unique ID, typically the plugin name.
* @param render Function that receives the GIF item and returns ReactNode(s) to inject.
* @param priority Lower numbers appear first. Defaults to 0.
*/
export function addGifPickerContextMenuPatch(id: string, render: GifPickerContextMenuItemFactory, priority = 0) {
handlers.set(id, { render, priority });
}
/**
* Remove a previously registered context menu item factory.
* @param id Unique ID used when registering.
*/
export function removeGifPickerContextMenuPatch(id: string) {
handlers.delete(id);
}
/** @internal Called by ExtraContextMenusAPI to render the merged context menu. */
export function _openGifPickerContextMenu(e: React.MouseEvent, instance) {
if (!handlers.size) return;
const items: React.ReactNode[] = [];
for (const [id, { render }] of Array.from(handlers).sort(([, a], [, b]) => a.priority - b.priority)) {
try {
const node = render(instance, e);
if (node) items.push(node);
} catch (err) {
logger.error(`Failed to render context menu for ${id}`, err);
}
}
if (!items.length) return;
ContextMenuApi.openContextMenu(e, () => <GifPickerContextMenuRoot items={items} />);
}
function GifPickerContextMenuRoot({ items }: { items: React.ReactNode[]; }) {
return (
<Menu.Menu
navId="gif-picker-context"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="GIF Options"
>
{items}
</Menu.Menu>
);
}

View file

@ -41,6 +41,7 @@ import Plugins from "~plugins";
export { Plugins as plugins };
import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer";
import { addGifPickerContextMenuPatch, removeGifPickerContextMenuPatch } from "./GifPickerContextMenu";
import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar";
import { addProfileCollection, removeProfileCollection } from "./ProfileCollections";
import { addProfileSection, removeProfileSection } from "./ProfileSections";
@ -222,7 +223,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
renderProfileSection, gifPickerContextMenu
} = p;
if (p.start) {
@ -292,6 +293,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (renderProfileCollection) addProfileCollection(name, renderProfileCollection.render, renderProfileCollection.priority);
if (chatBarButtonWrapper) addChatBarButtonWrapper(name, chatBarButtonWrapper.wrapper, chatBarButtonWrapper.priority);
if (renderProfileSection) addProfileSection(name, renderProfileSection.render, renderProfileSection.priority);
if (gifPickerContextMenu) addGifPickerContextMenuPatch(name, gifPickerContextMenu);
return true;
}, p => `startPlugin ${p.name}`);
@ -303,7 +305,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
renderProfileSection, gifPickerContextMenu
} = p;
if (p.stop) {
@ -371,6 +373,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (renderProfileCollection) removeProfileCollection(name);
if (chatBarButtonWrapper) removeChatBarButtonWrapper(name);
if (renderProfileSection) removeProfileSection(name);
if (gifPickerContextMenu) removeGifPickerContextMenuPatch(name);
return true;
}, p => `stopPlugin ${p.name}`);
@ -428,6 +431,7 @@ export const initPluginManager = onlyOnce(function init() {
if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI");
if (p.chatBarButtonWrapper) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderProfileSection) neededApiPlugins.add("ProfileSectionsAPI");
if (p.gifPickerContextMenu) neededApiPlugins.add("ExtraContextMenusAPI");
for (const key of pluginKeysToBind) {
p[key] &&= (p[key] as Function).bind(p) as any;

View file

@ -22,6 +22,7 @@ import * as $ChatButtons from "./ChatButtons";
import * as $Commands from "./Commands";
import * as $ContextMenu from "./ContextMenu";
import * as $DataStore from "./DataStore";
import * as $GifPickerContextMenu from "./GifPickerContextMenu";
import * as $HeaderBar from "./HeaderBar";
import * as $MemberListDecorators from "./MemberListDecorators";
import * as $MessageAccessories from "./MessageAccessories";
@ -182,3 +183,9 @@ export const ProfileCollections = $ProfileCollections;
* An API allowing you to add sections near the 'Member Since' area of user profile panels.
*/
export const ProfileSections = $ProfileSections;
/**
* An API allowing plugins to add items to the GIF picker right-click context menu
* without conflicting with each other.
*/
export const GifPickerContextMenu = $GifPickerContextMenu;

View file

@ -0,0 +1,31 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { _openGifPickerContextMenu } from "@api/GifPickerContextMenu";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "ExtraContextMenusAPI",
description: "API that adds more context menus to patch.",
authors: [EquicordDevs.thororen],
required: true,
patches: [
{
find: "renderEmptyFavorite",
replacement: {
match: /render\(\){.{1,500}onClick:this\.handleClick,/,
replace: "$&onContextMenu: (e) => $self.openContextMenu(e, this),"
}
}
],
openContextMenu(e: React.MouseEvent, instance) {
if (!instance) return;
_openGifPickerContextMenu(e, instance);
},
});

View file

@ -6,10 +6,10 @@
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { copyToClipboard } from "@utils/clipboard";
import { Alerts, Button, ContextMenuApi, FluxDispatcher, Menu, showToast, Toasts } from "@webpack/common";
import { Alerts, Button, FluxDispatcher, Menu, showToast, Toasts } from "@webpack/common";
import { settings } from "../settings";
import { Gif, GifItem, GifPickerInstance } from "../types";
import { Gif } from "../types";
import { addToCollection, cache_collections, deleteCollection, getGifById, getItemCollectionNameFromId, removeFromCollection } from "../utils/collectionManager";
import { getGif } from "../utils/getGif";
import { stripPrefix } from "../utils/misc";
@ -69,13 +69,9 @@ export const addCollectionContextMenuPatch: NavContextMenuPatchCallback = (child
group.push(AddToCollectionMenu(gif));
};
export function RemoveItemContextMenu({ type, nameOrId, instance }: { type: "collection" | "gif"; nameOrId: string; instance: { forceUpdate: () => void; }; }) {
export function RemoveItemContextMenuItems({ type, nameOrId, instance }: { type: "collection" | "gif"; nameOrId: string; instance: { forceUpdate: () => void; }; }) {
return (
<Menu.Menu
navId="gif-collection-id"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label={type === "collection" ? "Delete Collection" : "Remove"}
>
<Menu.MenuGroup key={`remove-item-${nameOrId}`}>
{type === "collection" && (
<>
<Menu.MenuItem
@ -87,7 +83,7 @@ export function RemoveItemContextMenu({ type, nameOrId, instance }: { type: "col
if (collection) openCollectionInfoModal(collection);
}}
/>
<Menu.MenuSeparator />
<Menu.MenuSeparator key="collection-sep-1" />
<Menu.MenuItem
key="rename-collection"
id="rename-collection"
@ -107,7 +103,7 @@ export function RemoveItemContextMenu({ type, nameOrId, instance }: { type: "col
if (gif) openGifInfoModal(gif);
}}
/>
<Menu.MenuSeparator />
<Menu.MenuSeparator key="gif-sep-1" />
<Menu.MenuItem
key="copy-url"
id="copy-url"
@ -125,7 +121,7 @@ export function RemoveItemContextMenu({ type, nameOrId, instance }: { type: "col
label="Move To Collection"
action={() => openMoveToCollectionModal(nameOrId)}
/>
<Menu.MenuSeparator />
<Menu.MenuSeparator key="gif-sep-2" />
</>
)}
<Menu.MenuItem
@ -159,7 +155,7 @@ export function RemoveItemContextMenu({ type, nameOrId, instance }: { type: "col
});
}}
/>
</Menu.Menu>
</Menu.MenuGroup>
);
}
@ -186,22 +182,22 @@ export function GifPickerContextMenu({ gif }: { gif: Gif; }) {
);
}
export function buildGifPickerContextMenu(e: React.MouseEvent, item: GifItem, GIF_COLLECTION_PREFIX: string, GIF_ITEM_PREFIX: string, instance: GifPickerInstance) {
if (item?.name?.startsWith(GIF_COLLECTION_PREFIX)) {
return ContextMenuApi.openContextMenu(e, () =>
<RemoveItemContextMenu type="collection" nameOrId={item.name!} instance={instance} />
);
}
if (item?.id?.startsWith(GIF_ITEM_PREFIX)) {
return ContextMenuApi.openContextMenu(e, () =>
<RemoveItemContextMenu type="gif" nameOrId={item.id!} instance={instance} />
);
}
const { src, url, height, width } = item;
if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX)) {
return ContextMenuApi.openContextMenu(e, () =>
<GifPickerContextMenu gif={{ id: uuidv4(GIF_ITEM_PREFIX), src, url, height, width }} />
);
}
return null;
export function getGifPickerContextMenuItems(src: string, url: string, height: number, width: number) {
const gif: Gif = { id: uuidv4(settings.store.itemPrefix), src, url, height, width };
return (
<Menu.MenuGroup key="gif-collections-group">
{settings.store.showCopyImageLink && (
<Menu.MenuItem
label="Copy Image Link"
key="copy-image-link"
id="copy-image-link"
action={() => {
copyToClipboard(url);
showToast("Image link copied to clipboard", Toasts.Type.SUCCESS);
}}
/>
)}
{AddToCollectionMenu(gif)}
</Menu.MenuGroup>
);
}

View file

@ -8,9 +8,9 @@ import "./style.css";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { FluxDispatcher } from "@webpack/common";
import { FluxDispatcher, React } from "@webpack/common";
import { addCollectionContextMenuPatch, buildGifPickerContextMenu } from "./components/contextMenus";
import { addCollectionContextMenuPatch, getGifPickerContextMenuItems, RemoveItemContextMenuItems } from "./components/contextMenus";
import { settings, SortingOptions } from "./settings";
import { Category, Collection, Gif, GifPickerInstance } from "./types";
import { cache_collections, refreshCacheCollection, updateGif } from "./utils/collectionManager";
@ -47,13 +47,6 @@ export default definePlugin({
},
],
},
{
find: "renderEmptyFavorite",
replacement: {
match: /render\(\){.{1,500}onClick:this\.handleClick,/,
replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this),",
},
},
{
find: "renderHeaderContent()",
replacement: {
@ -171,7 +164,23 @@ export default definePlugin({
return query.startsWith(GIF_COLLECTION_PREFIX) && cache_collections.some(c => c.name === query);
},
collectionContextMenu(e: React.MouseEvent, instance: GifPickerInstance) {
return buildGifPickerContextMenu(e, instance.props.item, GIF_COLLECTION_PREFIX, GIF_ITEM_PREFIX, instance);
},
gifPickerContextMenu(instance, e: React.MouseEvent) {
const item = instance?.props?.item;
if (!item) return;
const { name, id } = item;
if (name?.startsWith(GIF_COLLECTION_PREFIX)) {
return RemoveItemContextMenuItems({ type: "collection", nameOrId: name, instance });
}
if (id?.startsWith(GIF_ITEM_PREFIX)) {
return RemoveItemContextMenuItems({ type: "gif", nameOrId: id, instance });
}
const { src, url, height, width } = item;
if (!src || !url || !height || !width) return;
return getGifPickerContextMenuItems(src, url, height, width);
}
});

View file

@ -9,6 +9,12 @@ import { RestAPI } from "@webpack/common";
import { logger } from "./misc";
export function isCdnUrlExpired(url: string): boolean {
if (
!url?.startsWith("https://cdn.discordapp.com")
&& !url?.startsWith("https://media.discordapp.net")
&& !url?.startsWith("https://images-ext-1.discordapp.net")
&& !url?.startsWith("https://images-ext-2.discordapp.net")
) return false;
try {
const ex = new URL(url).searchParams.get("ex");
if (!ex) return false;

View file

@ -0,0 +1,58 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { getCanvasFont } from "../fonts";
import type { CaptionDefinition } from "../types";
type MeasureResult = { lines: string[]; lineHeight: number; };
export function measureTextLines(ctx: CanvasRenderingContext2D, text: string, fontSize: number, fontFamily: string, maxWidth: number): MeasureResult {
ctx.font = getCanvasFont(fontSize, fontFamily);
const words = text.split(" ");
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return { lines, lineHeight: fontSize * 1.2 };
}
export const captionCaption: CaptionDefinition = {
type: "caption",
name: "Caption",
render: (ctx, width, captionHeight, options) => {
const { captionText, captionSize, fontFamily } = options;
if (!captionText || captionHeight <= 0) return;
const maxWidth = width - 20;
const { lines, lineHeight } = measureTextLines(ctx, captionText, captionSize, fontFamily, maxWidth);
const padding = 10;
const areaHeight = lines.length * lineHeight + padding * 2;
const drawY = (captionHeight - areaHeight) / 2;
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, captionHeight);
ctx.fillStyle = "black";
ctx.font = getCanvasFont(captionSize, fontFamily);
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], width / 2, drawY + padding + i * lineHeight);
}
},
};

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { CaptionDefinition } from "../types";
import { captionCaption } from "./caption";
import { noneCaption } from "./none";
import { speechbubbleCaption } from "./speechbubble";
export const CAPTIONS: CaptionDefinition[] = [
noneCaption,
captionCaption,
speechbubbleCaption,
];

View file

@ -0,0 +1,13 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { CaptionDefinition } from "../types";
export const noneCaption: CaptionDefinition = {
type: "none",
name: "None",
render: () => { },
};

View file

@ -0,0 +1,85 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// Based on https://github.com/TheLazySquid/BetterDiscordPlugins/blob/main/plugins/GifCaptioner/src/render/speechbubble.ts
import type { CaptionDefinition } from "../types";
type Point = [number, number];
// https://stackoverflow.com/a/5634528
function bezierPoint(t: number, start: Point, control: Point, end: Point): Point {
const x = (1 - t) * (1 - t) * start[0] + 2 * (1 - t) * t * control[0] + t * t * end[0];
const y = (1 - t) * (1 - t) * start[1] + 2 * (1 - t) * t * control[1] + t * t * end[1];
return [x, y];
}
function moveAway(point: Point, from: Point, distance: number): Point {
const dx = point[0] - from[0];
const dy = point[1] - from[1];
const length = Math.sqrt(dx ** 2 + dy ** 2);
if (length === 0) return point;
const scale = distance / length;
return [point[0] + dx * scale, point[1] + dy * scale];
}
export function renderSpeechbubble(ctx: CanvasRenderingContext2D, width: number, height: number,
tipX: number, tipY: number, tipBase: number) {
const start: Point = [0, height * 0.1];
const control: Point = [width * 0.5, height * 0.2];
const end: Point = [width, height * 0.1];
// Fill the top (gentle arc left to right 10% of the height down)
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(...start);
ctx.quadraticCurveTo(...control, ...end);
ctx.lineTo(width, 0);
ctx.lineTo(0, 0);
ctx.fillStyle = "white";
ctx.fill();
// Add the bottom stroke
ctx.beginPath();
ctx.moveTo(...start);
ctx.quadraticCurveTo(...control, ...end);
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
const tipWidth = 0.2;
const base1 = bezierPoint(tipBase, start, control, end);
const base2 = bezierPoint(tipBase + tipWidth, start, control, end);
const tip: Point = [tipX, tipY];
// Draw the tip's background
const bgDistance = 5;
ctx.beginPath();
ctx.moveTo(...moveAway(base1, tip, bgDistance));
ctx.lineTo(tipX, tipY);
ctx.lineTo(...moveAway(base2, tip, bgDistance));
ctx.fillStyle = "white";
ctx.fill();
// Draw the tip's outline
ctx.beginPath();
ctx.moveTo(...base1);
ctx.lineTo(tipX, tipY);
ctx.lineTo(...base2);
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
}
export const speechbubbleCaption: CaptionDefinition = {
type: "speechbubble",
name: "Bubble",
render: (ctx, width, height, options) => {
renderSpeechbubble(ctx, width, height, options.bubbleTipX, options.bubbleTipY, options.bubbleTipBase);
},
};

View file

@ -0,0 +1,179 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { GoogleFontMetadata } from "./types";
const loadedFontFamilies = new Set<string>(["Arial"]);
const loadingFontFamilies = new Map<string, Promise<void>>();
const fontObjectUrls = new Set<string>();
export const createGoogleFontUrl = (family: string, options = "") =>
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, "+")}${options}&display=swap`;
export function getFontFamilyCss(fontFamily: string) {
const escaped = fontFamily.trim().replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
return `"${escaped || "Arial"}", sans-serif`;
}
export function getCanvasFont(size: number, fontFamily: string) {
return `${size}px ${getFontFamilyCss(fontFamily)}`;
}
function parseFontFaces(css: string) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
const fontDisplays = new Set<FontDisplay>(["auto", "block", "fallback", "optional", "swap"]);
return Array.from(sheet.cssRules)
.filter((rule): rule is CSSFontFaceRule => rule.type === CSSRule.FONT_FACE_RULE)
.map(rule => {
const src = rule.style.getPropertyValue("src");
const url = src.match(/url\((["']?)(.*?)\1\)/)?.[2];
if (!url) return null;
return {
descriptors: {
display: fontDisplays.has(rule.style.getPropertyValue("font-display") as FontDisplay)
? rule.style.getPropertyValue("font-display") as FontDisplay
: "swap",
stretch: rule.style.getPropertyValue("font-stretch") || undefined,
style: rule.style.getPropertyValue("font-style") || "normal",
unicodeRange: rule.style.getPropertyValue("unicode-range") || undefined,
weight: rule.style.getPropertyValue("font-weight") || "400"
} satisfies FontFaceDescriptors,
url
};
})
.filter((face): face is NonNullable<typeof face> => face !== null);
}
async function loadFontFace(family: string, url: string, descriptors: FontFaceDescriptors) {
try {
const response = await fetch(url);
if (!response.ok) return false;
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
fontObjectUrls.add(objectUrl);
try {
const font = new FontFace(family, `url(${objectUrl})`, descriptors);
await font.load();
document.fonts.add(font);
return true;
} catch {
URL.revokeObjectURL(objectUrl);
fontObjectUrls.delete(objectUrl);
return false;
}
} catch {
return false;
}
}
export function loadGoogleFont(fontFamily: string) {
const family = fontFamily.trim();
if (!family || loadedFontFamilies.has(family)) return Promise.resolve();
const loading = loadingFontFamilies.get(family);
if (loading) return loading;
const loadPromise = (async () => {
try {
const response = await fetch(createGoogleFontUrl(family));
if (!response.ok) return;
const css = await response.text();
if (!css) return;
const faces = parseFontFaces(css);
const results = await Promise.all(faces.map(face => loadFontFace(family, face.url, face.descriptors)));
const loaded = results.some(Boolean);
if (loaded) loadedFontFamilies.add(family);
} catch {
// font load failed silently
}
})();
loadingFontFamilies.set(family, loadPromise);
return loadPromise;
}
let cachedFonts: GoogleFontMetadata[] | null = null;
let fontsPromise: Promise<GoogleFontMetadata[]> | null = null;
export async function fetchAllGoogleFonts(): Promise<GoogleFontMetadata[]> {
if (cachedFonts) return cachedFonts;
if (fontsPromise) return fontsPromise;
fontsPromise = fetch("https://fonts.google.com/$rpc/fonts.fe.catalog.actions.metadata.MetadataService/FontSearch", {
body: JSON.stringify([["", null, null, null, null, null, 1], [5], null, 400]),
headers: {
"content-type": "application/json+protobuf",
"x-user-agent": "grpc-web-javascript/0.1"
},
method: "POST"
})
.then(response => response.ok ? response.json() : null)
.then(data => {
const rows = Array.isArray(data?.[1]) ? data[1] as unknown[] : [];
const fonts: GoogleFontMetadata[] = [];
for (const row of rows) {
if (!Array.isArray(row) || !Array.isArray(row[1])) continue;
const fontData = row[1] as unknown[];
const family = typeof fontData[0] === "string" ? fontData[0] : "";
if (!family || family.length > 100 || !/^[a-zA-Z0-9\s\-_']+$/.test(family)) continue;
const displayName = typeof fontData[1] === "string" ? fontData[1] : family;
const authors = Array.isArray(fontData[2])
? fontData[2].filter((author): author is string => typeof author === "string")
: [];
const category = typeof fontData[3] === "number" ? fontData[3] : undefined;
const variants = Array.isArray(fontData[6])
? fontData[6]
.filter((variant): variant is unknown[] => Array.isArray(variant))
.map(variant => {
const axesSource = Array.isArray(variant[0]) ? variant[0] as unknown[] : [];
const axes = axesSource
.filter((axis): axis is unknown[] => Array.isArray(axis))
.map(axis => {
const tag = axis[0];
const min = axis[1];
const max = axis[2];
if (typeof tag !== "string" || typeof min !== "number" || typeof max !== "number") {
return null;
}
return { tag, min, max };
})
.filter((axis): axis is NonNullable<typeof axis> => axis !== null);
return { axes };
})
: [];
fonts.push({
authors,
category,
displayName,
family,
popularity: 0,
variants
});
}
fonts.sort((a, b) => a.family.localeCompare(b.family));
cachedFonts = fonts;
return fonts;
})
.catch(() => {
cachedFonts = [];
return cachedFonts;
});
return fontsPromise;
}

View file

@ -0,0 +1,504 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Button } from "@components/Button";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { getCurrentChannel } from "@utils/discord";
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import definePlugin, { OptionType } from "@utils/types";
import { saveFile } from "@utils/web";
import type { RenderModalProps } from "@vencord/discord-types";
import { Menu, Modal, openModal, React, Select, Slider, TextInput, UploadHandler, useEffect, useRef, useState } from "@webpack/common";
import { CAPTIONS } from "./captions";
import { fetchAllGoogleFonts, getFontFamilyCss, loadGoogleFont } from "./fonts";
import css from "./styles.css?managed";
import { DEFAULT_OPTIONS, type GifMakerOptions, type GoogleFontMetadata } from "./types";
import { clamp, getInitialSize, getMediaInfo } from "./utils/contextMenu";
import { cleanupBlobUrl, createGif, loadImage, loadVideo } from "./utils/encoder";
import { applyTenorMp4Fix, collectCandidateUrls, isLikelyVideoUrl, normalizeUrl, orderCandidateUrls } from "./utils/gifPicker";
const cl = classNameFactory("vc-gifmaker-");
const logger = new Logger("gifMaker");
const settings = definePluginSettings({
lastWidth: {
type: OptionType.NUMBER,
default: DEFAULT_OPTIONS.width,
hidden: true,
description: ""
},
lastHeight: {
type: OptionType.NUMBER,
default: DEFAULT_OPTIONS.height,
hidden: true,
description: ""
},
lastCaptionMode: {
type: OptionType.STRING,
default: DEFAULT_OPTIONS.captionMode,
hidden: true,
description: ""
},
lastCaptionText: {
type: OptionType.STRING,
default: DEFAULT_OPTIONS.captionText,
hidden: true,
description: ""
},
lastCaptionSize: {
type: OptionType.NUMBER,
default: DEFAULT_OPTIONS.captionSize,
hidden: true,
description: ""
},
lastFontFamily: {
type: OptionType.STRING,
default: DEFAULT_OPTIONS.fontFamily,
hidden: true,
description: ""
},
lastBubbleTipBase: {
type: OptionType.NUMBER,
default: DEFAULT_OPTIONS.bubbleTipBase,
hidden: true,
description: ""
},
maxWidth: {
type: OptionType.NUMBER,
default: 1280,
description: "Maximum auto-fit width."
},
maxHeight: {
type: OptionType.NUMBER,
default: 720,
description: "Maximum auto-fit height."
},
});
const GIFMAKER_ID = "vc-gifmaker";
function resolveInitialSize(sourceWidth?: number, sourceHeight?: number, storedWidth?: number, storedHeight?: number): [number, number] {
return getInitialSize(settings.store.maxWidth, settings.store.maxHeight, sourceWidth, sourceHeight, storedWidth, storedHeight);
}
const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
const info = getMediaInfo(props);
if (!info) return;
children.push(
<Menu.MenuItem
id={GIFMAKER_ID}
label="Make GIF"
action={() => openModal(modalProps => (
<GifMakerModal url={info.url} isVideo={info.isVideo} sourceWidth={info.sourceWidth} sourceHeight={info.sourceHeight} {...modalProps} />
))}
/>
);
};
const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
if (!props?.src) return;
if ("href" in props && !props.src) return;
if (props.target?.classList?.contains("emoji")) return;
const info = getMediaInfo(props);
if (!info) return;
children.push(
<Menu.MenuItem
id={GIFMAKER_ID}
label="Make GIF"
action={() => openModal(modalProps => <GifMakerModal url={info.url} isVideo={info.isVideo} sourceWidth={info.sourceWidth} sourceHeight={info.sourceHeight} {...modalProps} />)}
/>
);
};
interface SelectOption {
key: string;
label: string;
value: string;
}
function FontOptionLabel({ option }: { option: SelectOption; }) {
React.useEffect(() => {
void loadGoogleFont(option.value);
}, [option.value]);
return (
<span style={{ fontFamily: getFontFamilyCss(option.value) }}>
{option.label}
</span>
);
}
function SelectedFontLabel({ options }: { options: SelectOption[]; }) {
const option = options[0];
return option ? <FontOptionLabel option={option} /> : null;
}
function FontSelector({ initialFont, onSelect }: { initialFont: string; onSelect: (font: GoogleFontMetadata) => void; }) {
const [fonts, setFonts] = React.useState<GoogleFontMetadata[]>([]);
const [selectedFont, setSelectedFont] = React.useState<string | null>(initialFont !== "Arial" ? initialFont : null);
React.useEffect(() => {
void fetchAllGoogleFonts().then(fetchedFonts => {
setFonts(fetchedFonts);
});
}, []);
const options = fonts.map<SelectOption>(font => ({
key: font.family,
label: font.displayName,
value: font.family
}));
const handleSelect = (fontFamily: string) => {
setSelectedFont(fontFamily);
const font = fonts.find(entry => entry.family === fontFamily);
if (!font) return;
void loadGoogleFont(fontFamily);
onSelect(font);
};
if (!fonts.length) {
return <div>Loading fonts...</div>;
}
return (
<Select
placeholder="Select a font..."
options={options}
maxVisibleItems={10}
closeOnSelect={true}
select={handleSelect}
isSelected={value => value === selectedFont}
serialize={value => String(value)}
renderOptionLabel={(option: SelectOption) => <FontOptionLabel option={option} />}
renderOptionValue={(options: SelectOption[]) => <SelectedFontLabel options={options} />}
/>
);
}
function GifMakerModal({ url, isVideo, sourceWidth, sourceHeight, ...props }: RenderModalProps & { url: string; isVideo: boolean; sourceWidth?: number; sourceHeight?: number; }) {
const [options, setOptions] = useState<GifMakerOptions>(() => {
const [width, height] = resolveInitialSize(sourceWidth, sourceHeight, settings.store.lastWidth, settings.store.lastHeight);
return {
width, height,
captionMode: settings.store.lastCaptionMode as GifMakerOptions["captionMode"],
captionText: settings.store.lastCaptionText,
captionSize: settings.store.lastCaptionSize,
fontFamily: settings.store.lastFontFamily as string || DEFAULT_OPTIONS.fontFamily,
bubbleTipX: DEFAULT_OPTIONS.bubbleTipX,
bubbleTipY: DEFAULT_OPTIONS.bubbleTipY,
bubbleTipBase: settings.store.lastBubbleTipBase,
};
});
const [gifBlob, setGifBlob] = useState<Blob | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const previewRef = useRef<HTMLImageElement>(null);
const optionsRef = useRef(options);
const generationRef = useRef(0);
optionsRef.current = options;
const patch = (partial: Partial<GifMakerOptions>) => {
setOptions(prev => {
const next = { ...prev, ...partial };
settings.store.lastWidth = next.width;
settings.store.lastHeight = next.height;
settings.store.lastCaptionMode = next.captionMode;
settings.store.lastCaptionText = next.captionText;
settings.store.lastCaptionSize = next.captionSize;
settings.store.lastFontFamily = next.fontFamily;
settings.store.lastBubbleTipBase = next.bubbleTipBase;
return next;
});
};
useEffect(() => {
if (sourceWidth && sourceHeight) {
const [w, h] = resolveInitialSize(sourceWidth, sourceHeight);
setOptions(prev => ({ ...prev, width: w, height: h }));
return;
}
if (isVideo) {
loadVideo(url).then(v => {
const [w, h] = resolveInitialSize(v.videoWidth, v.videoHeight);
setOptions(prev => ({ ...prev, width: w, height: h }));
cleanupBlobUrl(v);
v.remove();
}).catch(err => logger.error("auto-detect video failed", err));
return;
}
loadImage(url).then(img => {
const [w, h] = resolveInitialSize(img.naturalWidth, img.naturalHeight);
setOptions(prev => ({ ...prev, width: w, height: h }));
cleanupBlobUrl(img);
}).catch(err => logger.error("auto-detect image failed", err));
}, [sourceWidth, sourceHeight]);
useEffect(() => {
const timer = setTimeout(() => {
const gen = ++generationRef.current;
setGenerating(true);
const { current } = optionsRef;
createGif(url, isVideo, current).then(blob => {
if (gen !== generationRef.current) {
URL.revokeObjectURL(URL.createObjectURL(blob));
return;
}
setError(null);
setGifBlob(blob);
setPreviewUrl(prev => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
setGenerating(false);
}).catch((err: unknown) => {
if (gen !== generationRef.current) return;
logger.error("GIF generation failed", err);
setError(err instanceof Error ? err.message : String(err));
setGenerating(false);
});
}, 300);
return () => clearTimeout(timer);
}, [JSON.stringify(options.captionMode), options.captionText, options.captionSize, options.fontFamily, options.bubbleTipX, options.bubbleTipY, options.bubbleTipBase, options.width, options.height]);
const handlePreviewClick = (e: React.MouseEvent<HTMLImageElement>) => {
if (options.captionMode !== "speechbubble") return;
const img = previewRef.current;
if (!img) return;
const rect = img.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width * options.width;
const y = (e.clientY - rect.top) / rect.height * options.height;
patch({ bubbleTipX: x, bubbleTipY: y });
};
const handleExport = () => {
if (!gifBlob) return;
saveFile(new File([gifBlob], "export.gif", { type: "image/gif" }));
};
const handleSend = () => {
if (!gifBlob) return;
const channel = getCurrentChannel();
if (!channel) return;
const file = new File([gifBlob], "export.gif", { type: "image/gif" });
UploadHandler.promptToUpload([file], channel, 0);
props.onClose?.();
};
return (
<Modal
{...props}
size="lg"
title="Make GIF"
actions={[
{
text: "Send",
variant: "primary",
onClick: handleSend
},
{
text: "Export",
variant: "primary",
onClick: handleExport
}
]}
>
<div className={cl("modal-body")}>
<div className={cl("preview-section")}>
<div className={cl("preview-wrapper")}>
<img
ref={previewRef}
alt="GIF preview"
src={previewUrl ?? ""}
onClick={handlePreviewClick}
className={cl("preview", { "preview-generating": generating, "preview-crosshair": options.captionMode === "speechbubble" })}
/>
{generating && (
<div className={cl("generating-overlay")}>
Generating GIF...
</div>
)}
{error && !generating && (
<div className={cl("error-overlay")}>
{error}
</div>
)}
</div>
</div>
<div className={cl("controls-section")}>
<div className={cl("section-heading")}>Captions</div>
<div style={{ display: "flex", gap: 8 }} className={Margins.bottom16}>
{CAPTIONS.map(c => (
<Button
key={c.type}
size="min"
onClick={() => patch({ captionMode: c.type })}
variant={options.captionMode === c.type ? "primary" : "secondary"}
>
{c.name}
</Button>
))}
</div>
{options.captionMode === "caption" && (
<div className={cl("section")}>
<div className={Margins.bottom16}>
<label className={cl("label")}>Text</label>
<TextInput
value={options.captionText}
onChange={v => patch({ captionText: v })}
placeholder="Enter caption..."
/>
</div>
<div className="vc-gifmaker-font-range">
<div>Font</div>
<div className={cl("font-selector")}>
<FontSelector
initialFont={options.fontFamily}
onSelect={font => {
patch({ fontFamily: font.family });
}}
/>
</div>
</div>
<div className="vc-gifmaker-font-range">
<div>Font size</div>
<Slider
initialValue={options.captionSize}
onValueChange={v => patch({ captionSize: v })}
markers={[10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100]}
minValue={10}
maxValue={100}
/>
<span>{(options.captionSize).toFixed(2)}px</span>
</div>
</div>
)}
{options.captionMode === "speechbubble" && (
<div className={cl("section")}>
<div className={cl("section-hint")}>
Click on the preview to position the bubble tip
</div>
<div className="vc-gifmaker-font-range">
<div>Tip Base</div>
<Slider
initialValue={Math.round(options.bubbleTipBase * 100)}
onValueChange={v => patch({ bubbleTipBase: v / 100 })}
minValue={0}
maxValue={80}
/>
<span>{Math.round(options.bubbleTipBase * 100)}%</span>
</div>
</div>
)}
<div className={cl("section-heading")}>Dimensions</div>
<div className={cl("dims-row")}>
<div className={cl("field")}>
<label className={cl("label")}>Width</label>
<input
type="number"
min={32}
max={1024}
value={options.width}
onChange={e => patch({ width: Number(e.target.value) })}
onBlur={e => patch({ width: clamp(Number(e.target.value), 32, 1024, 32) })}
className={cl("input")}
aria-label="Width"
/>
</div>
<div className={cl("field")}>
<label className={cl("label")}>Height</label>
<input
type="number"
min={32}
max={1024}
value={options.height}
onChange={e => patch({ height: Number(e.target.value) })}
onBlur={e => patch({ height: clamp(Number(e.target.value), 32, 1024, 32) })}
className={cl("input")}
aria-label="Height"
/>
</div>
</div>
</div>
</div>
</Modal>
);
}
function openGifMakerFromItem(item) {
const isGif = item.format === 1;
const directUrl = typeof item.src === "string" ? item.src : null;
const candidates = collectCandidateUrls(item);
const adjustedCandidates = new Set<string>();
if (directUrl) adjustedCandidates.add(applyTenorMp4Fix(normalizeUrl(directUrl), isGif));
for (const candidate of candidates) {
adjustedCandidates.add(applyTenorMp4Fix(candidate, isGif));
}
const preferredUrl = directUrl ? applyTenorMp4Fix(normalizeUrl(directUrl), isGif) : null;
const orderedUrls = orderCandidateUrls(preferredUrl, adjustedCandidates);
if (!orderedUrls.length) return;
const firstUrl = orderedUrls[0];
const isVideo = isLikelyVideoUrl(firstUrl);
openModal(modalProps => (
<GifMakerModal url={firstUrl} isVideo={isVideo} {...modalProps} />
));
}
export default definePlugin({
name: "gifMaker",
description: "Create and caption GIFs from any media in chat or the GIF picker.",
authors: [EquicordDevs.Leon135, EquicordDevs.benjii],
settings,
managedStyle: css,
contextMenus: {
"message": messageContextMenuPatch,
"image-context": imageContextMenuPatch
},
start() {
void fetchAllGoogleFonts();
},
gifPickerContextMenu(instance, _e: React.MouseEvent) {
if (!instance?.props?.item?.src) return null;
return (
<Menu.MenuItem
id="gif-maker-edit"
key="gif-maker-edit"
label="Edit GIF"
action={() => openGifMakerFromItem(instance?.props?.item)}
/>
);
},
});

View file

@ -0,0 +1,37 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const ALLOWED_MEDIA_HOSTS = new Set([
"cdn.discordapp.com",
"images-ext-1.discordapp.net",
"images-ext-2.discordapp.net",
"media.discordapp.net",
"media.tenor.com",
"tenor.com",
"media.giphy.com",
"media0.giphy.com",
"media1.giphy.com",
"media2.giphy.com",
"media3.giphy.com",
"media4.giphy.com",
]);
export async function fetchMedia(_: unknown, url: string) {
const parsed = URL.parse(url);
if (!parsed || !ALLOWED_MEDIA_HOSTS.has(parsed.hostname))
throw new Error("Invalid URL");
const res = await fetch(parsed, { headers: { Accept: "*/*" } });
if (!res.ok) throw new Error(`Server error ${res.status}`);
const blob = await res.blob();
if (blob.size === 0) throw new Error(`Empty body (${res.status}) from ${url}`);
return {
data: await blob.arrayBuffer(),
type: blob.type || "application/octet-stream"
};
}

View file

@ -0,0 +1,189 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
.vc-gifmaker-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 500px;
}
.vc-gifmaker-preview-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
overflow: hidden;
}
.vc-gifmaker-preview-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.vc-gifmaker-preview {
border-radius: 8px;
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
.vc-gifmaker-generating-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 50%);
border-radius: 8px;
color: var(--text-normal);
font-weight: 600;
}
.vc-gifmaker-error-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 50%);
border-radius: 8px;
color: var(--status-danger);
font-weight: 600;
font-size: 13px;
padding: 20px;
text-align: center;
}
.vc-gifmaker-controls-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.vc-gifmaker-section-heading {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-top: 12px;
margin-bottom: 4px;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-accent);
}
.vc-gifmaker-section-heading:first-of-type {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.vc-gifmaker-preview-generating {
opacity: 0.5;
}
.vc-gifmaker-preview-crosshair {
cursor: crosshair;
}
.vc-gifmaker-label {
display: block;
margin-bottom: 4px;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.vc-gifmaker-input {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--background-modifier-accent);
background: var(--input-background);
color: var(--text-normal);
box-sizing: border-box;
font-size: 14px;
transition: border-color .15s ease;
}
.vc-gifmaker-input:focus {
outline: none;
border-color: var(--brand-500);
}
.vc-gifmaker-dims-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.vc-gifmaker-field {
flex: 1;
}
.vc-gifmaker-section {
margin-bottom: 4px;
}
.vc-gifmaker-section-hint {
margin-bottom: 8px;
color: var(--text-muted);
font-size: 12px;
font-style: italic;
}
.vc-gifmaker-font-selector {
flex: 1 1 auto;
min-width: 0;
}
.vc-gifmaker-font-selector > div {
width: 100%;
}
.vc-gifmaker-font-range {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.vc-gifmaker-font-range > div:first-child {
min-width: 62px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.vc-gifmaker-font-range span {
min-width: 36px;
text-align: right;
font-size: 13px;
color: var(--text-normal);
font-weight: 600;
flex-shrink: 0;
}
.vc-gifmaker-capture-wrapper {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
opacity: 0.01;
pointer-events: none;
overflow: hidden;
}

View file

@ -0,0 +1,56 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export type CaptionMode = "none" | "caption" | "speechbubble";
export interface CaptionDefinition {
type: CaptionMode;
name: string;
render: (ctx: CanvasRenderingContext2D, width: number, height: number, options: GifMakerOptions) => void;
}
export interface GifMakerOptions {
width: number;
height: number;
captionMode: CaptionMode;
captionText: string;
captionSize: number;
fontFamily: string;
bubbleTipX: number;
bubbleTipY: number;
bubbleTipBase: number;
}
export const DEFAULT_OPTIONS: GifMakerOptions = {
width: 256,
height: 256,
captionMode: "none",
captionText: "",
captionSize: 40,
fontFamily: "Arial",
bubbleTipX: 80,
bubbleTipY: 80,
bubbleTipBase: 0.1,
};
export interface GoogleFontAxis {
tag: string;
min: number;
max: number;
}
export interface GoogleFontVariant {
axes: GoogleFontAxis[];
}
export interface GoogleFontMetadata {
family: string;
displayName: string;
authors: string[];
category?: number;
popularity?: number;
variants: GoogleFontVariant[];
}

View file

@ -0,0 +1,101 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { lodash } from "@webpack/common";
import { DEFAULT_OPTIONS } from "../types";
export const MEDIA_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", "video/mp4", "video/webm", "video/quicktime"];
export const MEDIA_EXT_RE = /\.(png|jpe?g|webp|gif|mp4|webm|mov)([?#]|$)/i;
export const VIDEO_EXT_RE = /\.(mp4|webm|mov)([?#]|$)/i;
interface MediaAttachment {
content_type?: string;
proxy_url?: string;
url?: string;
width?: number;
height?: number;
}
interface MediaEmbed {
video?: { proxyURL?: string; url?: string; width?: number; height?: number; };
image?: { proxyURL?: string; url?: string; width?: number; height?: number; };
thumbnail?: { proxyURL?: string; url?: string; width?: number; height?: number; };
}
interface MediaMessage {
attachments?: MediaAttachment[];
embeds?: MediaEmbed[];
}
export function getMediaInfo(props: Record<string, unknown>): { url: string; isVideo: boolean; sourceWidth?: number; sourceHeight?: number; } | null {
const msg = props.message as MediaMessage | undefined;
const directAttachment = props.attachment as MediaAttachment | undefined;
if (directAttachment?.proxy_url && MEDIA_TYPES.some(t => directAttachment.content_type?.startsWith(t))) {
return {
url: directAttachment.proxy_url ?? directAttachment.url,
isVideo: directAttachment.content_type?.startsWith("video/") || false,
sourceWidth: directAttachment.width,
sourceHeight: directAttachment.height
};
}
const msgAttachment = msg?.attachments?.find(a => MEDIA_TYPES.some(t => a.content_type?.startsWith(t)));
if (msgAttachment?.proxy_url) {
return {
url: msgAttachment.proxy_url ?? msgAttachment.url,
isVideo: msgAttachment.content_type?.startsWith("video/") || false,
sourceWidth: msgAttachment.width,
sourceHeight: msgAttachment.height
};
}
if (msg?.embeds) {
for (const embed of msg.embeds) {
const v = embed?.video;
if (v?.proxyURL || v?.url) {
return { url: v.proxyURL ?? (v.url || ""), isVideo: true, sourceWidth: v.width, sourceHeight: v.height };
}
const i = embed?.image ?? embed?.thumbnail;
if (i?.proxyURL || i?.url) {
return { url: i.proxyURL ?? (i.url || ""), isVideo: false, sourceWidth: i.width, sourceHeight: i.height };
}
}
}
const linkUrl = (props.itemHref ?? props.itemSrc ?? props.src) as string | undefined;
if (linkUrl && MEDIA_EXT_RE.test(linkUrl)) {
return {
url: linkUrl,
isVideo: VIDEO_EXT_RE.test(linkUrl)
};
}
return null;
}
export function clamp(val: number, min: number, max: number, fallback: number): number {
return lodash.clamp(val || fallback, min, max);
}
export function getInitialSize(
maxW: number,
maxH: number,
sourceWidth?: number,
sourceHeight?: number,
storedWidth?: number,
storedHeight?: number
): [number, number] {
if (sourceWidth && sourceHeight) {
if (sourceWidth <= maxW && sourceHeight <= maxH) {
return [sourceWidth, sourceHeight];
}
const scale = Math.min(maxW / sourceWidth, maxH / sourceHeight);
return [Math.round(sourceWidth * scale), Math.round(sourceHeight * scale)];
}
return [storedWidth ?? DEFAULT_OPTIONS.width, storedHeight ?? DEFAULT_OPTIONS.height];
}

View file

@ -0,0 +1,451 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { sleep } from "@utils/misc";
import type { PluginNative } from "@utils/types";
import { applyPalette, GIFEncoder, quantize } from "gifenc";
import { CAPTIONS } from "../captions";
import { measureTextLines } from "../captions/caption";
import type { GifMakerOptions } from "../types";
const MAX_FRAMES = 50;
const INTERNAL_FPS = 10;
const INTERNAL_MAX_DURATION = 3;
const PALETTE_COLORS = 255;
const ALLOWED_MEDIA_HOSTS = new Set([
"cdn.discordapp.com",
"images-ext-1.discordapp.net",
"images-ext-2.discordapp.net",
"media.discordapp.net",
"media.tenor.com",
"tenor.com",
"media.giphy.com",
"media0.giphy.com",
"media1.giphy.com",
"media2.giphy.com",
"media3.giphy.com",
"media4.giphy.com",
]);
const MediaNative = VencordNative?.pluginHelpers?.gifMaker as PluginNative<typeof import("../native")> | undefined;
const blobUrlMap = new WeakMap<HTMLElement, string>();
function isDiscordCdnUrl(url: string): boolean {
try {
return ALLOWED_MEDIA_HOSTS.has(new URL(url).hostname);
} catch {
return false;
}
}
async function getMediaBlobUrl(url: string): Promise<string> {
if (MediaNative) {
const { data, type } = await MediaNative.fetchMedia(url);
if (data) return URL.createObjectURL(new Blob([data], { type }));
}
const res = await fetch(url);
const blob = await res.blob();
return URL.createObjectURL(blob);
}
const mediaProxyParser = /^https:\/\/(?:images-ext-\d+|cdn)\.discord(?:app|cdn)\.net\/external\/[^/]+\/(?<protocol>https?)\/(?<rest>.+)$/i;
function resolveMediaUrl(url: string): string {
const normalized = url.startsWith("//") ? `https:${url}` : url;
const match = normalized.match(mediaProxyParser);
if (match?.groups) {
const { protocol, rest } = match.groups;
return `${decodeURIComponent(protocol)}://${decodeURIComponent(rest)}`;
}
return normalized;
}
export function cleanupBlobUrl(el: HTMLElement) {
const blobUrl = blobUrlMap.get(el);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrlMap.delete(el);
}
}
export function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
img.crossOrigin = "anonymous";
const resolved = resolveMediaUrl(url);
if (isDiscordCdnUrl(resolved)) {
getMediaBlobUrl(resolved).then(blobUrl => {
blobUrlMap.set(img, blobUrl);
img.src = blobUrl;
}).catch(reject);
} else {
img.src = resolved;
}
});
}
function createVideoElement(src: string): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
const v = document.createElement("video");
v.preload = "auto";
v.muted = true;
v.crossOrigin = "anonymous";
v.addEventListener("loadedmetadata", () => {
const { duration, videoWidth, videoHeight } = v;
if (!isFinite(duration) || duration <= 0 || !videoWidth || !videoHeight) {
reject(new Error(`Invalid video: duration=${duration} w=${videoWidth} h=${videoHeight}`));
return;
}
resolve(v);
}, { once: true });
v.addEventListener("error", () => {
reject(new Error(`Video load failed: ${src} (code=${v.error?.code})`));
}, { once: true });
v.src = src;
v.load();
});
}
export function loadVideo(url: string): Promise<HTMLVideoElement> {
const resolved = resolveMediaUrl(url);
if (isDiscordCdnUrl(resolved)) {
return getMediaBlobUrl(resolved).then(blobUrl =>
createVideoElement(blobUrl).then(video => {
blobUrlMap.set(video, blobUrl);
return video;
})
);
}
return createVideoElement(resolved);
}
function waitForSeek(video: HTMLVideoElement): Promise<void> {
return new Promise(resolve => {
if (video.seeking) {
video.addEventListener("seeked", () => resolve(), { once: true });
} else {
resolve();
}
});
}
export function getCaptionHeight(ctx: CanvasRenderingContext2D, width: number, options: GifMakerOptions): number {
if (options.captionMode === "caption" && options.captionText) {
const { lines, lineHeight } = measureTextLines(ctx, options.captionText, options.captionSize, options.fontFamily, width - 20);
return Math.ceil(lines.length * lineHeight + 20);
}
return 0;
}
async function encodeFrames(
width: number,
height: number,
options: GifMakerOptions,
frameCount: number,
drawFrame: (ctx: CanvasRenderingContext2D, i: number) => void | Promise<void>,
): Promise<Blob> {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return new Blob();
const captionHeight = getCaptionHeight(ctx, width, options);
const gifHeight = height + captionHeight;
canvas.width = width;
canvas.height = gifHeight;
const delay = Math.round(1000 / INTERNAL_FPS);
const frameData: Uint8ClampedArray[] = [];
for (let i = 0; i < frameCount; i++) {
ctx.clearRect(0, 0, width, gifHeight);
ctx.save();
ctx.translate(0, captionHeight);
await drawFrame(ctx, i);
ctx.restore();
const caption = CAPTIONS.find(c => c.type === options.captionMode);
if (caption) {
ctx.save();
caption.render(ctx, width, captionHeight > 0 ? captionHeight : height, options);
ctx.restore();
}
frameData.push(ctx.getImageData(0, 0, width, gifHeight).data);
}
const totalLength = frameData.reduce((sum, data) => sum + data.length, 0);
const combined = new Uint8ClampedArray(totalLength);
let offset = 0;
for (const data of frameData) {
combined.set(data, offset);
offset += data.length;
}
const palette = quantize(combined, PALETTE_COLORS);
const gif = GIFEncoder();
for (let i = 0; i < frameCount; i++) {
const index = applyPalette(frameData[i], palette);
gif.writeFrame(index, width, gifHeight, {
delay,
palette: i === 0 ? palette : undefined,
});
}
gif.finish();
const bytes = gif.bytesView();
return new Blob([new Uint8Array(bytes.buffer as ArrayBuffer, bytes.byteOffset, bytes.byteLength)], { type: "image/gif" });
}
async function createGifFromImage(url: string, options: GifMakerOptions): Promise<Blob> {
const img = await loadImage(url);
try {
return await encodeFrames(options.width, options.height, options, 1, ctx => {
ctx.drawImage(img, 0, 0, options.width, options.height);
});
} finally {
cleanupBlobUrl(img);
}
}
async function createGifFromVideo(url: string, options: GifMakerOptions): Promise<Blob> {
const video = await loadVideo(url);
try {
const { duration } = video;
const frameCount = Math.min(
INTERNAL_FPS * INTERNAL_MAX_DURATION,
Math.floor(duration * INTERNAL_FPS),
MAX_FRAMES
);
const interval = duration / frameCount;
return await encodeFrames(options.width, options.height, options, frameCount, async (ctx, i) => {
video.currentTime = i * interval;
await waitForSeek(video);
ctx.drawImage(video, 0, 0, options.width, options.height);
});
} finally {
cleanupBlobUrl(video);
}
}
export interface SourceFrameInfo {
fps?: number;
frameCount?: number;
frameWidth: number;
frameHeight: number;
}
function hasExt(url: string, ext: string): boolean {
try {
const normalized = url.startsWith("//") ? `https:${url}` : url;
const match = normalized.match(mediaProxyParser);
const resolved = match?.groups
? `${decodeURIComponent(match.groups.protocol)}://${decodeURIComponent(match.groups.rest)}`
: normalized;
return new URL(resolved).pathname.toLowerCase().endsWith(ext);
} catch {
return url.toLowerCase().endsWith(ext);
}
}
export async function getSourceFrameInfo(url: string, isVideo: boolean): Promise<SourceFrameInfo | null> {
if (isVideo) return getVideoSourceInfo(url);
if (hasExt(url, ".gif")) return getGifInfo(url);
if (hasExt(url, ".webp")) return getWebpInfo(url);
return null;
}
export async function createGif(url: string, isVideo: boolean, options: GifMakerOptions): Promise<Blob> {
if (isVideo) return createGifFromVideo(url, options);
const info = await getSourceFrameInfo(url, false);
if (info?.frameCount && info.frameCount > 1) {
return createGifFromAnimatedImage(url, options);
}
return createGifFromImage(url, options);
}
export async function getGifInfo(url: string): Promise<SourceFrameInfo | null> {
if (!MediaNative) return null;
try {
const resolved = resolveMediaUrl(url);
const { data } = await MediaNative.fetchMedia(resolved);
const bytes = new Uint8Array(data);
if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) return null;
const frameWidth = bytes[6] | (bytes[7] << 8);
const frameHeight = bytes[8] | (bytes[9] << 8);
let frameCount = 0;
let totalDelay = 0;
let delayCount = 0;
for (let i = 0; i < bytes.length - 8; i++) {
if (bytes[i] === 0x2C) frameCount++;
if (bytes[i] === 0x21 && bytes[i + 1] === 0xF9 && bytes[i + 2] === 0x04) {
const delay = bytes[i + 4] | (bytes[i + 5] << 8);
if (delay > 0) {
totalDelay += delay;
delayCount++;
}
}
}
if (frameCount > 1 && delayCount > 0) {
const avgFps = Math.round(100 / (totalDelay / delayCount));
return { fps: Math.max(1, Math.min(60, avgFps)), frameCount, frameWidth, frameHeight };
}
return null;
} catch {
return null;
}
}
async function getWebpInfo(url: string): Promise<SourceFrameInfo | null> {
if (!MediaNative) return null;
try {
const resolved = resolveMediaUrl(url);
const { data } = await MediaNative.fetchMedia(resolved);
const bytes = new Uint8Array(data);
if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46 ||
bytes[8] !== 0x57 || bytes[9] !== 0x45 || bytes[10] !== 0x42 || bytes[11] !== 0x50) {
return null;
}
let hasAnimation = false;
let canvasWidth = 0;
let canvasHeight = 0;
let frameCount = 0;
let totalDelay = 0;
let delayCount = 0;
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkSize = bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
const fourCC = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
if (fourCC === "VP8X" && offset + 18 <= bytes.length) {
hasAnimation = !!(bytes[offset + 8] & 0x02);
canvasWidth = ((bytes[offset + 12] | (bytes[offset + 13] << 8) | (bytes[offset + 14] << 16)) & 0xFFFFFF) + 1;
canvasHeight = ((bytes[offset + 15] | (bytes[offset + 16] << 8) | (bytes[offset + 17] << 16)) & 0xFFFFFF) + 1;
} else if (fourCC === "ANMF" && offset + 23 <= bytes.length) {
frameCount++;
const delayMs = bytes[offset + 20] | (bytes[offset + 21] << 8) | (bytes[offset + 22] << 16);
if (delayMs > 0) {
totalDelay += delayMs;
delayCount++;
}
}
offset += 8 + chunkSize;
if (chunkSize % 2 === 1) offset++;
}
if (hasAnimation && frameCount > 1 && delayCount > 0) {
const avgFps = Math.round(1000 / (totalDelay / delayCount));
return { fps: Math.max(1, Math.min(60, avgFps)), frameCount, frameWidth: canvasWidth, frameHeight: canvasHeight };
}
return null;
} catch {
return null;
}
}
async function getVideoSourceInfo(url: string): Promise<SourceFrameInfo | null> {
try {
const resolved = resolveMediaUrl(url);
let src: string;
let needsCleanup = false;
if (isDiscordCdnUrl(resolved)) {
src = await getMediaBlobUrl(resolved);
needsCleanup = true;
} else {
src = resolved;
}
return new Promise(resolve => {
const v = document.createElement("video");
v.preload = "metadata";
v.muted = true;
v.crossOrigin = "anonymous";
v.addEventListener("loadedmetadata", () => {
const info: SourceFrameInfo = { frameWidth: v.videoWidth, frameHeight: v.videoHeight };
if (needsCleanup) URL.revokeObjectURL(src);
v.remove();
resolve(info);
}, { once: true });
v.addEventListener("error", () => {
if (needsCleanup) URL.revokeObjectURL(src);
v.remove();
resolve(null);
}, { once: true });
v.src = src;
v.load();
});
} catch {
return null;
}
}
async function createGifFromAnimatedImage(url: string, options: GifMakerOptions): Promise<Blob> {
const resolved = resolveMediaUrl(url);
const img = new Image();
img.crossOrigin = "anonymous";
const wrapper = document.createElement("div");
wrapper.className = "vc-gifmaker-capture-wrapper";
wrapper.appendChild(img);
document.body.appendChild(wrapper);
await new Promise<void>((resolve, reject) => {
img.onload = () => {
setTimeout(() => resolve(), 200);
};
img.onerror = () => {
wrapper.remove();
reject(new Error(`Failed to load: ${url}`));
};
if (isDiscordCdnUrl(resolved)) {
getMediaBlobUrl(resolved).then(blobUrl => {
blobUrlMap.set(img, blobUrl);
img.src = blobUrl;
}).catch(reject);
} else {
img.src = resolved;
}
});
try {
const frameCount = Math.min(INTERNAL_FPS * INTERNAL_MAX_DURATION, MAX_FRAMES);
const interval = 1000 / INTERNAL_FPS;
return await encodeFrames(options.width, options.height, options, frameCount, async (ctx, i) => {
if (i > 0) {
await sleep(interval);
}
ctx.drawImage(img, 0, 0, options.width, options.height);
});
} finally {
wrapper.remove();
cleanupBlobUrl(img);
}
}

View file

@ -0,0 +1,98 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const URL_KEYWORDS = ["url", "src", "proxy"];
const URL_CONTAINER_KEYS = ["gif", "media", "image", "video", "thumbnail", "preview", "result", "item"];
export function normalizeUrl(url: string) {
return url.startsWith("//") ? `https:${url}` : url;
}
export function looksLikeUrl(value: string) {
return value.startsWith("http://") || value.startsWith("https://") || value.startsWith("//");
}
export function applyTenorMp4Fix(url: string, isGif: boolean) {
if (isGif) return url;
try {
const { host } = new URL(url);
if (!host.endsWith("tenor.com")) return url;
} catch {
return url;
}
const typeIndex = url.lastIndexOf("/") - 1;
if (typeIndex <= 0 || url[typeIndex] === "o") return url;
return url.slice(0, typeIndex) + "o" + url.slice(typeIndex + 1);
}
export function collectCandidateUrls(source: unknown, depth = 0, out = new Set<string>()) {
if (!source || depth > 2) return out;
if (typeof source === "string") {
if (looksLikeUrl(source)) out.add(normalizeUrl(source));
return out;
}
if (Array.isArray(source)) {
for (const entry of source) collectCandidateUrls(entry, depth + 1, out);
return out;
}
if (typeof source !== "object") return out;
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
const keyLower = key.toLowerCase();
if (typeof value === "string") {
if (looksLikeUrl(value) && URL_KEYWORDS.some(keyword => keyLower.includes(keyword))) {
out.add(normalizeUrl(value));
}
continue;
}
if (value && typeof value === "object" && URL_CONTAINER_KEYS.some(keyword => keyLower.includes(keyword))) {
collectCandidateUrls(value, depth + 1, out);
}
}
return out;
}
export function scoreUrl(url: string) {
let host = "";
try {
host = new URL(url).host;
} catch {
// invalid URL, host remains empty
}
let score = 0;
if (host.endsWith("discordapp.net") || host.endsWith("discordapp.com")) score += 100;
if (host.includes("images-ext")) score += 20;
if (host.includes("media.discordapp.net") || host.includes("cdn.discordapp.com")) score += 10;
if (host.endsWith("klipy.com")) score += 5;
if (host.endsWith("tenor.com")) score += 5;
if (url.includes(".gif")) score += 1;
return score;
}
export function orderCandidateUrls(preferred: string | null, candidates: Set<string>) {
const all = Array.from(candidates);
if (!all.length) return [];
const rest = preferred ? all.filter(url => url !== preferred) : all;
rest.sort((a, b) => scoreUrl(b) - scoreUrl(a));
return preferred ? [preferred, ...rest] : rest;
}
export function isLikelyVideoUrl(url: string) {
return /\.(webm|mp4|m4v)(\?|$)/i.test(url);
}

View file

@ -20,6 +20,7 @@ import { AudioProcessor } from "@api/AudioPlayer";
import type { ProfileBadge } from "@api/Badges";
import type { ChatBarButtonData, ChatBarButtonWrapperData } from "@api/ChatButtons";
import type { NavContextMenuPatchCallback } from "@api/ContextMenu";
import type { GifPickerContextMenuItemFactory } from "@api/GifPickerContextMenu";
import { HeaderBarButtonData } from "@api/HeaderBar";
import type { MemberListDecoratorFactory } from "@api/MemberListDecorators";
import type { MessageAccessoryFactory } from "@api/MessageAccessories";
@ -236,6 +237,7 @@ export interface PluginDef {
renderProfileCollection?: ProfileCollectionData;
chatBarButtonWrapper?: ChatBarButtonWrapperData;
renderProfileSection?: ProfileSectionData;
gifPickerContextMenu?: GifPickerContextMenuItemFactory;
/**
* A Vencord plugin that is modified for extra features in Equicord