fix(gifMaker): add gifuct js to handle all gif types decoding (#1182)

* fix(gifMaker): add gifuct js to handle all gif types decoding

* fix(gifMaker): address code review

* fix(gifMaker): greptile haunts me in my sleep

* Update src/equicordplugins/gifMaker/utils/encoder.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Jakub Bednarski 2026-06-17 02:12:29 +02:00 • committed by GitHub
parent a7eb93c875
commit 06f6caa1d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 175 additions and 87 deletions

View file

@ -55,6 +55,7 @@
"@vap/shiki": "0.10.5",
"fflate": "^0.8.2",
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
"gifuct-js": "^2.1.2",
"idb": "8.0.3",
"jsqr": "1.4.0",
"monaco-editor": "^0.55.1",

21
pnpm-lock.yaml generated
View file

@ -8,9 +8,7 @@ overrides:
'@eslint/plugin-kit@<0.3.4': '>=0.3.4'
patchedDependencies:
'@types/less@3.0.8':
hash: 641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076
path: patches/@types__less@3.0.8.patch
'@types/less@3.0.8': 641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076
importers:
@ -52,6 +50,9 @@ importers:
gifenc:
specifier: github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3
version: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3
gifuct-js:
specifier: ^2.1.2
version: 2.1.2
idb:
specifier: 8.0.3
version: 8.0.3
@ -1770,9 +1771,12 @@ packages:
engines: {node: '>= 14'}
gifenc@https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3:
resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3}
resolution: {gitHosted: true, tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3}
version: 1.0.3
gifuct-js@2.1.2:
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -2046,6 +2050,9 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
js-binary-schema-parser@2.0.3:
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -4536,6 +4543,10 @@ snapshots:
gifenc@https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3: {}
gifuct-js@2.1.2:
dependencies:
js-binary-schema-parser: 2.0.3
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@ -4808,6 +4819,8 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
js-binary-schema-parser@2.0.3: {}
js-tokens@4.0.0: {}
js-yaml@4.1.0:

View file

@ -453,17 +453,16 @@ function GifMakerModal({ url, isVideo, sourceWidth, sourceHeight, ...props }: Re
}
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));
if (directUrl) adjustedCandidates.add(applyTenorMp4Fix(normalizeUrl(directUrl)));
for (const candidate of candidates) {
adjustedCandidates.add(applyTenorMp4Fix(candidate, isGif));
adjustedCandidates.add(applyTenorMp4Fix(candidate));
}
const preferredUrl = directUrl ? applyTenorMp4Fix(normalizeUrl(directUrl), isGif) : null;
const preferredUrl = directUrl ? applyTenorMp4Fix(normalizeUrl(directUrl)) : null;
const orderedUrls = orderCandidateUrls(preferredUrl, adjustedCandidates);
if (!orderedUrls.length) return;

View file

@ -177,13 +177,3 @@
flex-shrink: 0;
}
.vc-gifmaker-capture-wrapper {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
opacity: 0.01;
pointer-events: none;
overflow: hidden;
}

View file

