diff --git a/src/api/GifPickerContextMenu.tsx b/src/api/GifPickerContextMenu.tsx new file mode 100644 index 000000000..52e5219e9 --- /dev/null +++ b/src/api/GifPickerContextMenu.tsx @@ -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(); + +/** + * 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, () => ); +} + +function GifPickerContextMenuRoot({ items }: { items: React.ReactNode[]; }) { + return ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label="GIF Options" + > + {items} + + ); +} diff --git a/src/api/PluginManager.ts b/src/api/PluginManager.ts index 9789b6793..2171b9a83 100644 --- a/src/api/PluginManager.ts +++ b/src/api/PluginManager.ts @@ -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; diff --git a/src/api/index.ts b/src/api/index.ts index 585cb3dbb..a5085a889 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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; diff --git a/src/equicordplugins/_api/extraContextMenusAPI.ts b/src/equicordplugins/_api/extraContextMenusAPI.ts new file mode 100644 index 000000000..b953c33c6 --- /dev/null +++ b/src/equicordplugins/_api/extraContextMenusAPI.ts @@ -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); + }, +}); diff --git a/src/equicordplugins/gifCollections/components/contextMenus.tsx b/src/equicordplugins/gifCollections/components/contextMenus.tsx index 7b44bea7f..e0fb70481 100644 --- a/src/equicordplugins/gifCollections/components/contextMenus.tsx +++ b/src/equicordplugins/gifCollections/components/contextMenus.tsx @@ -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 ( - FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} - aria-label={type === "collection" ? "Delete Collection" : "Remove"} - > + {type === "collection" && ( <> - + - + openMoveToCollectionModal(nameOrId)} /> - + )} - + ); } @@ -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, () => - - ); - } - if (item?.id?.startsWith(GIF_ITEM_PREFIX)) { - return ContextMenuApi.openContextMenu(e, () => - - ); - } - const { src, url, height, width } = item; - if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX)) { - return ContextMenuApi.openContextMenu(e, () => - - ); - } - 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 ( + + {settings.store.showCopyImageLink && ( + { + copyToClipboard(url); + showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); + }} + /> + )} + {AddToCollectionMenu(gif)} + + ); } diff --git a/src/equicordplugins/gifCollections/index.tsx b/src/equicordplugins/gifCollections/index.tsx index 92c3ac962..47df38842 100644 --- a/src/equicordplugins/gifCollections/index.tsx +++ b/src/equicordplugins/gifCollections/index.tsx @@ -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); + } }); diff --git a/src/equicordplugins/gifCollections/utils/refreshUrl.ts b/src/equicordplugins/gifCollections/utils/refreshUrl.ts index 76d25cdbf..9e33fdd90 100644 --- a/src/equicordplugins/gifCollections/utils/refreshUrl.ts +++ b/src/equicordplugins/gifCollections/utils/refreshUrl.ts @@ -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; diff --git a/src/equicordplugins/gifMaker/captions/caption.ts b/src/equicordplugins/gifMaker/captions/caption.ts new file mode 100644 index 000000000..41d1cfc09 --- /dev/null +++ b/src/equicordplugins/gifMaker/captions/caption.ts @@ -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); + } + }, +}; diff --git a/src/equicordplugins/gifMaker/captions/index.ts b/src/equicordplugins/gifMaker/captions/index.ts new file mode 100644 index 000000000..6d758be36 --- /dev/null +++ b/src/equicordplugins/gifMaker/captions/index.ts @@ -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, +]; diff --git a/src/equicordplugins/gifMaker/captions/none.ts b/src/equicordplugins/gifMaker/captions/none.ts new file mode 100644 index 000000000..50e001cae --- /dev/null +++ b/src/equicordplugins/gifMaker/captions/none.ts @@ -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: () => { }, +}; diff --git a/src/equicordplugins/gifMaker/captions/speechbubble.ts b/src/equicordplugins/gifMaker/captions/speechbubble.ts new file mode 100644 index 000000000..721108887 --- /dev/null +++ b/src/equicordplugins/gifMaker/captions/speechbubble.ts @@ -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); + }, +}; diff --git a/src/equicordplugins/gifMaker/fonts.ts b/src/equicordplugins/gifMaker/fonts.ts new file mode 100644 index 000000000..c7d235fdb --- /dev/null +++ b/src/equicordplugins/gifMaker/fonts.ts @@ -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(["Arial"]); +const loadingFontFamilies = new Map>(); +const fontObjectUrls = new Set(); + +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(["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 => 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 | null = null; + +export async function fetchAllGoogleFonts(): Promise { + 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 => 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; +} diff --git a/src/equicordplugins/gifMaker/index.tsx b/src/equicordplugins/gifMaker/index.tsx new file mode 100644 index 000000000..3c399b5a2 --- /dev/null +++ b/src/equicordplugins/gifMaker/index.tsx @@ -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( + openModal(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( + openModal(modalProps => )} + /> + ); +}; + +interface SelectOption { + key: string; + label: string; + value: string; +} + +function FontOptionLabel({ option }: { option: SelectOption; }) { + React.useEffect(() => { + void loadGoogleFont(option.value); + }, [option.value]); + + return ( + + {option.label} + + ); +} + +function SelectedFontLabel({ options }: { options: SelectOption[]; }) { + const option = options[0]; + return option ? : null; +} + +function FontSelector({ initialFont, onSelect }: { initialFont: string; onSelect: (font: GoogleFontMetadata) => void; }) { + const [fonts, setFonts] = React.useState([]); + const [selectedFont, setSelectedFont] = React.useState(initialFont !== "Arial" ? initialFont : null); + + React.useEffect(() => { + void fetchAllGoogleFonts().then(fetchedFonts => { + setFonts(fetchedFonts); + }); + }, []); + + const options = fonts.map(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
Loading fonts...
; + } + + return ( + patch({ width: Number(e.target.value) })} + onBlur={e => patch({ width: clamp(Number(e.target.value), 32, 1024, 32) })} + className={cl("input")} + aria-label="Width" + /> + +
+ + patch({ height: Number(e.target.value) })} + onBlur={e => patch({ height: clamp(Number(e.target.value), 32, 1024, 32) })} + className={cl("input")} + aria-label="Height" + /> +
+ + + + + ); +} + +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(); + 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 => ( + + )); +} + +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 ( + openGifMakerFromItem(instance?.props?.item)} + /> + ); + }, +}); diff --git a/src/equicordplugins/gifMaker/native.ts b/src/equicordplugins/gifMaker/native.ts new file mode 100644 index 000000000..b3336c903 --- /dev/null +++ b/src/equicordplugins/gifMaker/native.ts @@ -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" + }; +} diff --git a/src/equicordplugins/gifMaker/styles.css b/src/equicordplugins/gifMaker/styles.css new file mode 100644 index 000000000..dc67a697e --- /dev/null +++ b/src/equicordplugins/gifMaker/styles.css @@ -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; +} \ No newline at end of file diff --git a/src/equicordplugins/gifMaker/types.ts b/src/equicordplugins/gifMaker/types.ts new file mode 100644 index 000000000..bbfba676b --- /dev/null +++ b/src/equicordplugins/gifMaker/types.ts @@ -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[]; +} diff --git a/src/equicordplugins/gifMaker/utils/contextMenu.ts b/src/equicordplugins/gifMaker/utils/contextMenu.ts new file mode 100644 index 000000000..cd9c41395 --- /dev/null +++ b/src/equicordplugins/gifMaker/utils/contextMenu.ts @@ -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): { 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]; +} diff --git a/src/equicordplugins/gifMaker/utils/encoder.ts b/src/equicordplugins/gifMaker/utils/encoder.ts new file mode 100644 index 000000000..65ff43c58 --- /dev/null +++ b/src/equicordplugins/gifMaker/utils/encoder.ts @@ -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 | undefined; + +const blobUrlMap = new WeakMap(); + +function isDiscordCdnUrl(url: string): boolean { + try { + return ALLOWED_MEDIA_HOSTS.has(new URL(url).hostname); + } catch { + return false; + } +} + +async function getMediaBlobUrl(url: string): Promise { + 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\/[^/]+\/(?https?)\/(?.+)$/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 { + 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 { + 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 { + 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 { + 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, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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((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); + } +} diff --git a/src/equicordplugins/gifMaker/utils/gifPicker.ts b/src/equicordplugins/gifMaker/utils/gifPicker.ts new file mode 100644 index 000000000..b9c772677 --- /dev/null +++ b/src/equicordplugins/gifMaker/utils/gifPicker.ts @@ -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()) { + 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)) { + 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) { + 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); +} diff --git a/src/utils/types.ts b/src/utils/types.ts index f8a8dfa7b..34f5528f7 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -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