diff --git a/package.json b/package.json index 8e90f1dbf..408b59b1d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ca76f94..6cc3a3e8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/equicordplugins/gifMaker/index.tsx b/src/equicordplugins/gifMaker/index.tsx index 3c399b5a2..f7a36b4f9 100644 --- a/src/equicordplugins/gifMaker/index.tsx +++ b/src/equicordplugins/gifMaker/index.tsx @@ -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(); - 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; diff --git a/src/equicordplugins/gifMaker/styles.css b/src/equicordplugins/gifMaker/styles.css index dc67a697e..abfe67ae2 100644 --- a/src/equicordplugins/gifMaker/styles.css +++ b/src/equicordplugins/gifMaker/styles.css @@ -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; -} \ No newline at end of file diff --git a/src/equicordplugins/gifMaker/utils/encoder.ts b/src/equicordplugins/gifMaker/utils/encoder.ts index 65ff43c58..c7c67434e 100644 --- a/src/equicordplugins/gifMaker/utils/encoder.ts +++ b/src/equicordplugins/gifMaker/utils/encoder.ts @@ -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 { + 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 { 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, + delays?: number[], ): Promise { 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 { 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 { - 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 { + 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 { + 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 { if (!MediaNative) return null; try { @@ -407,45 +469,70 @@ async function getVideoSourceInfo(url: string): Promise } async function createGifFromAnimatedImage(url: string, options: GifMakerOptions): Promise { - 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((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 + ); } diff --git a/src/equicordplugins/gifMaker/utils/gifPicker.ts b/src/equicordplugins/gifMaker/utils/gifPicker.ts index b9c772677..e4bbcbe5d 100644 --- a/src/equicordplugins/gifMaker/utils/gifPicker.ts +++ b/src/equicordplugins/gifMaker/utils/gifPicker.ts @@ -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;