@ -4,9 +4,9 @@
* 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";
import { CAPTIONS } from "../captions";
import { measureTextLines } from "../captions/caption";
@ -16,6 +16,7 @@ const MAX_FRAMES = 50;
const INTERNAL_FPS = 10;
const INTERNAL_MAX_DURATION = 3;
const PALETTE_COLORS = 255;
const MAX_GIF_SCAN_BYTES = 524288; // 512KB
const ALLOWED_MEDIA_HOSTS = new Set([
"cdn.discordapp.com",
@ -44,6 +45,17 @@ function isDiscordCdnUrl(url: string): boolean {
}
}
async function fetchFullGifBytes(url: string): Promise<Uint8Array> {
const resolved = resolveMediaUrl(url);
if (MediaNative) {
const { data } = await MediaNative.fetchMedia(resolved);
return new Uint8Array(data);
}
const res = await fetch(resolved);
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
async function getMediaBlobUrl(url: string): Promise<string> {
if (MediaNative) {
const { data, type } = await MediaNative.fetchMedia(url);
@ -155,6 +167,7 @@ async function encodeFrames(
options: GifMakerOptions,
frameCount: number,
drawFrame: (ctx: CanvasRenderingContext2D, i: number) => void | Promise<void>,
delays?: number[],
): Promise<Blob> {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
@ -164,7 +177,7 @@ async function encodeFrames(
canvas.width = width;
canvas.height = gifHeight;
const delay = Math.round(1000 / INTERNAL_FPS);
const defaultDelay = Math.round(1000 / INTERNAL_FPS);
const frameData: Uint8ClampedArray[] = [];
for (let i = 0; i < frameCount; i++) {
@ -199,7 +212,7 @@ async function encodeFrames(
for (let i = 0; i < frameCount; i++) {
const index = applyPalette(frameData[i], palette);
gif.writeFrame(index, width, gifHeight, {
delay,
delay: delays ? delays[i] : defaultDelay,
palette: i === 0 ? palette : undefined,
});
}
@ -271,50 +284,99 @@ export async function getSourceFrameInfo(url: string, isVideo: boolean): Promise
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);
if (hasExt(url, ".gif")) {
try {
return await createGifFromAnimatedImage(url, options);
} catch (err) {
if (!(err instanceof Error) || err.message !== "No animated frames found") {
throw err;
}
}
}
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);
export function parseGifBytes(bytes: Uint8Array): SourceFrameInfo | null {
if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) return null;
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);
const frameWidth = bytes[6] | (bytes[7] << 8);
const frameHeight = bytes[8] | (bytes[9] << 8);
let frameCount = 0;
let totalDelay = 0;
let delayCount = 0;
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++;
}
const scanLimit = Math.min(bytes.length, MAX_GIF_SCAN_BYTES);
for (let i = 0; i < scanLimit - 8; i++) {
if (bytes[i] === 0x2C) {
frameCount++;
if (frameCount > MAX_FRAMES) break;
}
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 };
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;
}
export async function getGifInfo(url: string): Promise<SourceFrameInfo | null> {
try {
const resolved = resolveMediaUrl(url);
let bytes: Uint8Array;
if (MediaNative) {
const { data } = await MediaNative.fetchMedia(resolved);
bytes = new Uint8Array(data);
} else {
bytes = await fetchGifBytes(resolved);
}
return null;
return parseGifBytes(bytes);
} catch {
return null;
}
}
async function fetchGifBytes(url: string): Promise<Uint8Array> {
const res = await fetch(url, {
headers: { Range: `bytes=0-${MAX_GIF_SCAN_BYTES}` }
});
if (res.ok) {
return new Uint8Array(await res.arrayBuffer());
}
const full = await fetch(url);
if (!full.ok) throw new Error(`fetch failed: ${full.status}`);
const reader = full.body?.getReader();
if (!reader) return new Uint8Array(await full.arrayBuffer()).slice(0, MAX_GIF_SCAN_BYTES);
const chunks: Uint8Array[] = [];
let total = 0;
while (total < MAX_GIF_SCAN_BYTES) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
total += value.length;
}
reader.cancel();
const combined = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return combined;
}
async function getWebpInfo(url: string): Promise<SourceFrameInfo | null> {
if (!MediaNative) return null;
try {
@ -407,45 +469,70 @@ async function getVideoSourceInfo(url: string): Promise<SourceFrameInfo | null>
}
async function createGifFromAnimatedImage(url: string, options: GifMakerOptions): Promise<Blob> {
const resolved = resolveMediaUrl(url);
const img = new Image();
img.crossOrigin = "anonymous";
const bytes = await fetchFullGifBytes(url);
const parsedGif = parseGIF(bytes.buffer as ArrayBuffer);
const frames = decompressFrames(parsedGif, true);
const wrapper = document.createElement("div");
wrapper.className = "vc-gifmaker-capture-wrapper";
wrapper.appendChild(img);
document.body.appendChild(wrapper);
if (frames.length <= 1) throw new Error("No animated frames found");
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;
}
});
const gifW = parsedGif.lsd.width;
const gifH = parsedGif.lsd.height;
try {
const frameCount = Math.min(INTERNAL_FPS * INTERNAL_MAX_DURATION, MAX_FRAMES);
const interval = 1000 / INTERNAL_FPS;
const composite = document.createElement("canvas");
composite.width = gifW;
composite.height = gifH;
const ctx = composite.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("Failed to get canvas context for GIF compositing.");
return await encodeFrames(options.width, options.height, options, frameCount, async (ctx, i) => {
if (i > 0) {
await sleep(interval);
const patchCanvas = document.createElement("canvas");
const frameCount = Math.min(frames.length, MAX_FRAMES);
const delays: number[] = [];
const rendered: HTMLCanvasElement[] = [];
for (let i = 0; i < frameCount; i++) {
const frame = frames[i];
delays.push(frame.delay * 10);
if (i > 0) {
const prev = frames[i - 1];
if (prev.disposalType === 2) {
ctx.clearRect(prev.dims.left, prev.dims.top, prev.dims.width, prev.dims.height);
} else if (prev.disposalType === 3 && i > 1) {
const prevCtx = rendered[i - 2].getContext("2d");
if (prevCtx) {
const prevState = prevCtx.getImageData(0, 0, gifW, gifH);
ctx.putImageData(prevState, 0, 0);
}
}
ctx.drawImage(img, 0, 0, options.width, options.height);
});
} finally {
wrapper.remove();
cleanupBlobUrl(img);
}
const patchData = new ImageData(
new Uint8ClampedArray(frame.patch),
frame.dims.width,
frame.dims.height
);
patchCanvas.width = frame.dims.width;
patchCanvas.height = frame.dims.height;
const patchCtx = patchCanvas.getContext("2d");
if (!patchCtx) throw new Error("Failed to get canvas context for patch rendering.");
patchCtx.putImageData(patchData, 0, 0);
ctx.drawImage(patchCanvas, frame.dims.left, frame.dims.top);
const snap = document.createElement("canvas");
snap.width = gifW;
snap.height = gifH;
const snapCtx = snap.getContext("2d");
if (!snapCtx) throw new Error("Failed to get canvas context for frame snapshot.");
snapCtx.drawImage(composite, 0, 0);
rendered.push(snap);
}
return await encodeFrames(
options.width, options.height, options, frameCount,
(encodeCtx, i) => {
encodeCtx.drawImage(rendered[i], 0, 0, options.width, options.height);
},
delays
);
}

View file

@ -15,9 +15,7 @@ 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;
export function applyTenorMp4Fix(url: string) {
try {
const { host } = new URL(url);
if (!host.endsWith("tenor.com")) return url;