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:
parent
a7eb93c875
commit
06f6caa1d8
6 changed files with 175 additions and 87 deletions
|
|
@ -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
21
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue