fix(gifMaker): fix discord cdn and gif/webp/mp4 madness (#1185)

* fix(gifMaker): fix discord cdn and gif/webp/mp4 madness

* fix(gifMaker): code review

* refr(gifMaker): apply murphy's fix for gif url handling, increased internal fps to 30 from 10
This commit is contained in:
Jakub Bednarski 2026-06-17 13:52:47 +02:00 • committed by GitHub
parent 1f1b0536b3
commit 31dc6c2def
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 63 additions and 37 deletions

View file

@ -23,7 +23,7 @@ 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";
import { collectCandidateUrls, ensureGifUrl, isLikelyVideoUrl, normalizeUrl, orderCandidateUrls, stripDiscordFormatParam } from "./utils/gifPicker";
const cl = classNameFactory("vc-gifmaker-");
const logger = new Logger("gifMaker");
@ -457,20 +457,21 @@ function openGifMakerFromItem(item) {
const candidates = collectCandidateUrls(item);
const adjustedCandidates = new Set<string>();
if (directUrl) adjustedCandidates.add(applyTenorMp4Fix(normalizeUrl(directUrl)));
if (directUrl) adjustedCandidates.add(normalizeUrl(directUrl));
for (const candidate of candidates) {
adjustedCandidates.add(applyTenorMp4Fix(candidate));
adjustedCandidates.add(candidate);
}
const preferredUrl = directUrl ? applyTenorMp4Fix(normalizeUrl(directUrl)) : null;
const preferredUrl = directUrl ? normalizeUrl(directUrl) : null;
const orderedUrls = orderCandidateUrls(preferredUrl, adjustedCandidates);
if (!orderedUrls.length) return;
const firstUrl = orderedUrls[0];
const isVideo = isLikelyVideoUrl(firstUrl);
const firstUrl = ensureGifUrl(orderedUrls[0]);
const cleanedUrl = stripDiscordFormatParam(firstUrl);
const isVideo = isLikelyVideoUrl(cleanedUrl);
openModal(modalProps => (
<GifMakerModal url={firstUrl} isVideo={isVideo} {...modalProps} />
<GifMakerModal url={cleanedUrl} isVideo={isVideo} {...modalProps} />
));
}

View file

@ -4,6 +4,7 @@
* 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 { decompressFrames, parseGIF } from "gifuct-js";
@ -12,9 +13,8 @@ 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 MAX_FRAMES = 200;
const INTERNAL_FPS = 30;
const PALETTE_COLORS = 255;
const MAX_GIF_SCAN_BYTES = 524288; // 512KB
@ -238,18 +238,19 @@ async function createGifFromVideo(url: string, options: GifMakerOptions): Promis
try {
const { duration } = video;
const frameCount = Math.min(
INTERNAL_FPS * INTERNAL_MAX_DURATION,
Math.floor(duration * INTERNAL_FPS),
MAX_FRAMES
);
const interval = duration / frameCount;
const delay = Math.round(interval * 1000);
const delays = new Array(frameCount).fill(delay);
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);
});
}, delays);
} finally {
cleanupBlobUrl(video);
}
@ -486,13 +487,13 @@ async function createGifFromAnimatedImage(url: string, options: GifMakerOptions)
const patchCanvas = document.createElement("canvas");
const frameCount = Math.min(frames.length, MAX_FRAMES);
const delays: number[] = [];
const totalFrames = frames.length;
const rendered: HTMLCanvasElement[] = [];
const delays: number[] = [];
for (let i = 0; i < frameCount; i++) {
for (let i = 0; i < totalFrames; i++) {
const frame = frames[i];
delays.push(frame.delay * 10);
delays.push(frame.delay);
if (i > 0) {
const prev = frames[i - 1];
@ -526,10 +527,14 @@ async function createGifFromAnimatedImage(url: string, options: GifMakerOptions)
if (!snapCtx) throw new Error("Failed to get canvas context for frame snapshot.");
snapCtx.drawImage(composite, 0, 0);
rendered.push(snap);
if (i % 20 === 19) {
await sleep(0);
}
}
return await encodeFrames(
options.width, options.height, options, frameCount,
options.width, options.height, options, totalFrames,
(encodeCtx, i) => {
encodeCtx.drawImage(rendered[i], 0, 0, options.width, options.height);
},

View file

@ -15,20 +15,6 @@ export function looksLikeUrl(value: string) {
return value.startsWith("http://") || value.startsWith("https://") || value.startsWith("//");
}
export function applyTenorMp4Fix(url: string) {
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;
@ -63,12 +49,7 @@ export function collectCandidateUrls(source: unknown, depth = 0, out = new Set<s
}
export function scoreUrl(url: string) {
let host = "";
try {
host = new URL(url).host;
} catch {
// invalid URL, host remains empty
}
const { host } = new URL(url);
let score = 0;
if (host.endsWith("discordapp.net") || host.endsWith("discordapp.com")) score += 100;
@ -94,3 +75,42 @@ export function orderCandidateUrls(preferred: string | null, candidates: Set<str
export function isLikelyVideoUrl(url: string) {
return /\.(webm|mp4|m4v)(\?|$)/i.test(url);
}
export function ensureGifUrl(url: string): string {
if (/\.gif(\?|$)/i.test(url)) return url;
const parsed = new URL(url);
const host = parsed.hostname;
if (host.includes("tenor.com")) {
const paths = parsed.pathname.split("/").filter(Boolean);
if (paths.length >= 2) {
const idSegment = paths[paths.length - 2];
paths[paths.length - 2] = idSegment.replace(/Po$/, "Ad");
paths[paths.length - 1] = "tenor.gif";
parsed.pathname = "/" + paths.join("/");
}
parsed.search = "";
return parsed.href;
}
if (host.includes("giphy.com")) {
const paths = parsed.pathname.split("/").filter(Boolean);
if (paths.length >= 2) {
const gifId = paths[paths.length - 2];
return `https://i.giphy.com/${gifId}.gif`;
}
return url;
}
return url;
}
export function stripDiscordFormatParam(url: string): string {
const parsed = new URL(url);
if (/discord(app)?\.(com|net|gg)/i.test(parsed.hostname)) {
parsed.searchParams.delete("format");
parsed.searchParams.delete("animated");
}
return parsed.href;
}