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:
parent
621dfb74de
commit
046281c768
20 changed files with 1954 additions and 44 deletions
68
src/api/GifPickerContextMenu.tsx
Normal file
68
src/api/GifPickerContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
31
src/equicordplugins/_api/extraContextMenusAPI.ts
Normal file
31
src/equicordplugins/_api/extraContextMenusAPI.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
58
src/equicordplugins/gifMaker/captions/caption.ts
Normal file
58
src/equicordplugins/gifMaker/captions/caption.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
16
src/equicordplugins/gifMaker/captions/index.ts
Normal file
16
src/equicordplugins/gifMaker/captions/index.ts
Normal 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,
|
||||
];
|
||||
13
src/equicordplugins/gifMaker/captions/none.ts
Normal file
13
src/equicordplugins/gifMaker/captions/none.ts
Normal 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: () => { },
|
||||
};
|
||||
85
src/equicordplugins/gifMaker/captions/speechbubble.ts
Normal file
85
src/equicordplugins/gifMaker/captions/speechbubble.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
179
src/equicordplugins/gifMaker/fonts.ts
Normal file
179
src/equicordplugins/gifMaker/fonts.ts
Normal 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;
|
||||
}
|
||||
504
src/equicordplugins/gifMaker/index.tsx
Normal file
504
src/equicordplugins/gifMaker/index.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
37
src/equicordplugins/gifMaker/native.ts
Normal file
37
src/equicordplugins/gifMaker/native.ts
Normal 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"
|
||||
};
|
||||
}
|
||||
189
src/equicordplugins/gifMaker/styles.css
Normal file
189
src/equicordplugins/gifMaker/styles.css
Normal 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;
|
||||
}
|
||||
56
src/equicordplugins/gifMaker/types.ts
Normal file
56
src/equicordplugins/gifMaker/types.ts
Normal 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[];
|
||||
}
|
||||
101
src/equicordplugins/gifMaker/utils/contextMenu.ts
Normal file
101
src/equicordplugins/gifMaker/utils/contextMenu.ts
Normal 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];
|
||||
}
|
||||
451
src/equicordplugins/gifMaker/utils/encoder.ts
Normal file
451
src/equicordplugins/gifMaker/utils/encoder.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
98
src/equicordplugins/gifMaker/utils/gifPicker.ts
Normal file
98
src/equicordplugins/gifMaker/utils/gifPicker.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue