diff --git a/README.md b/README.md index 1eb995f..6df1671 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # AutoCompress (Vencord Plugin) -- AutoCompress is a Vencord plugin that automatically compresses videos and other applicable media upon attempted send that exceed a configurable size limit, reducing them to a specified target size +- AutoCompress is a Vencord plugin that automatically compresses videos, audio, images, and other applicable media upon attempted send that exceed a configurable size limit, reducing them to a specified target size ## Features -- customizable compression settings, target file size, compression thresholds, etc. +- customizable compression settings, target file size, compression thresholds, attachment type modes, and fallback behavior +- bounded concurrent compression jobs so multi-file uploads do not launch every ffmpeg encode at once +- progress overlay with ETA, upload cancellation, and a post-compression size preview +- optional custom image/video dimensions, plus quick preset limits +- optional prompt before compressing inserted media +- diagnostics button in settings for ffmpeg/GPU encoder troubleshooting ## How It Works - works via reencoding media with ffmpeg @@ -17,6 +22,15 @@ ## Notes - requires [ffmpeg](https://github.com/FFmpeg/FFmpeg) (and ffprobe), plugin should automatically resolve binaries - if not, set a path in the plugin settings - set a limit a bit below your ideal size +- target size and compression threshold can use KB, MB, or GB units +- use Compression Mode to limit compression to videos/audio, images, both, or neither +- enable "Prompt to compress after every media insertion" to choose between compression and uploading originals each time AutoCompress intercepts media +- failed files are skipped by default, but can optionally upload originals; compression results that are larger than the source can also optionally fall back to the original +- increasing Concurrent Jobs can improve GPU video engine usage on systems that can handle multiple simultaneous encodes - ensure you set a realistic time limit - lower resolution scaling can help encoding speed & artifacting -- ffmpeg will likely utilize the majority of your cpu when compressing as this plugin currently only does software encoding, support for hardware accel may happen eventually (but probably not) +- GPU encoders are preferred when available (`h264_nvenc`, `h264_amf`, `h264_qsv`, or `h264_videotoolbox`), with software `libx264` as the final fallback +- video-like media, including MOV and GIF files, is reencoded to MP4; audio-only media is reencoded to M4A +- large outputs are retried at lower bitrates to get closer to the configured target +- very large source videos may be retried at 1080p on GPU if the hardware encoder rejects the original resolution +- large JPEG, PNG, and WebP images are compressed in-browser and are only intercepted when they exceed the configured threshold diff --git a/autoCompress/index.ts b/autoCompress/index.ts deleted file mode 100644 index 24c94b6..0000000 --- a/autoCompress/index.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { showNotification } from "@api/Notifications"; -import { definePluginSettings } from "@api/Settings"; -import definePlugin, { OptionType, PluginNative } from "@utils/types"; -import { - DraftType, - SelectedChannelStore, - showToast, - Toasts, - UploadManager, -} from "@webpack/common"; - -type ProcessResult = - | { success: true; file: File; size: number; } - | { success: false; fileName: string; error: string; }; - -const Native = VencordNative.pluginHelpers.AutoCompress as PluginNative< - typeof import("./native") ->; -const FORMATS = new Set([ - "video/mp4", - "video/quicktime", - "video/x-msvideo", - "video/x-matroska", - "video/webm", - "audio/mpeg", - "audio/wav", - "audio/flac", -]); - -const settings = definePluginSettings({ - ffmpegTimeout: { - type: OptionType.NUMBER, - description: "Duration per file before compression aborted [seconds]", - default: 10 - }, - ffmpegPath: { - type: OptionType.STRING, - description: "Path to ffmpeg binary (empty will attempt to resolve automatically)" - }, - ffprobePath: { - type: OptionType.STRING, - description: "Path to ffprobe binary (empty will attempt to resolve automatically)" - }, - compressionTarget: { - type: OptionType.NUMBER, - description: "File size to target with compression [mb]", - default: 9, - }, - compressionThreshold: { - type: OptionType.NUMBER, - description: "Maximum file size before compression is used [mb]", - default: 10, - }, - compressionPreset: { - type: OptionType.SELECT, - description: "Encoding speed (slower results in better quality at the same size)", - options: [ - { label: "Fastest", value: "ultrafast" }, - { label: "Fast", value: "fast" }, - { label: "Medium (Balanced)", value: "medium", default: true }, - { label: "Slow", value: "slow" }, - { label: "Very Slow", value: "veryslow" }, - ], - }, - maxResolution: { - type: OptionType.SELECT, - description: - "Maximum resolution (downscaling MAY result in better quality with low bitrates)", - options: [ - { label: "Keep Original", value: "original", default: true }, - { label: "1080p", value: "1080" }, - { label: "720p", value: "720" }, - { label: "480p", value: "480" }, - ], - }, -}); - -function isValid(files: FileList | undefined): files is FileList { - return files !== undefined && files.length > 0; -} - -async function validateBinaries() { - // check that ffmpeg & ffprobe are found and reachable - const validated = await Native.testBinaries(settings.store.ffmpegPath, settings.store.ffprobePath); - if (!validated.success) { - showNotification({ - title: "AutoCompress", - body: `Failed validation with: ${validated}`, - color: "#f04747", - noPersist: false, - }); - return false; - } - return true; -} - -async function hookPaste(event: ClipboardEvent) { - const files = event.clipboardData?.files; - if (!isValid(files) || !(await validateBinaries())) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - - await handleFiles(files); -} - -async function hookDrop(event: DragEvent) { - const files = event.dataTransfer?.files; - if (!isValid(files) || !(await validateBinaries())) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - - await handleFiles(files); -} - -async function handleFiles(files: FileList) { - - const allFiles = Array.from(files); - const compressibleFiles: File[] = []; - const otherFiles: File[] = []; - - for (const file of allFiles) { - const sizeMB = file.size / (1024 * 1024); - if (FORMATS.has(file.type) && sizeMB > settings.store.compressionThreshold) { - compressibleFiles.push(file); - continue; - } - otherFiles.push(file); - } - const channelId = SelectedChannelStore.getChannelId(); - if (!channelId) { - return; - } - UploadManager.clearAll(channelId, DraftType.ChannelMessage); - - // handles situation in which no compressible files are included - if (compressibleFiles.length === 0) { - if (otherFiles.length > 0) { - UploadManager.addFiles({ - channelId, - draftType: DraftType.ChannelMessage, - files: otherFiles.map(file => ({ file, platform: 1 })), - showLargeMessageDialog: false, - }); - } - return; - } - showToast( - `Preparing to compress ${compressibleFiles.length}/${files.length} file(s)`, - Toasts.Type.MESSAGE, - ); - - const results = await Promise.all( - compressibleFiles.map(file => processFile(file)), - ); - const successful = results.filter(r => r.success); - const failed = results.filter(r => !r.success); - const toUpload = [...successful.map(r => r.file), ...otherFiles]; - // slightly scary looking ternary that formats the output message - const message = `Compressed ${successful.length}/${results.length} file(s)${failed.length > 0 - ? `\nFailed: ${failed.map(f => `${f.fileName} (${f.error})`).join(", ")}` - : "" - }`; - - if (toUpload.length > 0) { - UploadManager.addFiles({ - channelId, - draftType: DraftType.ChannelMessage, - files: toUpload.map(file => ({ file, platform: 1 })), - showLargeMessageDialog: false, - }); - } - - const color = - failed.length === 0 - ? "#43b581" - : successful.length === 0 - ? "#f04747" - : "#faa61a"; - - showNotification({ - title: "AutoCompress", - body: message, - color: color, - noPersist: false, - }); -} - -async function hookDrag(e: DragEvent) { - const types = e.dataTransfer?.types; - if (types?.includes("Files")) { - e.preventDefault(); - e.stopPropagation(); - } -} - -async function processFile(file: File): Promise { - try { - const buffer = await file.arrayBuffer(); - const res = await Native.handleFile( - new Uint8Array(buffer), - file.name, - settings.store.compressionTarget, - settings.store.compressionPreset, - settings.store.maxResolution, - settings.store.ffmpegTimeout * 1000 - ); - - if (!res.success) { - return { - success: false, - fileName: file.name, - error: res.error, - }; - } - - const resArray = res.data; - const compressedSizeMB = resArray.byteLength / (1024 * 1024); - const wrapped = new Uint8Array(resArray); - const compressedFile = new File([wrapped], file.name, { type: file.type }); - - return { - success: true, - file: compressedFile, - size: compressedSizeMB, - }; - } catch (err) { - return { - success: false, - fileName: file.name, - error: err instanceof Error ? err.message : String(err), - }; - } -} - -export default definePlugin({ - name: "AutoCompress", - description: "Automatically compress videos/audio to reach a target size", - authors: [{ name: "dyn", id: 262458273247002636n }], - settings, - - start() { - document.addEventListener("drop", hookDrop, { capture: true }); - document.addEventListener("dragover", hookDrag, { capture: true }); - document.addEventListener("paste", hookPaste, { capture: true }); - }, - - stop() { - document.removeEventListener("drop", hookDrop, { capture: true }); - document.removeEventListener("dragover", hookDrag, { capture: true }); - }, -}); diff --git a/autoCompress/native.ts b/autoCompress/native.ts deleted file mode 100644 index b7e8eb9..0000000 --- a/autoCompress/native.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { readFile, unlink, writeFile } from "node:fs/promises"; - -import { spawn } from "child_process"; -import { IpcMainInvokeEvent } from "electron"; -import os from "os"; -import path from "path"; - -import { pullBinary, resolveBinary } from "./resolve"; - -type CompressResult = - | { success: true; data: Uint8Array; } - | { success: false; error: string; }; - - -export async function testBinaries( - _: IpcMainInvokeEvent, - ffmpegPath: string | undefined, - ffprobePath: string | undefined -) { - try { - await resolveBinary(ffmpegPath, "ffmpeg"); - await resolveBinary(ffprobePath, "ffprobe"); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } -} - - -export async function handleFile( - _: IpcMainInvokeEvent, - fileData: Uint8Array, - fileName: string, - target: number, - preset: string, - resolution: string, - timeout: number -): Promise { - const fileExt = path.extname(fileName) || ".mp4"; - const temp = os.tmpdir(); - const inPath = path.join(temp, `ac_in_${Date.now()}${fileExt}`); - const outPath = path.join(temp, `ac_out_${Date.now()}${fileExt}`); - - try { - const fileBuf = Buffer.from(fileData); - await writeFile(inPath, fileBuf); - - const duration = await getVideoDuration(inPath); - - const tgBits = target * 8 * 1024 * 1024; - const audioBitrate = 128; - const videoBitrate = Math.floor(tgBits / duration / 1000); - - if (videoBitrate < 100) { - await cleanup(inPath); - return { - success: false, - error: `target bitrate too low (${videoBitrate}k)`, - }; - } - - await compressVideo(inPath, outPath, videoBitrate, audioBitrate, preset, resolution, timeout); - - const res = await readFile(outPath); - await cleanup(inPath, outPath); - return { success: true, data: res }; - } catch (err: any) { - await cleanup(inPath, outPath); - return { - success: false, - error: err?.message || err?.toString() || "unk error", - }; - } -} - -function getVideoDuration(inputPath: string): Promise { - return new Promise((resolve, reject) => { - const ffprobe = spawn(pullBinary("ffprobe"), [ - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - inputPath, - ]); - - let out = ""; - let errOut = ""; - ffprobe.stdout.on("data", data => { - out += data.toString(); - }); - - ffprobe.stderr.on("data", data => { - errOut += data.toString(); - }); - - ffprobe.on("close", code => { - if (code !== 0) { - reject(new Error(`ffprobe exited with ${code}, ${errOut.trim()}`)); - return; - } - - const duration = parseFloat(out.trim()); - if (isNaN(duration) || duration <= 0) { - reject(new Error("ffprobe invalid data")); - return; - } - - resolve(duration); - }); - - ffprobe.on("error", err => { - reject(new Error(`failed to spawn ffprobe: ${err.message}`)); - }); - }); -} - -function compressVideo( - inputPath: string, - outputPath: string, - vidBitrate: number, - audioBitrate: number, - preset: string, - maxResolution: string, - ffmpegTimeout: number -): Promise { - return new Promise((resolve, reject) => { - const args = [ - "-y", - "-i", - inputPath, - "-c:v", - "libx264", - "-b:v", - `${vidBitrate}k`, - "-maxrate", - `${vidBitrate}k`, - "-bufsize", - `${vidBitrate * 2}k`, - "-preset", - preset, - ]; - - if (maxResolution !== "original") { - const resolutionMap: Record = { - "1080": - "scale='min(iw,1920)':'min(ih,1080)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2", - "720": - "scale='min(iw,1280)':'min(ih,720)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2", - "480": - "scale='min(iw,854)':'min(ih,480)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2", - }; - args.push("-vf", resolutionMap[maxResolution]); - } - - args.push( - "-c:a", - "aac", - "-b:a", - `${audioBitrate}k`, - "-map_metadata", - "0", - "-movflags", - "+faststart", - outputPath, - ); - - const ffmpeg = spawn(pullBinary("ffmpeg"), args); - - let errOut = ""; - - ffmpeg.stderr.on("data", data => { - errOut += data.toString(); - }); - setTimeout(() => { - ffmpeg.kill(); - reject(new Error(`ffmpeg exceeded alloted time of ${ffmpegTimeout / 1000}`)); - return; - }, ffmpegTimeout); - ffmpeg.on("close", code => { - if (code === 0) { - resolve(); - return; - } - reject(new Error(`ffmpeg exited with code ${code}, ${errOut}`)); - }); - - ffmpeg.on("error", err => { - reject(new Error(`ffmpeg spawn error ${err}. stderr: ${errOut}`)); - return; - }); - }); -} - -async function cleanup(...paths: string[]): Promise { - await Promise.allSettled(paths.map(p => unlink(p))); -} - - diff --git a/autoCompress/resolve.ts b/autoCompress/resolve.ts deleted file mode 100644 index 854d054..0000000 --- a/autoCompress/resolve.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * This file exists purely to avoid reliance on ffmpeg/ffprobe being in the system path - * not a big fan of path based resolution - */ - -import { spawn } from "node:child_process"; -import { access } from "node:fs/promises"; -import path from "node:path"; - -const cache: Partial> = {}; -function candidatePaths(bin: "ffmpeg" | "ffprobe"): string[] { - switch (process.platform) { - case "win32": - return [ - `C:\\ffmpeg\\bin\\${bin}.exe`, - `C:\\Program Files\\ffmpeg\\bin\\${bin}.exe`, - `C:\\Program Files (x86)\\ffmpeg\\bin\\${bin}.exe`, - ]; - case "darwin": - return [ - `/opt/homebrew/bin/${bin}`, - `/usr/local/bin/${bin}`, - `/usr/bin/${bin}`, - ]; - default: - return [ - `/usr/bin/${bin}`, - `/usr/local/bin/${bin}`, - `/bin/${bin}`, - `/snap/bin/${bin}`, - `/app/bin/${bin}`, - ]; - } -} - -async function validateBinary(binPath: string, binName: string, timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - const p = spawn(binPath, ["-version"], { shell: false }); - - let out = ""; - let timeExceeded = false; - - const timeout = setTimeout(() => { - timeExceeded = true; - p.kill(); - reject(new Error(`${binPath} validation timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - p.stdout.on("data", d => (out += d)); - - p.on("close", code => { - clearTimeout(timeout); - if (timeExceeded) return; - - if (code !== 0 || !out.toLowerCase().includes(binName)) { - reject(new Error(`${binPath} is not a valid ${binName} binary`)); - } else { - resolve(); - } - }); - - p.on("error", err => { - clearTimeout(timeout); - if (!timeExceeded) reject(err); - }); - }); -} - -async function fileExists(filePath: string): Promise { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -export function pullBinary(bin: "ffmpeg" | "ffprobe") { - const binPath = cache[bin]; - if (!binPath) throw new Error("requested binary has not been resolved"); - return binPath; -} - -export async function resolveBinary( - userPath: string | undefined, - bin: "ffmpeg" | "ffprobe", -): Promise { - if (cache[bin]) return cache[bin]; - - const searchedPaths: string[] = []; - - if (userPath) { - if (!path.isAbsolute(userPath)) { - throw new Error(`${bin} path must be absolute`); - } - if (!(await fileExists(userPath))) { - throw new Error(`${bin} not found at ${userPath}`); - } - await validateBinary(userPath, bin); - cache[bin] = userPath; - return userPath; - } - - for (const p of candidatePaths(bin)) { - searchedPaths.push(p); - - if (await fileExists(p)) { - try { - await validateBinary(p, bin); - cache[bin] = p; - return p; - } catch (err) { - continue; - } - } - } - - throw new Error( - `${bin} not found, either needs installation or define a custom path` - ); -} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..36f4522 --- /dev/null +++ b/index.ts @@ -0,0 +1,1490 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { showNotification } from "@api/Notifications"; +import { definePluginSettings } from "@api/Settings"; +import definePlugin, { OptionType, PluginNative } from "@utils/types"; +import { + Alerts, + DraftType, + React, + Select, + SelectedChannelStore, + showToast, + Text, + TextInput, + Toasts, + UploadAttachmentStore, + UploadManager, + useState, +} from "@webpack/common"; + +type ProcessResult = + | { success: true; file: File; originalFileName: string; originalSizeMB: number; sizeMB: number; encoderUsed: string; output: "compressed" | "original"; note?: string; } + | { success: false; file: File; fileName: string; error: string; cancelled?: true; }; + +const Native = VencordNative.pluginHelpers.AutoCompress as PluginNative< + typeof import("./native") +>; + +const MEDIA_FORMATS = new Set([ + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/x-matroska", + "video/webm", + "image/gif", + "audio/mpeg", + "audio/wav", + "audio/flac", +]); + +const MEDIA_EXTENSIONS = new Set([ + ".mp4", + ".mov", + ".qt", + ".avi", + ".mkv", + ".webm", + ".gif", + ".mp3", + ".wav", + ".flac", +]); + +const IMAGE_FORMATS = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + +const IMAGE_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".webp", +]); + +const AUDIO_EXTENSIONS = new Set([ + ".mp3", + ".wav", + ".flac", +]); + +const CHUNK_SIZE = 4 * 1024 * 1024; +const DUPLICATE_BATCH_MS = 1500; + +type CompressionKind = "media" | "image"; +type CompressionMode = "auto" | "media" | "image" | "off"; +type FailedFileBehavior = "upload-original" | "skip"; +type SizeUnit = "KB" | "MB" | "GB"; +type SizeSetting = number | { value: number; unit: SizeUnit; }; + +const SIZE_UNIT_BYTES: Record = { + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, +}; + +const SIZE_UNIT_OPTIONS = [ + { label: "KB", value: "KB" }, + { label: "MB", value: "MB" }, + { label: "GB", value: "GB" }, +]; + +const COMPRESSION_MODE_OPTIONS = [ + { label: "Auto (Videos, Audio, Images)", value: "auto", default: true }, + { label: "Videos + Audio Only", value: "media" }, + { label: "Images Only", value: "image" }, + { label: "Off", value: "off" }, +]; + +const FAILED_FILE_BEHAVIOR_OPTIONS = [ + { label: "Skip failed file", value: "skip", default: true }, + { label: "Upload original", value: "upload-original" }, +]; + +function debugLog(message: string, ...args: unknown[]) { + if (settings.store.debugLogging) { + console.log(`[AutoCompress] ${message}`, ...args); + } +} + +function normalizeSizeSetting(raw: unknown, fallbackValue: number, fallbackUnit: SizeUnit): { value: number; unit: SizeUnit; } { + if (typeof raw === "object" && raw !== null) { + const maybeSetting = raw as { value?: unknown; unit?: unknown; }; + const value = typeof maybeSetting.value === "number" && Number.isFinite(maybeSetting.value) + ? maybeSetting.value + : fallbackValue; + return { value, unit: getSizeUnit(maybeSetting.unit) }; + } + + if (typeof raw === "number" && Number.isFinite(raw)) { + return { value: raw, unit: fallbackUnit }; + } + + return { value: fallbackValue, unit: fallbackUnit }; +} + +function formatSettingValue(value: number): string { + if (!Number.isFinite(value)) return "0"; + + return String(Math.round(value * 1000) / 1000); +} + +function convertSizeValue(value: number, fromUnit: SizeUnit, toUnit: SizeUnit): number { + return (value * SIZE_UNIT_BYTES[fromUnit]) / SIZE_UNIT_BYTES[toUnit]; +} + +function SizeSettingControl({ + label, + description, + defaultValue, + settingKey, + setValue, +}: { + label: string; + description: string; + defaultValue: number; + settingKey: "compressionTarget" | "compressionThreshold"; + setValue(newValue: SizeSetting): void; +}) { + const legacyUnit = getSizeUnit((settings.store as Record)[`${settingKey}Unit`]); + const initial = normalizeSizeSetting(settings.store[settingKey], defaultValue, legacyUnit); + const [value, setLocalValue] = useState(formatSettingValue(initial.value)); + const [unit, setLocalUnit] = useState(initial.unit); + + function commit(nextValue: string, nextUnit: SizeUnit) { + const parsed = Number(nextValue); + if (!Number.isFinite(parsed)) return; + + setValue({ value: Math.max(0, parsed), unit: nextUnit }); + } + + function handleValueChange(nextValue: string) { + setLocalValue(nextValue); + commit(nextValue, unit); + } + + function handleUnitChange(nextUnit: SizeUnit) { + const parsed = Number(value); + const converted = Number.isFinite(parsed) + ? formatSettingValue(convertSizeValue(parsed, unit, nextUnit)) + : value; + + setLocalUnit(nextUnit); + setLocalValue(converted); + commit(converted, nextUnit); + } + + return React.createElement( + "div", + { style: { marginBottom: "20px" } }, + React.createElement( + "div", + { style: { marginBottom: "8px" } }, + React.createElement(Text, { variant: "text-md/medium" }, label), + React.createElement(Text, { color: "text-muted", variant: "text-sm/normal" }, description), + ), + React.createElement( + "div", + { style: { display: "flex", gap: "8px", alignItems: "center" } }, + React.createElement(TextInput, { + type: "number", + value, + min: 0, + step: "any", + onChange: handleValueChange, + style: { flex: "1 1 auto" }, + }), + React.createElement( + "div", + { style: { flex: "0 0 96px" } }, + React.createElement(Select, { + options: SIZE_UNIT_OPTIONS, + maxVisibleItems: 3, + closeOnSelect: true, + select: handleUnitChange, + isSelected: (selected: SizeUnit) => selected === unit, + serialize: String, + }), + ), + ), + ); +} + +function DiagnosticsControl() { + const [isRunning, setIsRunning] = useState(false); + + async function runDiagnostics() { + if (isRunning) return; + + setIsRunning(true); + try { + const ffmpegPath = settings.store.ffmpegPath?.trim() || undefined; + const ffprobePath = settings.store.ffprobePath?.trim() || undefined; + const validation = await Native.testBinaries(ffmpegPath, ffprobePath); + const diagnostics = await Native.getDiagnostics(); + const payload = { + validation, + settings: { + compressionMode: settings.store.compressionMode, + promptAfterInsertion: settings.store.promptAfterInsertion, + compressionTarget: settings.store.compressionTarget, + compressionThreshold: settings.store.compressionThreshold, + compressionPreset: settings.store.compressionPreset, + maxResolution: settings.store.maxResolution, + maxWidth: settings.store.maxWidth, + maxHeight: settings.store.maxHeight, + concurrentJobs: settings.store.concurrentJobs, + useHardwareDecode: settings.store.useHardwareDecode, + debugLogging: settings.store.debugLogging, + }, + diagnostics, + }; + const text = JSON.stringify(payload, null, 2); + + console.log("[AutoCompress] Diagnostics", payload); + await navigator.clipboard.writeText(text).catch(() => undefined); + + showNotification({ + title: "AutoCompress", + body: validation.success + ? `Diagnostics copied. Encoder: ${validation.encoder ?? "unknown"}` + : `Diagnostics copied. Validation failed: ${validation.error}`, + color: validation.success ? "#43b581" : "#faa61a", + noPersist: false, + }); + } catch (err) { + showNotification({ + title: "AutoCompress", + body: `Diagnostics failed: ${err instanceof Error ? err.message : String(err)}`, + color: "#f04747", + noPersist: false, + }); + } finally { + setIsRunning(false); + } + } + + return React.createElement( + "div", + { style: { marginBottom: "20px" } }, + React.createElement( + "div", + { style: { marginBottom: "8px" } }, + React.createElement(Text, { variant: "text-md/medium" }, "Diagnostics"), + React.createElement(Text, { color: "text-muted", variant: "text-sm/normal" }, "Test ffmpeg/GPU support and copy a report to the clipboard"), + ), + React.createElement( + "button", + { + disabled: isRunning, + onClick: runDiagnostics, + style: { + background: "var(--brand-experiment, #5865f2)", + border: "none", + borderRadius: "4px", + color: "#fff", + cursor: isRunning ? "default" : "pointer", + fontSize: "14px", + fontWeight: 600, + padding: "8px 12px", + opacity: isRunning ? 0.7 : 1, + }, + }, + isRunning ? "Running..." : "Run Diagnostics", + ), + ); +} + +const settings = definePluginSettings({ + compressionMode: { + type: OptionType.SELECT, + description: "Which attachment types AutoCompress should intercept", + options: COMPRESSION_MODE_OPTIONS, + }, + promptAfterInsertion: { + type: OptionType.BOOLEAN, + description: "Prompt to compress after every media insertion", + default: false, + }, + ffmpegTimeout: { + type: OptionType.NUMBER, + description: "Duration per file before compression is aborted [seconds]", + default: 120, + }, + ffmpegPath: { + type: OptionType.STRING, + description: "Path to ffmpeg binary (empty will attempt to resolve automatically)", + default: "", + }, + ffprobePath: { + type: OptionType.STRING, + description: "Path to ffprobe binary (empty will attempt to resolve automatically)", + default: "", + }, + compressionTarget: { + type: OptionType.COMPONENT, + default: { value: 9, unit: "MB" }, + component: props => React.createElement(SizeSettingControl, { + label: "Compression Target", + description: "File size to target with compression", + defaultValue: 9, + settingKey: "compressionTarget", + setValue: props.setValue, + }), + }, + compressionThreshold: { + type: OptionType.COMPONENT, + default: { value: 10, unit: "MB" }, + component: props => React.createElement(SizeSettingControl, { + label: "Compression Threshold", + description: "Maximum file size before compression is used", + defaultValue: 10, + settingKey: "compressionThreshold", + setValue: props.setValue, + }), + }, + useOriginalIfWorse: { + type: OptionType.BOOLEAN, + description: "Upload the original file if compression makes it larger", + default: false, + }, + uploadIfSmaller: { + type: OptionType.BOOLEAN, + description: "Upload the compressed file even if it exceeds the target, as long as it is smaller than the original", + default: false, + }, + failedFileBehavior: { + type: OptionType.SELECT, + description: "What to do when an individual file fails compression", + options: FAILED_FILE_BEHAVIOR_OPTIONS, + }, + compressionPreset: { + type: OptionType.SELECT, + description: "Encoding speed (slower results in better quality at the same size)", + options: [ + { label: "Fastest", value: "ultrafast" }, + { label: "Fast", value: "fast" }, + { label: "Medium (Balanced)", value: "medium", default: true }, + { label: "Slow", value: "slow" }, + { label: "Very Slow", value: "veryslow" }, + ], + }, + maxResolution: { + type: OptionType.SELECT, + description: "Maximum resolution (downscaling MAY result in better quality with low bitrates)", + options: [ + { label: "Keep Original", value: "original", default: true }, + { label: "1080p", value: "1080" }, + { label: "720p", value: "720" }, + { label: "480p", value: "480" }, + ], + }, + maxWidth: { + type: OptionType.NUMBER, + description: "Optional custom maximum width in pixels (0 uses the preset above)", + default: 0, + }, + maxHeight: { + type: OptionType.NUMBER, + description: "Optional custom maximum height in pixels (0 uses the preset above)", + default: 0, + }, + concurrentJobs: { + type: OptionType.NUMBER, + description: "Maximum files to compress at once (higher can use more GPU, but may make Discord less responsive)", + default: 2, + }, + useHardwareDecode: { + type: OptionType.BOOLEAN, + description: "Use GPU decoding when ffmpeg can do so safely", + default: true, + }, + debugLogging: { + type: OptionType.BOOLEAN, + description: "Log AutoCompress pipeline details to the console", + default: false, + }, + diagnostics: { + type: OptionType.COMPONENT, + component: DiagnosticsControl, + }, +}); + +const OVERLAY_ID = "autocompress-progress-overlay"; + +interface ProgressEstimate { + startedAt: number; + lastPercent: number; +} + +const progressEstimates = new Map(); +const uploadWatchers = new Map>(); + +function getOrCreateOverlay(): HTMLElement { + let el = document.getElementById(OVERLAY_ID); + if (!el) { + el = document.createElement("div"); + el.id = OVERLAY_ID; + Object.assign(el.style, { + position: "fixed", + top: "16px", + left: "50%", + transform: "translateX(-50%)", + zIndex: "9999", + display: "flex", + flexDirection: "column", + gap: "6px", + alignItems: "center", + pointerEvents: "none", + maxWidth: "calc(100vw - 32px)", + }); + document.body.appendChild(el); + } + return el; +} + +function createProgressCard(jobId: string, fileName: string, onCancel: () => void, cancelTitle = "Cancel compression"): HTMLElement { + const card = document.createElement("div"); + card.dataset.jobId = jobId; + Object.assign(card.style, { + background: "var(--background-floating, #18191c)", + border: "1px solid var(--background-modifier-accent, #4f545c)", + borderRadius: "8px", + padding: "10px 12px", + width: "min(420px, calc(100vw - 32px))", + pointerEvents: "all", + boxShadow: "0 4px 16px rgba(0,0,0,0.4)", + }); + + const header = document.createElement("div"); + Object.assign(header.style, { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "6px", + }); + + const label = document.createElement("span"); + label.style.cssText = "font-size:13px;font-weight:600;color:var(--header-primary,#fff);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;"; + label.title = fileName; + label.textContent = fileName; + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "✕"; + Object.assign(cancelBtn.style, { + background: "none", + border: "none", + color: "var(--interactive-normal, #b9bbbe)", + cursor: "pointer", + fontSize: "14px", + padding: "0 0 0 8px", + lineHeight: "1", + }); + cancelBtn.title = cancelTitle; + cancelBtn.onclick = () => { + onCancel(); + cancelBtn.disabled = true; + cancelBtn.textContent = "..."; + cancelBtn.style.cursor = "default"; + }; + + header.appendChild(label); + header.appendChild(cancelBtn); + + const track = document.createElement("div"); + Object.assign(track.style, { + background: "var(--background-modifier-accent, #4f545c)", + borderRadius: "3px", + height: "6px", + overflow: "hidden", + }); + + const fill = document.createElement("div"); + fill.dataset.fill = "1"; + Object.assign(fill.style, { + height: "100%", + width: "0%", + background: "var(--brand-experiment, #5865f2)", + borderRadius: "3px", + transition: "width 0.3s ease", + }); + + const pctLabel = document.createElement("div"); + pctLabel.dataset.pct = "1"; + pctLabel.style.cssText = "font-size:11px;color:var(--text-muted,#72767d);margin-top:4px;"; + pctLabel.textContent = "0%"; + + track.appendChild(fill); + card.appendChild(header); + card.appendChild(track); + card.appendChild(pctLabel); + + getOrCreateOverlay().appendChild(card); + return card; +} + +function getSizeUnit(value: unknown): SizeUnit { + return value === "KB" || value === "MB" || value === "GB" ? value : "MB"; +} + +function getSizeBytes(value: number, unit: SizeUnit): number { + return Math.max(1, value * SIZE_UNIT_BYTES[unit]); +} + +function getStoredSizeBytes(settingKey: "compressionTarget" | "compressionThreshold", defaultValue: number): number { + const legacyUnit = getSizeUnit((settings.store as Record)[`${settingKey}Unit`]); + const setting = normalizeSizeSetting(settings.store[settingKey], defaultValue, legacyUnit); + return getSizeBytes(setting.value, setting.unit); +} + +function getCompressionTargetBytes(): number { + return getStoredSizeBytes("compressionTarget", 9); +} + +function getCompressionTargetMB(): number { + return getCompressionTargetBytes() / SIZE_UNIT_BYTES.MB; +} + +function getCompressionThresholdBytes(): number { + return getStoredSizeBytes("compressionThreshold", 10); +} + +function getCompressionMode(): CompressionMode { + const mode = settings.store.compressionMode; + return mode === "media" || mode === "image" || mode === "off" ? mode : "auto"; +} + +function getFailedFileBehavior(): FailedFileBehavior { + return settings.store.failedFileBehavior === "upload-original" ? "upload-original" : "skip"; +} + +function getConcurrentJobs(): number { + const value = Number(settings.store.concurrentJobs); + if (!Number.isFinite(value)) return 2; + + return Math.max(1, Math.min(8, Math.floor(value))); +} + +function getMaxDimension(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + + return Math.floor(parsed); +} + +function getCustomMaxDimensions(): { width: number; height: number; } { + return { + width: getMaxDimension(settings.store.maxWidth), + height: getMaxDimension(settings.store.maxHeight), + }; +} + +function formatSize(sizeMB: number): string { + const bytes = sizeMB * SIZE_UNIT_BYTES.MB; + + if (bytes < SIZE_UNIT_BYTES.MB) return `${(bytes / SIZE_UNIT_BYTES.KB).toFixed(1)} KB`; + if (bytes >= SIZE_UNIT_BYTES.GB) return `${(bytes / SIZE_UNIT_BYTES.GB).toFixed(2)} GB`; + + return sizeMB >= 100 + ? `${Math.round(sizeMB)} MB` + : `${sizeMB.toFixed(1)} MB`; +} + +function formatBytes(bytes: number): string { + return formatSize(bytes / SIZE_UNIT_BYTES.MB); +} + +function formatPercentChange(originalSizeMB: number, sizeMB: number): string { + if (originalSizeMB <= 0) return "0%"; + + const change = ((sizeMB - originalSizeMB) / originalSizeMB) * 100; + return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`; +} + +function formatResultLine(result: Extract): string { + if (result.output === "original") { + return `${result.originalFileName}: kept original (${formatSize(result.originalSizeMB)}${result.note ? `, ${result.note}` : ""})`; + } + + return `${result.originalFileName}: ${formatSize(result.originalSizeMB)} -> ${formatSize(result.sizeMB)} (${formatPercentChange(result.originalSizeMB, result.sizeMB)}${result.note ? `, ${result.note}` : ""})`; +} + +function makeOriginalFallbackResult(file: File, note: string): Extract | null { + if (!settings.store.useOriginalIfWorse || file.size > getCompressionTargetBytes()) return null; + + return { + success: true, + file, + originalFileName: file.name, + originalSizeMB: file.size / SIZE_UNIT_BYTES.MB, + sizeMB: file.size / SIZE_UNIT_BYTES.MB, + encoderUsed: "original", + output: "original", + note, + }; +} + +function formatEta(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 1) return "<1s"; + + const rounded = Math.ceil(seconds); + const minutes = Math.floor(rounded / 60); + const remainingSeconds = rounded % 60; + + if (minutes === 0) return `${remainingSeconds}s`; + if (minutes < 60) return `${minutes}m ${remainingSeconds.toString().padStart(2, "0")}s`; + + const hours = Math.floor(minutes / 60); + return `${hours}h ${(minutes % 60).toString().padStart(2, "0")}m`; +} + +function formatProgressStatus(jobId: string, percent: number): string { + const now = Date.now(); + const clamped = Math.max(0, Math.min(100, percent)); + const current = progressEstimates.get(jobId); + + if (!current || clamped <= 0 || clamped < current.lastPercent) { + progressEstimates.set(jobId, { startedAt: now, lastPercent: clamped }); + return `${clamped}%`; + } + + current.lastPercent = clamped; + + if (clamped >= 100) return "100%"; + + const elapsedSeconds = (now - current.startedAt) / 1000; + const etaSeconds = (elapsedSeconds / clamped) * (100 - clamped); + return `${clamped}% - ETA ${formatEta(etaSeconds)}`; +} + +function updateProgressCard(jobId: string, percent: number, status?: string) { + const card = document.querySelector(`[data-job-id="${jobId}"]`) as HTMLElement | null; + if (!card) return; + const fill = card.querySelector("[data-fill]") as HTMLElement | null; + const pct = card.querySelector("[data-pct]") as HTMLElement | null; + if (fill) fill.style.width = `${Math.max(0, Math.min(100, percent))}%`; + if (pct) pct.textContent = status ?? `${Math.max(0, Math.min(100, percent))}%`; +} + +function removeProgressCard(jobId: string) { + const card = document.querySelector(`[data-job-id="${jobId}"]`); + card?.remove(); + progressEstimates.delete(jobId); + const watcher = uploadWatchers.get(jobId); + if (watcher) { + clearInterval(watcher); + uploadWatchers.delete(jobId); + } + const overlay = document.getElementById(OVERLAY_ID); + if (overlay && overlay.childElementCount === 0) overlay.remove(); +} + +function createPostCompressionPreview(lines: string[], color: string) { + if (lines.length === 0) return; + + const jobId = `preview-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const card = document.createElement("div"); + card.dataset.jobId = jobId; + Object.assign(card.style, { + background: "var(--background-floating, #18191c)", + border: `1px solid ${color}`, + borderRadius: "8px", + padding: "10px 12px", + width: "min(520px, calc(100vw - 32px))", + pointerEvents: "none", + boxShadow: "0 4px 16px rgba(0,0,0,0.4)", + }); + + const title = document.createElement("div"); + title.style.cssText = "font-size:13px;font-weight:700;color:var(--header-primary,#fff);margin-bottom:4px;"; + title.textContent = "AutoCompress"; + + const body = document.createElement("div"); + body.style.cssText = "font-size:12px;line-height:1.35;color:var(--text-normal,#dcddde);white-space:pre-wrap;"; + body.textContent = lines.slice(0, 6).join("\n") + (lines.length > 6 ? `\n+${lines.length - 6} more` : ""); + + card.appendChild(title); + card.appendChild(body); + getOrCreateOverlay().appendChild(card); + setTimeout(() => removeProgressCard(jobId), 8_000); +} + +function isValid(files: FileList | undefined): files is FileList { + return files !== undefined && files.length > 0; +} + +function getFileExtension(fileName: string): string { + const extension = fileName.match(/\.[^/.]+$/)?.[0]; + + return extension?.toLowerCase() ?? ""; +} + +function getCompressionKind(file: File): CompressionKind | null { + const mimeType = file.type.toLowerCase(); + const extension = getFileExtension(file.name); + + if (MEDIA_FORMATS.has(mimeType) || MEDIA_EXTENSIONS.has(extension)) return "media"; + if (IMAGE_FORMATS.has(mimeType) || IMAGE_EXTENSIONS.has(extension)) return "image"; + return null; +} + +function canCompressFile(file: File): boolean { + const kind = getCompressionKind(file); + const mode = getCompressionMode(); + + if (kind === null || mode === "off") return false; + if (mode !== "auto" && kind !== mode) return false; + + return true; +} + +function shouldCompressFile(file: File, ignoreThreshold = false): boolean { + if (!canCompressFile(file)) return false; + + return ignoreThreshold || file.size > getCompressionThresholdBytes(); +} + +function shouldInterceptInsertion(file: File): boolean { + return settings.store.promptAfterInsertion + ? canCompressFile(file) + : shouldCompressFile(file); +} + +function getBatchKey(files: File[]): string { + return files + .map(file => `${file.name}:${file.size}:${file.lastModified}:${file.type}`) + .join("|"); +} + +function shouldSkipDuplicateBatch(files: File[]): boolean { + const now = Date.now(); + const batchKey = getBatchKey(files); + + if (batchKey === lastHandledBatchKey && now - lastHandledBatchAt < DUPLICATE_BATCH_MS) { + return true; + } + + lastHandledBatchKey = batchKey; + lastHandledBatchAt = now; + return false; +} + +function hasFileDrag(event: DragEvent): boolean { + return Array.from(event.dataTransfer?.types ?? []).includes("Files"); +} + +function makeCancelledError(): Error & { cancelled: true; } { + const err = new Error("cancelled") as Error & { cancelled: true; }; + err.cancelled = true; + return err; +} + +function getFileKey(file: File): string { + return `${file.name}:${file.size}:${file.type}`; +} + +function getUploadFile(upload: unknown): File | null { + return (upload as { item?: { file?: File; }; })?.item?.file ?? null; +} + +function addFilesToUploadManager(channelId: string, files: File[]) { + if (files.length === 0) return; + + debugLog("adding files to Discord upload queue", { + count: files.length, + names: files.map(f => f.name), + target: getCompressionTargetBytes(), + }); + UploadManager.addFiles({ + channelId, + draftType: DraftType.ChannelMessage, + files: files.map(file => ({ file, platform: 1 })), + showLargeMessageDialog: false, + }); +} + +function createUploadCancelCard(channelId: string, files: File[]) { + const jobId = `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const fileKeys = new Set(files.map(getFileKey)); + let sawUploadInDraft = false; + + createProgressCard(jobId, `${files.length} pending upload${files.length === 1 ? "" : "s"}`, () => { + UploadManager.clearAll(channelId, DraftType.ChannelMessage); + showToast("Upload cancelled", Toasts.Type.MESSAGE); + removeProgressCard(jobId); + }, "Cancel upload"); + updateProgressCard(jobId, 100, "Ready to upload"); + + const watcher = setInterval(() => { + const uploads = UploadAttachmentStore + .getUploads(channelId, DraftType.ChannelMessage) + .map(getUploadFile) + .filter((file): file is File => file !== null); + const matchingCount = uploads.filter(file => fileKeys.has(getFileKey(file))).length; + + sawUploadInDraft ||= matchingCount > 0; + + if (sawUploadInDraft && matchingCount === 0) { + removeProgressCard(jobId); + } + }, 500); + uploadWatchers.set(jobId, watcher); + + setTimeout(() => { + if (!sawUploadInDraft) removeProgressCard(jobId); + }, 10_000); +} + +let validationCache: { ffmpegPath: string; ffprobePath: string; encoder: string; } | null = null; +let lastHandledBatchKey = ""; +let lastHandledBatchAt = 0; + +async function validateBinaries(): Promise { + const ffmpegPath = settings.store.ffmpegPath?.trim() ?? ""; + const ffprobePath = settings.store.ffprobePath?.trim() ?? ""; + + if ( + validationCache + && validationCache.ffmpegPath === ffmpegPath + && validationCache.ffprobePath === ffprobePath + ) { + return true; + } + + const validated = await Native.testBinaries(ffmpegPath || undefined, ffprobePath || undefined); + if (!validated.success) { + showNotification({ + title: "AutoCompress", + body: `Failed validation: ${validated.error}`, + color: "#f04747", + noPersist: false, + }); + return false; + } + + const encoder = validated.encoder ?? "unknown encoder"; + validationCache = { ffmpegPath, ffprobePath, encoder }; + showToast(`AutoCompress ready - using ${encoder}`, Toasts.Type.SUCCESS); + return true; +} + +function promptToCompressFiles(files: File[]): Promise { + const compressibleFiles = files.filter(canCompressFile); + const [firstFile] = compressibleFiles; + const totalSize = compressibleFiles.reduce((sum, file) => sum + file.size, 0); + const fileSummary = compressibleFiles.length === 1 + ? `${firstFile.name} (${formatBytes(totalSize)})` + : `${compressibleFiles.length} files (${formatBytes(totalSize)})`; + + return new Promise(resolve => { + let settled = false; + const settle = (value: boolean) => { + if (settled) return; + + settled = true; + resolve(value); + }; + + Alerts.show({ + title: "Compress media?", + body: React.createElement( + "div", + null, + React.createElement(Text, { variant: "text-md/normal" }, `AutoCompress can compress ${fileSummary} before upload.`), + React.createElement(Text, { color: "text-muted", variant: "text-sm/normal" }, `Target: ${formatBytes(getCompressionTargetBytes())}`), + ), + confirmText: "Compress", + cancelText: "Upload Original", + onConfirm: () => settle(true), + onCloseCallback: () => setImmediate(() => settle(false)), + }); + }); +} + +async function uploadOriginalFiles(files: File[]) { + const channelId = SelectedChannelStore.getChannelId(); + if (!channelId) return; + + await addFilesToUploadManager(channelId, files); + createUploadCancelCard(channelId, files); +} + +async function handleInsertedFiles(allFiles: File[], source: "paste" | "drop") { + if (shouldSkipDuplicateBatch(allFiles)) return; + + const promptEnabled = settings.store.promptAfterInsertion; + const forceCompression = promptEnabled && await promptToCompressFiles(allFiles); + + debugLog(`${source} intercepted`, allFiles.map(file => ({ name: file.name, size: file.size, type: file.type, shouldCompress: shouldCompressFile(file, forceCompression) }))); + + if (promptEnabled) { + debugLog("compression prompt result", { source, shouldCompress: forceCompression }); + + if (!forceCompression) { + await uploadOriginalFiles(allFiles); + return; + } + } + + if (allFiles.some(file => shouldCompressFile(file, forceCompression) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; + + await handleFiles(allFiles, forceCompression); +} + +async function hookPaste(event: ClipboardEvent) { + const files = event.clipboardData?.files; + if (!isValid(files)) return; + + const allFiles = Array.from(files); + if (!allFiles.some(shouldInterceptInsertion)) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + await handleInsertedFiles(allFiles, "paste"); +} + +function hookDragOver(event: DragEvent) { + if (!hasFileDrag(event) || getCompressionMode() === "off") return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; +} + +async function hookDrop(event: DragEvent) { + const files = event.dataTransfer?.files; + if (!isValid(files)) return; + + const allFiles = Array.from(files); + if (!allFiles.some(shouldInterceptInsertion)) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + await handleInsertedFiles(allFiles, "drop"); +} + +async function handleFiles(allFiles: File[], forceCompression = false) { + try { + return await doHandleFiles(allFiles, forceCompression); + } catch (err) { + showNotification({ + title: "AutoCompress", + body: `Unexpected error: ${err instanceof Error ? err.message : String(err)}`, + color: "#f04747", + noPersist: false, + }); + } +} + +async function doHandleFiles(allFiles: File[], forceCompression = false) { + const compressibleFiles: File[] = []; + const otherFiles: File[] = []; + + for (const file of allFiles) { + if (shouldCompressFile(file, forceCompression)) { + compressibleFiles.push(file); + } else { + otherFiles.push(file); + } + } + + const channelId = SelectedChannelStore.getChannelId(); + if (!channelId) return; + + debugLog("handling files", { + total: allFiles.length, + compressible: compressibleFiles.length, + passthrough: otherFiles.length, + target: getCompressionTargetBytes(), + threshold: getCompressionThresholdBytes(), + concurrentJobs: getConcurrentJobs(), + }); + + if (compressibleFiles.length === 0) { + if (otherFiles.length > 0) { + await addFilesToUploadManager(channelId, otherFiles); + createUploadCancelCard(channelId, otherFiles); + } + return; + } + + const results = await processFilesLimited(compressibleFiles); + + const successful = results.filter((r): r is Extract => r.success); + const cancelled = results.filter((r): r is Extract => !r.success && !!r.cancelled); + const failed = results.filter((r): r is Extract => !r.success && !r.cancelled); + const fallbackFiles = getFailedFileBehavior() === "upload-original" + ? failed.map(r => r.file) + : []; + const toUpload = [...successful.map(r => r.file), ...fallbackFiles, ...otherFiles]; + + debugLog("compression complete", { + successful: successful.map(r => ({ + originalFileName: r.originalFileName, + outputName: r.file.name, + outputSize: r.file.size, + output: r.output, + encoderUsed: r.encoderUsed, + })), + failed: failed.map(r => ({ fileName: r.fileName, error: r.error })), + cancelled: cancelled.length, + fallbackUploads: fallbackFiles.map(file => ({ name: file.name, size: file.size })), + uploadCount: toUpload.length, + }); + + if (cancelled.length === results.length) { + showToast("Compression cancelled", Toasts.Type.MESSAGE); + return; + } + + const previewLines = [ + ...successful.map(formatResultLine), + ...fallbackFiles.map(file => `${file.name}: uploaded original after compression failed`), + ]; + createPostCompressionPreview(previewLines, failed.length === 0 ? "#43b581" : "#faa61a"); + + const messageParts = [ + `Compressed ${successful.length}/${results.length} file(s)`, + failed.length > 0 ? `Failed: ${failed.map(f => `${f.fileName} (${f.error})`).join(", ")}` : "", + cancelled.length > 0 ? `Cancelled: ${cancelled.length}` : "", + fallbackFiles.length > 0 ? `Fallback: uploaded ${fallbackFiles.length} original file(s)` : "", + successful.length > 0 ? `Encoder: ${Array.from(new Set(successful.map(r => r.encoderUsed))).join(", ")}` : "", + successful.length > 0 + ? `Changes:\n${successful.map(formatResultLine).join("\n")}` + : "", + ].filter(Boolean); + + if (toUpload.length > 0) { + await addFilesToUploadManager(channelId, toUpload); + createUploadCancelCard(channelId, toUpload); + } + + showNotification({ + title: "AutoCompress", + body: messageParts.join("\n"), + color: failed.length === 0 ? "#43b581" : successful.length === 0 ? "#f04747" : "#faa61a", + noPersist: false, + }); +} + +async function processFilesLimited(files: File[]): Promise { + const results: ProcessResult[] = new Array(files.length); + const limit = getConcurrentJobs(); + let nextIndex = 0; + + async function worker() { + while (nextIndex < files.length) { + const index = nextIndex++; + results[index] = await processFile(files[index]); + } + } + + await Promise.all(Array.from({ length: Math.min(limit, files.length) }, () => worker())); + return results; +} + +async function resolveInputPath( + file: File, + jobId: string, + shouldCancel: () => boolean, +): Promise<{ inputPath: string; isTemp: boolean; }> { + const nativePath = (file as { path?: string; }).path; + if (nativePath && nativePath.length > 0) return { inputPath: nativePath, isTemp: false }; + + showToast(`Staging ${file.name} for compression...`, Toasts.Type.MESSAGE); + updateProgressCard(jobId, 0, "Staging file..."); + const tempPath = await Native.openTempFile(file.name); + const reader = file.stream().getReader(); + let written = 0; + + try { + while (true) { + if (shouldCancel()) { + await reader.cancel().catch(() => {}); + throw makeCancelledError(); + } + + const { done, value } = await reader.read(); + if (done) break; + let offset = 0; + while (offset < value.byteLength) { + if (shouldCancel()) throw makeCancelledError(); + const chunk = value.subarray(offset, offset + CHUNK_SIZE); + await Native.writeChunk(tempPath, chunk); + offset += chunk.byteLength; + written += chunk.byteLength; + updateProgressCard(jobId, Math.floor((written / file.size) * 100), "Staging file..."); + } + } + } catch (err) { + await Native.closeTempFile(tempPath).catch(() => {}); + throw err; + } finally { + reader.releaseLock(); + } + + await Native.closeTempFile(tempPath); + return { inputPath: tempPath, isTemp: true }; +} + +async function loadImage(file: File): Promise { + const url = URL.createObjectURL(file); + + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("failed to load image")); + }; + image.src = url; + }); +} + +function getImageBounds(width: number, height: number): { width: number; height: number; } { + const custom = getCustomMaxDimensions(); + if (custom.width > 0 || custom.height > 0) { + const scale = Math.min( + 1, + custom.width > 0 ? custom.width / width : 1, + custom.height > 0 ? custom.height / height : 1, + ); + + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; + } + + const bounds: Record = { + "1080": { width: 1920, height: 1080 }, + "720": { width: 1280, height: 720 }, + "480": { width: 854, height: 480 }, + }; + const bound = bounds[settings.store.maxResolution]; + if (!bound) return { width, height }; + + const scale = Math.min(1, bound.width / width, bound.height / height); + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob(blob => { + if (!blob) { + reject(new Error(`${type} encoding is not available`)); + return; + } + + resolve(blob); + }, type, quality); + }); +} + +type ImageCompressionCandidate = { + blob: Blob; + outputType: string; +}; + +async function compressCanvasToTarget( + canvas: HTMLCanvasElement, + outputType: string, + targetBytes: number, + onIteration: (iteration: number, total: number) => void, + shouldCancel: () => boolean, +): Promise { + const totalIterations = 9; + let low = 0; + let high = 0.92; + let bestWithinTarget: Blob | null = null; + const lowestQualityBlob = await canvasToBlob(canvas, outputType, 0); + + for (let i = 0; i < totalIterations; i++) { + if (shouldCancel()) throw makeCancelledError(); + + const quality = (low + high) / 2; + const blob = await canvasToBlob(canvas, outputType, quality); + onIteration(i + 1, totalIterations); + + if (blob.size <= targetBytes) { + bestWithinTarget = blob; + low = quality; + } else { + high = quality; + } + } + + return { + blob: bestWithinTarget ?? lowestQualityBlob, + outputType, + }; +} + +async function getBestImageCompression( + canvas: HTMLCanvasElement, + sourceType: string, + targetBytes: number, + onProgress: (percent: number) => void, + shouldCancel: () => boolean, +): Promise { + const outputTypes = sourceType === "image/jpeg" + ? ["image/jpeg", "image/webp"] + : ["image/webp"]; + const candidates: ImageCompressionCandidate[] = []; + + for (let i = 0; i < outputTypes.length; i++) { + const baseProgress = Math.round((i / outputTypes.length) * 80); + const progressShare = Math.round(80 / outputTypes.length); + const candidate = await compressCanvasToTarget( + canvas, + outputTypes[i], + targetBytes, + (iteration, total) => onProgress(15 + baseProgress + Math.round((iteration / total) * progressShare)), + shouldCancel, + ); + candidates.push(candidate); + } + + return candidates.reduce((best, candidate) => candidate.blob.size < best.blob.size ? candidate : best); +} + +function replaceExtension(fileName: string, extension: string): string { + return fileName.includes(".") + ? fileName.replace(/\.[^/.]+$/, extension) + : `${fileName}${extension}`; +} + +function getCompressedMediaOutput(file: File): { name: string; type: string; } { + const isAudio = file.type.toLowerCase().startsWith("audio/") + || AUDIO_EXTENSIONS.has(getFileExtension(file.name)); + + return isAudio + ? { name: replaceExtension(file.name, ".m4a"), type: "audio/mp4" } + : { name: replaceExtension(file.name, ".mp4"), type: "video/mp4" }; +} + +async function processImageFile(file: File): Promise { + let cancelled = false; + const jobId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + createProgressCard(jobId, file.name, () => { + cancelled = true; + updateProgressCard(jobId, 0, "Cancelling..."); + }); + + try { + updateProgressCard(jobId, 5, "Loading image..."); + const image = await loadImage(file); + if (cancelled) throw makeCancelledError(); + + const dimensions = getImageBounds(image.naturalWidth, image.naturalHeight); + const canvas = document.createElement("canvas"); + canvas.width = dimensions.width; + canvas.height = dimensions.height; + + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("failed to create image canvas"); + + ctx.drawImage(image, 0, 0, dimensions.width, dimensions.height); + + const targetBytes = getCompressionTargetBytes(); + const imageCompression = await getBestImageCompression( + canvas, + file.type.toLowerCase(), + targetBytes, + percent => updateProgressCard(jobId, percent, "Compressing image..."), + () => cancelled, + ); + const bestBlob = imageCompression.blob; + + if (bestBlob.size >= file.size) { + const fallback = makeOriginalFallbackResult(file, "compression was larger"); + if (fallback) return fallback; + + throw new Error("image compression did not reduce file size"); + } + + if (bestBlob.size > targetBytes && !settings.store.uploadIfSmaller) { + throw new Error(`compressed image is ${formatBytes(bestBlob.size)}, above target ${formatBytes(targetBytes)}`); + } + + updateProgressCard(jobId, 100, "100%"); + const { outputType } = imageCompression; + const outputName = outputType === file.type ? file.name : replaceExtension(file.name, ".webp"); + const compressedFile = new File([bestBlob], outputName, { type: outputType }); + + return { + success: true, + file: compressedFile, + originalFileName: file.name, + originalSizeMB: file.size / (1024 * 1024), + sizeMB: bestBlob.size / (1024 * 1024), + encoderUsed: outputType === "image/jpeg" ? "canvas-jpeg" : "canvas-webp", + output: "compressed", + note: bestBlob.size > targetBytes ? "above target" : undefined, + }; + } catch (err) { + if ((err as { cancelled?: boolean; })?.cancelled) { + return { success: false, file, fileName: file.name, error: "cancelled", cancelled: true }; + } + + return { + success: false, + file, + fileName: file.name, + error: err instanceof Error ? err.message : String(err), + }; + } finally { + removeProgressCard(jobId); + } +} + +async function processFile(file: File): Promise { + if (getCompressionKind(file) === "image") return processImageFile(file); + + return processMediaFile(file); +} + +async function processMediaFile(file: File): Promise { + let inputPath: string | undefined; + let inputIsTemp = false; + let outPath: string | undefined; + let pollInterval: ReturnType | undefined; + let cancelled = false; + + const jobId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + createProgressCard(jobId, file.name, () => { + cancelled = true; + updateProgressCard(jobId, 0, "Cancelling..."); + void Native.cancelJob(jobId); + }); + + try { + ({ inputPath, isTemp: inputIsTemp } = await resolveInputPath(file, jobId, () => cancelled)); + if (cancelled) throw makeCancelledError(); + + pollInterval = setInterval(async () => { + try { + const percent = await Native.getProgress(jobId); + if (percent !== null) updateProgressCard(jobId, percent, formatProgressStatus(jobId, percent)); + } catch { + // Ignore polling errors during teardown/cancellation. + } + }, 250); + + const res = await Native.handleFile( + jobId, + inputPath, + file.name, + file.type, + getCompressionTargetMB(), + settings.store.compressionPreset, + settings.store.maxResolution, + getCustomMaxDimensions().width, + getCustomMaxDimensions().height, + settings.store.useHardwareDecode, + settings.store.ffmpegTimeout * 1000, + ); + + if (!res.success) { + if (res.cancelled) { + return { success: false, file, fileName: file.name, error: "cancelled", cancelled: true }; + } + + return { success: false, file, fileName: file.name, error: res.error }; + } + + outPath = res.outPath; + const bytes = await Native.readFileBytes(outPath); + if (bytes.byteLength >= file.size) { + const fallback = makeOriginalFallbackResult(file, "compression was larger"); + if (fallback) return fallback; + + return { success: false, file, fileName: file.name, error: "compressed file was not smaller" }; + } + + const targetBytes = getCompressionTargetBytes(); + if (bytes.byteLength > targetBytes && !settings.store.uploadIfSmaller) { + return { + success: false, + file, + fileName: file.name, + error: `compressed file is ${formatBytes(bytes.byteLength)}, above target ${formatBytes(targetBytes)}`, + }; + } + + const output = getCompressedMediaOutput(file); + const compressedFile = new File([bytes], output.name, { type: output.type }); + return { + success: true, + file: compressedFile, + originalFileName: file.name, + originalSizeMB: file.size / (1024 * 1024), + sizeMB: bytes.byteLength / (1024 * 1024), + encoderUsed: res.encoderUsed, + output: "compressed", + note: bytes.byteLength > targetBytes ? "above target" : undefined, + }; + } catch (err) { + if ((err as { cancelled?: boolean; })?.cancelled) { + return { success: false, file, fileName: file.name, error: "cancelled", cancelled: true }; + } + + return { + success: false, + file, + fileName: file.name, + error: err instanceof Error ? err.message : String(err), + }; + } finally { + if (pollInterval) clearInterval(pollInterval); + removeProgressCard(jobId); + await Native.clearProgress(jobId).catch(() => {}); + if (outPath) await Native.cleanupFile(outPath).catch(() => {}); + if (inputIsTemp && inputPath) await Native.cleanupFile(inputPath).catch(() => {}); + } +} + +export default definePlugin({ + name: "AutoCompress", + description: "Automatically compress videos, audio, and images to reach a target size", + authors: [{ name: "dyn", id: 262458273247002636n }], + settings, + + start() { + document.addEventListener("dragover", hookDragOver, { capture: true }); + document.addEventListener("drop", hookDrop, { capture: true }); + document.addEventListener("paste", hookPaste, { capture: true }); + }, + + stop() { + document.removeEventListener("dragover", hookDragOver, { capture: true }); + document.removeEventListener("drop", hookDrop, { capture: true }); + document.removeEventListener("paste", hookPaste, { capture: true }); + document.getElementById(OVERLAY_ID)?.remove(); + for (const watcher of uploadWatchers.values()) clearInterval(watcher); + uploadWatchers.clear(); + validationCache = null; + lastHandledBatchKey = ""; + lastHandledBatchAt = 0; + progressEstimates.clear(); + }, +}); diff --git a/native.ts b/native.ts new file mode 100644 index 0000000..a56fa57 --- /dev/null +++ b/native.ts @@ -0,0 +1,747 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ChildProcess, spawn } from "node:child_process"; +import { createWriteStream, WriteStream } from "node:fs"; +import { readFile, stat, unlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { IpcMainInvokeEvent } from "electron"; + +import { + clearBinaryCache, + clearEncoderCache, + detectEncoder, + getEncoderFallbackOrder, + pullBinary, + resolveBinary, + VideoEncoder, +} from "./resolve"; + +type CompressResult = + | { success: true; outPath: string; encoderUsed: string; } + | { success: false; error: string; cancelled?: true; }; + +const openStreams = new Map(); +const progressMap = new Map(); +const activeJobs = new Map(); +const cancelledJobs = new Set(); +const TARGET_BITRATE_SAFETY = 0.9; +const MIN_VIDEO_BITRATE_KBPS = 40; +const MIN_AUDIO_BITRATE_KBPS = 24; +const VIDEO_RETRY_BITRATE_SCALES = [1, 0.82, 0.68, 0.55]; +const AUDIO_RETRY_BITRATE_SCALES = [1, 0.8, 0.64, 0.5]; +const AUDIO_EXTENSIONS = new Set([ + ".mp3", + ".wav", + ".flac", +]); + +function mapNvencPreset(preset: string): string { + const presets: Record = { + ultrafast: "p1", + fast: "p2", + medium: "p4", + slow: "p6", + veryslow: "p7", + }; + + return presets[preset] ?? "p4"; +} + +function mapAmfQuality(preset: string): string { + const qualities: Record = { + ultrafast: "speed", + fast: "speed", + medium: "balanced", + slow: "quality", + veryslow: "quality", + }; + + return qualities[preset] ?? "balanced"; +} + +function mapQsvPreset(preset: string): string { + const presets: Record = { + ultrafast: "veryfast", + fast: "fast", + medium: "medium", + slow: "slow", + veryslow: "veryslow", + }; + + return presets[preset] ?? "medium"; +} + +function buildEncoderArgs(encoder: VideoEncoder, vidBitrate: number, preset: string): string[] { + switch (encoder) { + case "h264_nvenc": + return [ + "-c:v", "h264_nvenc", + "-preset", mapNvencPreset(preset), + "-b:v", `${vidBitrate}k`, + "-maxrate", `${vidBitrate}k`, + "-bufsize", `${vidBitrate * 2}k`, + "-rc", "cbr", + "-cbr", "true", + ]; + + case "h264_amf": + return [ + "-c:v", "h264_amf", + "-quality", mapAmfQuality(preset), + "-b:v", `${vidBitrate}k`, + "-maxrate", `${vidBitrate}k`, + "-bufsize", `${vidBitrate * 2}k`, + "-rc", "cbr", + ]; + + case "h264_qsv": + return [ + "-c:v", "h264_qsv", + "-preset", mapQsvPreset(preset), + "-b:v", `${vidBitrate}k`, + "-maxrate", `${vidBitrate}k`, + "-bufsize", `${vidBitrate * 2}k`, + "-look_ahead", "0", + ]; + + case "h264_videotoolbox": + return [ + "-c:v", "h264_videotoolbox", + "-b:v", `${vidBitrate}k`, + "-maxrate", `${vidBitrate}k`, + "-bufsize", `${vidBitrate * 2}k`, + ]; + + case "libx264": + default: + return [ + "-c:v", "libx264", + "-b:v", `${vidBitrate}k`, + "-maxrate", `${vidBitrate}k`, + "-bufsize", `${vidBitrate * 2}k`, + "-preset", preset, + ]; + } +} + +function isAudioMime(mimeType: string): boolean { + return mimeType.startsWith("audio/"); +} + +function getFileExtension(fileName: string): string { + const extension = fileName.match(/\.[^/.]+$/)?.[0]; + + return extension?.toLowerCase() ?? ""; +} + +function isAudioFile(fileName: string, mimeType: string): boolean { + return isAudioMime(mimeType.toLowerCase()) || AUDIO_EXTENSIONS.has(getFileExtension(fileName)); +} + +function getVideoAudioBitrate(totalBitrate: number): number { + if (totalBitrate <= 96) return MIN_AUDIO_BITRATE_KBPS; + if (totalBitrate <= 180) return 32; + if (totalBitrate <= 360) return 48; + if (totalBitrate <= 800) return 64; + + return Math.min(96, Math.floor(totalBitrate * 0.18)); +} + +function getAudioOnlyBitrate(totalBitrate: number): number { + return Math.min(160, Math.max(MIN_AUDIO_BITRATE_KBPS, totalBitrate)); +} + +function getScaledBitrate(bitrate: number, scale: number, minimum: number): number { + return Math.max(minimum, Math.floor(bitrate * scale)); +} + +export async function openTempFile(_: IpcMainInvokeEvent, fileName: string): Promise { + const ext = path.extname(fileName) || ".tmp"; + const tempPath = path.join(os.tmpdir(), `ac_in_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`); + openStreams.set(tempPath, createWriteStream(tempPath)); + return tempPath; +} + +export function writeChunk(_: IpcMainInvokeEvent, tempPath: string, chunk: Uint8Array): Promise { + return new Promise((resolve, reject) => { + const stream = openStreams.get(tempPath); + if (!stream) { + reject(new Error(`no open stream for ${tempPath}`)); + return; + } + + stream.write(Buffer.from(chunk), err => err ? reject(err) : resolve()); + }); +} + +export function closeTempFile(_: IpcMainInvokeEvent, tempPath: string): Promise { + return new Promise((resolve, reject) => { + const stream = openStreams.get(tempPath); + if (!stream) { + resolve(); + return; + } + + stream.once("error", reject); + stream.end(() => { + openStreams.delete(tempPath); + resolve(); + }); + }); +} + +export function getProgress(_: IpcMainInvokeEvent, jobId: string): number | null { + return progressMap.get(jobId) ?? null; +} + +export function clearProgress(_: IpcMainInvokeEvent, jobId: string): void { + progressMap.delete(jobId); + cancelledJobs.delete(jobId); +} + +export function cancelJob(_: IpcMainInvokeEvent, jobId: string): void { + cancelledJobs.add(jobId); + const proc = activeJobs.get(jobId); + if (proc) { + proc.kill(); + activeJobs.delete(jobId); + } +} + +export async function testBinaries( + _: IpcMainInvokeEvent, + ffmpegPath: string | undefined, + ffprobePath: string | undefined, +) { + try { + clearBinaryCache(); + clearEncoderCache(); + + await resolveBinary(ffmpegPath, "ffmpeg"); + await resolveBinary(ffprobePath, "ffprobe"); + + const encoder = await detectEncoder(); + return { success: true, encoder }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function getDiagnostics(_: IpcMainInvokeEvent): Promise { + const { app } = await import("electron"); + + const ffmpegPath = (() => { + try { + return pullBinary("ffmpeg"); + } catch { + return null; + } + })(); + + let gpuDevices: unknown = null; + let gpuError: string | null = null; + + try { + const info = await app.getGPUInfo("complete") as { gpuDevice?: unknown; }; + gpuDevices = info?.gpuDevice ?? null; + } catch (e) { + gpuError = e instanceof Error ? e.message : String(e); + } + + let h264Encoders: string[] | null = null; + if (ffmpegPath) { + h264Encoders = await new Promise(resolve => { + const p = spawn(ffmpegPath, ["-encoders", "-v", "quiet"]); + let out = ""; + + p.stdout.on("data", d => { out += d.toString(); }); + p.stderr.on("data", d => { out += d.toString(); }); + + p.on("close", () => { + resolve( + out + .split("\n") + .filter(line => line.toLowerCase().includes("h264")) + .map(line => line.trim()) + ); + }); + + p.on("error", () => resolve(null)); + }); + } + + const encoderTests: Record = {}; + if (ffmpegPath) { + const { testEncodeWithError } = await import("./resolve"); + const encoders = ["h264_nvenc", "h264_amf", "h264_qsv", "h264_videotoolbox", "libx264"] as const; + + for (const enc of encoders) { + const result = await testEncodeWithError(ffmpegPath, enc); + encoderTests[enc] = result.success ? "ok" : result.error; + } + } + + return { + platform: process.platform, + ffmpegPath, + gpuError, + gpuDevices, + h264Encoders, + encoderTests, + }; +} + +export async function handleFile( + _: IpcMainInvokeEvent, + jobId: string, + filePath: string, + fileName: string, + mimeType: string, + target: number, + preset: string, + resolution: string, + maxWidth: number, + maxHeight: number, + useHardwareDecode: boolean, + timeout: number, +): Promise { + const hasVideo = !isAudioFile(fileName, mimeType); + const preferredExt = hasVideo ? ".mp4" : ".m4a"; + const outPath = path.join(os.tmpdir(), `ac_out_${jobId}${preferredExt}`); + + try { + const duration = await getMediaDuration(filePath); + + const targetBytes = target * 1024 * 1024; + const targetBits = targetBytes * 8 * TARGET_BITRATE_SAFETY; + const totalBitrateKbps = Math.max(MIN_AUDIO_BITRATE_KBPS, Math.floor(targetBits / duration / 1000)); + const audioBitrate = hasVideo + ? getVideoAudioBitrate(totalBitrateKbps) + : getAudioOnlyBitrate(totalBitrateKbps); + const videoBitrate = hasVideo + ? Math.max(MIN_VIDEO_BITRATE_KBPS, totalBitrateKbps - audioBitrate) + : 0; + + const onProgress = (percent: number) => progressMap.set(jobId, percent); + const registerJob = (proc: ChildProcess) => activeJobs.set(jobId, proc); + + if (hasVideo) { + const preferredEncoder = await detectEncoder(); + const encoders = getEncoderFallbackOrder(preferredEncoder); + const errors: string[] = []; + let encoderUsed: VideoEncoder | null = null; + + encoderLoop: + for (const encoder of encoders) { + if (cancelledJobs.has(jobId)) { + const err = new Error("cancelled") as Error & { cancelled?: boolean; }; + err.cancelled = true; + throw err; + } + + const hasCustomDimensions = getPositiveDimension(maxWidth) > 0 || getPositiveDimension(maxHeight) > 0; + const resolutionAttempts = resolution === "original" && !hasCustomDimensions && encoder !== "libx264" + ? ["original", "1080"] + : [resolution]; + + for (const resolutionAttempt of resolutionAttempts) { + for (let bitrateAttempt = 0; bitrateAttempt < VIDEO_RETRY_BITRATE_SCALES.length; bitrateAttempt++) { + const bitrateScale = VIDEO_RETRY_BITRATE_SCALES[bitrateAttempt]; + const scaledVideoBitrate = getScaledBitrate(videoBitrate, bitrateScale, MIN_VIDEO_BITRATE_KBPS); + const scaledAudioBitrate = getScaledBitrate(audioBitrate, bitrateScale, MIN_AUDIO_BITRATE_KBPS); + + await unlink(outPath).catch(() => {}); + onProgress(0); + + try { + await compressVideo( + filePath, + outPath, + scaledVideoBitrate, + scaledAudioBitrate, + preset, + resolutionAttempt, + maxWidth, + maxHeight, + useHardwareDecode, + timeout, + encoder, + duration, + onProgress, + registerJob, + () => cancelledJobs.has(jobId), + ); + + const outputSize = (await stat(outPath)).size; + if (outputSize <= targetBytes || bitrateAttempt === VIDEO_RETRY_BITRATE_SCALES.length - 1) { + encoderUsed = encoder; + break encoderLoop; + } + + errors.push(`${encoder}${resolutionAttempt === resolution ? "" : ` (${resolutionAttempt}p retry)`}: ${Math.round(bitrateScale * 100)}% bitrate produced ${(outputSize / 1024 / 1024).toFixed(2)}MB, retrying lower`); + } catch (err) { + activeJobs.delete(jobId); + + const maybeCancelled = err as { cancelled?: boolean; message?: string; toString(): string; }; + if (maybeCancelled?.cancelled || cancelledJobs.has(jobId)) { + const cancelErr = new Error("cancelled") as Error & { cancelled?: boolean; }; + cancelErr.cancelled = true; + throw cancelErr; + } + + errors.push(`${encoder}${resolutionAttempt === resolution ? "" : ` (${resolutionAttempt}p retry)`}: ${maybeCancelled?.message || maybeCancelled?.toString() || "unknown error"}`); + break; + } + } + } + } + + if (!encoderUsed) { + return { + success: false, + error: `all encoders failed:\n${errors.join("\n\n")}`, + }; + } + + activeJobs.delete(jobId); + return { success: true, outPath, encoderUsed }; + } else { + for (let bitrateAttempt = 0; bitrateAttempt < AUDIO_RETRY_BITRATE_SCALES.length; bitrateAttempt++) { + const bitrateScale = AUDIO_RETRY_BITRATE_SCALES[bitrateAttempt]; + const scaledAudioBitrate = getScaledBitrate(audioBitrate, bitrateScale, MIN_AUDIO_BITRATE_KBPS); + + await unlink(outPath).catch(() => {}); + onProgress(0); + + await compressAudio( + filePath, + outPath, + scaledAudioBitrate, + timeout, + duration, + onProgress, + registerJob, + () => cancelledJobs.has(jobId), + ); + + const outputSize = (await stat(outPath)).size; + if (outputSize <= targetBytes || bitrateAttempt === AUDIO_RETRY_BITRATE_SCALES.length - 1) break; + } + + activeJobs.delete(jobId); + return { success: true, outPath, encoderUsed: "aac" }; + } + } catch (err) { + activeJobs.delete(jobId); + await unlink(outPath).catch(() => {}); + + const maybeCancelled = err as { cancelled?: boolean; message?: string; toString(): string; }; + if (maybeCancelled?.cancelled) { + return { success: false, error: "cancelled", cancelled: true }; + } + + return { + success: false, + error: maybeCancelled?.message || maybeCancelled?.toString() || "unknown error", + }; + } +} + +export async function cleanupFile(_: IpcMainInvokeEvent, filePath: string): Promise { + await unlink(filePath).catch(() => {}); +} + +export async function readFileBytes(_: IpcMainInvokeEvent, filePath: string): Promise { + const data = await readFile(filePath); + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); +} + +function getMediaDuration(inputPath: string): Promise { + return new Promise((resolve, reject) => { + const ffprobe = spawn(pullBinary("ffprobe"), [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + inputPath, + ]); + + let out = ""; + let errOut = ""; + + ffprobe.stdout.on("data", d => { out += d.toString(); }); + ffprobe.stderr.on("data", d => { errOut += d.toString(); }); + + ffprobe.on("close", code => { + if (code !== 0) { + reject(new Error(`ffprobe exited with ${code}, ${errOut.trim()}`)); + return; + } + + const duration = parseFloat(out.trim()); + if (Number.isNaN(duration) || duration <= 0) { + reject(new Error("ffprobe returned invalid duration")); + return; + } + + resolve(duration); + }); + + ffprobe.on("error", err => reject(new Error(`failed to spawn ffprobe: ${err.message}`))); + }); +} + +function parseProgressPercent(line: string, totalDuration: number): number | null { + const trimmed = line.trim(); + + const outTimeMs = trimmed.match(/^out_time_ms=(\d+)$/); + if (outTimeMs) { + const elapsed = parseInt(outTimeMs[1], 10) / 1_000_000; + return Math.min(99, Math.floor((elapsed / totalDuration) * 100)); + } + + const outTime = trimmed.match(/^out_time=(\d+):(\d+):(\d+(?:\.\d+)?)$/); + if (outTime) { + const elapsed = + parseInt(outTime[1], 10) * 3600 + + parseInt(outTime[2], 10) * 60 + + parseFloat(outTime[3]); + return Math.min(99, Math.floor((elapsed / totalDuration) * 100)); + } + + const statsTime = trimmed.match(/time=(\d+):(\d+):(\d+(?:\.\d+)?)/); + if (statsTime) { + const elapsed = + parseInt(statsTime[1], 10) * 3600 + + parseInt(statsTime[2], 10) * 60 + + parseFloat(statsTime[3]); + return Math.min(99, Math.floor((elapsed / totalDuration) * 100)); + } + + return null; +} + +function getPositiveDimension(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0; + + return Math.floor(value); +} + +function buildScaleFilter(maxResolution: string, maxWidth: number, maxHeight: number): string | null { + const customWidth = getPositiveDimension(maxWidth); + const customHeight = getPositiveDimension(maxHeight); + + if (customWidth > 0 || customHeight > 0) { + const widthExpr = customWidth > 0 ? `min(iw,${customWidth})` : "iw"; + const heightExpr = customHeight > 0 ? `min(ih,${customHeight})` : "ih"; + + return `scale=w='${widthExpr}':h='${heightExpr}':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2'`; + } + + const resolutionMap: Record = { + "1080": "scale=w='min(iw,1920)':h='min(ih,1080)':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2'", + "720": "scale=w='min(iw,1280)':h='min(ih,720)':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2'", + "480": "scale=w='min(iw,854)':h='min(ih,480)':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2'", + }; + + return resolutionMap[maxResolution] ?? null; +} + +function buildVideoFilter(maxResolution: string, maxWidth: number, maxHeight: number): string | null { + return buildScaleFilter(maxResolution, maxWidth, maxHeight); +} + +function compressVideo( + inputPath: string, + outputPath: string, + vidBitrate: number, + audioBitrate: number, + preset: string, + maxResolution: string, + maxWidth: number, + maxHeight: number, + useHardwareDecode: boolean, + ffmpegTimeout: number, + encoder: VideoEncoder, + duration: number, + onProgress: (percent: number) => void, + onSpawn: (proc: ChildProcess) => void, + isCancelled: () => boolean, +): Promise { + return new Promise((resolve, reject) => { + const filter = buildVideoFilter(maxResolution, maxWidth, maxHeight); + const canUseHardwareDecode = useHardwareDecode && encoder !== "libx264" && filter === null; + const args = [ + "-y", + "-hide_banner", + ...(canUseHardwareDecode ? ["-hwaccel", "auto"] : []), + "-i", inputPath, + "-map", "0:v:0", + "-map", "0:a:0?", + ...buildEncoderArgs(encoder, vidBitrate, preset), + ]; + + if (filter) { + args.push("-vf", filter); + } + + args.push( + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", `${audioBitrate}k`, + "-ac", "2", + "-map_metadata", "-1", + "-map_chapters", "-1", + "-movflags", "+faststart", + "-progress", "pipe:1", + "-nostats", + outputPath, + ); + + const ffmpeg = spawn(pullBinary("ffmpeg"), args); + onSpawn(ffmpeg); + + let errOut = ""; + let settled = false; + + ffmpeg.stdout.on("data", data => { + const chunk = data.toString(); + + for (const line of chunk.split("\n")) { + const pct = parseProgressPercent(line, duration); + if (pct !== null) onProgress(pct); + } + }); + + ffmpeg.stderr.on("data", data => { + errOut += data.toString(); + }); + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + ffmpeg.kill(); + reject(new Error(`ffmpeg exceeded allotted time of ${ffmpegTimeout / 1000}s`)); + }, ffmpegTimeout); + + ffmpeg.on("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + + if (isCancelled() || signal === "SIGTERM" || signal === "SIGKILL") { + const err = new Error("cancelled") as Error & { cancelled?: boolean; }; + err.cancelled = true; + reject(err); + return; + } + + if (code === 0) { + onProgress(100); + resolve(); + return; + } + + const tail = errOut.trim().split("\n").slice(-8).join("\n"); + reject(new Error(`${encoder} failed (code ${code}):\n${tail}`)); + }); + + ffmpeg.on("error", err => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error(`ffmpeg spawn error: ${err}. stderr: ${errOut}`)); + }); + }); +} + +function compressAudio( + inputPath: string, + outputPath: string, + audioBitrate: number, + ffmpegTimeout: number, + duration: number, + onProgress: (percent: number) => void, + onSpawn: (proc: ChildProcess) => void, + isCancelled: () => boolean, +): Promise { + return new Promise((resolve, reject) => { + const args = [ + "-y", + "-hide_banner", + "-i", inputPath, + "-vn", + "-c:a", "aac", + "-b:a", `${audioBitrate}k`, + "-ac", "2", + "-map_metadata", "-1", + "-map_chapters", "-1", + "-progress", "pipe:1", + "-nostats", + outputPath, + ]; + + const ffmpeg = spawn(pullBinary("ffmpeg"), args); + onSpawn(ffmpeg); + + let errOut = ""; + let settled = false; + + ffmpeg.stdout.on("data", data => { + const chunk = data.toString(); + + for (const line of chunk.split("\n")) { + const pct = parseProgressPercent(line, duration); + if (pct !== null) onProgress(pct); + } + }); + + ffmpeg.stderr.on("data", data => { + errOut += data.toString(); + }); + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + ffmpeg.kill(); + reject(new Error(`ffmpeg exceeded allotted time of ${ffmpegTimeout / 1000}s`)); + }, ffmpegTimeout); + + ffmpeg.on("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + + if (isCancelled() || signal === "SIGTERM" || signal === "SIGKILL") { + const err = new Error("cancelled") as Error & { cancelled?: boolean; }; + err.cancelled = true; + reject(err); + return; + } + + if (code === 0) { + onProgress(100); + resolve(); + return; + } + + const tail = errOut.trim().split("\n").slice(-8).join("\n"); + reject(new Error(`aac failed (code ${code}):\n${tail}`)); + }); + + ffmpeg.on("error", err => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error(`ffmpeg spawn error: ${err}. stderr: ${errOut}`)); + }); + }); +} diff --git a/resolve.ts b/resolve.ts new file mode 100644 index 0000000..0061520 --- /dev/null +++ b/resolve.ts @@ -0,0 +1,319 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import path from "node:path"; + +import { app } from "electron"; + +const cache: Partial> = {}; + +export type VideoEncoder = + | "h264_nvenc" + | "h264_amf" + | "h264_qsv" + | "h264_videotoolbox" + | "libx264"; + +let cachedEncoder: VideoEncoder | null = null; + +const GPU_ENCODERS: VideoEncoder[] = process.platform === "darwin" + ? ["h264_videotoolbox"] + : ["h264_nvenc", "h264_amf", "h264_qsv"]; + +const VENDOR_ENCODER_MAP: Record = { + 0x10DE: "h264_nvenc", + 0x1002: "h264_amf", + 0x1022: "h264_amf", + 0x8086: "h264_qsv", +}; + +export function getEncoderFallbackOrder(preferredEncoder: VideoEncoder): VideoEncoder[] { + const candidates = preferredEncoder === "libx264" + ? [...GPU_ENCODERS, preferredEncoder] + : [preferredEncoder, ...GPU_ENCODERS, "libx264" as VideoEncoder]; + + return candidates.filter((encoder, index) => candidates.indexOf(encoder) === index); +} + +async function getVendorEncoder(): Promise { + if (process.platform === "darwin") { + return "h264_videotoolbox"; + } + + try { + const info = await app.getGPUInfo("complete") as { + gpuDevice?: Array<{ vendorId?: number; description?: string; }>; + }; + const devices = info?.gpuDevice ?? []; + + for (const device of devices) { + const { vendorId } = device; + if (typeof vendorId === "number") { + const encoder = VENDOR_ENCODER_MAP[vendorId]; + if (encoder) return encoder; + } + } + + for (const device of devices) { + const desc = (device.description ?? "").toLowerCase(); + if (desc.includes("nvidia")) return "h264_nvenc"; + if (desc.includes("amd") || desc.includes("radeon")) return "h264_amf"; + if (desc.includes("intel")) return "h264_qsv"; + } + } catch { + // app.getGPUInfo can throw in sandboxed environments — not fatal. + } + + return null; +} + +export function testEncodeWithError( + ffmpegPath: string, + encoder: VideoEncoder, +): Promise<{ success: true; } | { success: false; error: string; }> { + return new Promise(resolve => { + const encoderArgs: Record = { + "h264_nvenc": ["-c:v", "h264_nvenc", "-rc", "cbr", "-b:v", "1000k"], + "h264_amf": ["-c:v", "h264_amf", "-rc", "cbr", "-b:v", "1000k"], + "h264_qsv": ["-c:v", "h264_qsv", "-look_ahead", "0", "-b:v", "1000k"], + "h264_videotoolbox": ["-c:v", "h264_videotoolbox", "-b:v", "1000k"], + "libx264": ["-c:v", "libx264", "-preset", "ultrafast", "-b:v", "1000k"], + }; + + const args = [ + "-f", "lavfi", + "-i", "nullsrc=s=320x240:d=1", + ...encoderArgs[encoder], + "-an", + "-f", "null", + "-", + ]; + + const p = spawn(ffmpegPath, args, { stdio: ["ignore", "ignore", "pipe"] }); + let errOut = ""; + let settled = false; + + p.stderr.on("data", (d: Buffer) => { + errOut += d.toString(); + }); + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + p.kill(); + resolve({ success: false, error: "timed out after 8s" }); + }, 8000); + + p.on("close", code => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (code === 0) { + resolve({ success: true }); + return; + } + + const tail = errOut.trim().split("\n").slice(-5).join("\n"); + resolve({ success: false, error: tail || `ffmpeg exited with code ${code}` }); + }); + + p.on("error", err => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve({ success: false, error: err.message }); + }); + }); +} + +async function testEncode(ffmpegPath: string, encoder: VideoEncoder): Promise { + const result = await testEncodeWithError(ffmpegPath, encoder); + if (!result.success) { + console.log(`[AutoCompress] testEncode ${encoder} failed: ${result.error}`); + } + return result.success; +} + +export async function detectEncoder(): Promise { + if (cachedEncoder !== null) return cachedEncoder; + + const ffmpegPath = pullBinary("ffmpeg"); + const vendorEncoder = await getVendorEncoder(); + + if (vendorEncoder) { + const works = await testEncode(ffmpegPath, vendorEncoder); + if (works) { + cachedEncoder = vendorEncoder; + return cachedEncoder; + } + } + + for (const encoder of GPU_ENCODERS) { + if (encoder === vendorEncoder) continue; + const works = await testEncode(ffmpegPath, encoder); + if (works) { + cachedEncoder = encoder; + return cachedEncoder; + } + } + + cachedEncoder = "libx264"; + return cachedEncoder; +} + +export function clearEncoderCache(): void { + cachedEncoder = null; +} + +export function clearBinaryCache(): void { + delete cache.ffmpeg; + delete cache.ffprobe; +} + +function resolveFromPath(bin: "ffmpeg" | "ffprobe"): string | null { + const name = process.platform === "win32" ? `${bin}.exe` : bin; + const pathEnv = process.env.PATH ?? ""; + const dirs = pathEnv.split(path.delimiter); + + for (const dir of dirs) { + if (!dir) continue; + const candidate = path.join(dir, name); + try { + require("node:fs").accessSync(candidate); + return candidate; + } catch { + continue; + } + } + + return null; +} + +function candidatePaths(bin: "ffmpeg" | "ffprobe"): string[] { + const fromPath = resolveFromPath(bin); + + switch (process.platform) { + case "win32": { + const hardcoded = [ + `C:\\ffmpeg\\bin\\${bin}.exe`, + `C:\\Program Files\\ffmpeg\\bin\\${bin}.exe`, + `C:\\Program Files (x86)\\ffmpeg\\bin\\${bin}.exe`, + `${process.env.USERPROFILE}\\scoop\\shims\\${bin}.exe`, + `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Links\\${bin}.exe`, + ]; + return fromPath ? [fromPath, ...hardcoded] : hardcoded; + } + case "darwin": { + const hardcoded = [ + `/opt/homebrew/bin/${bin}`, + `/usr/local/bin/${bin}`, + `/usr/bin/${bin}`, + ]; + return fromPath ? [fromPath, ...hardcoded] : hardcoded; + } + default: { + const hardcoded = [ + `/usr/bin/${bin}`, + `/usr/local/bin/${bin}`, + `/bin/${bin}`, + `/snap/bin/${bin}`, + `/app/bin/${bin}`, + ]; + return fromPath ? [fromPath, ...hardcoded] : hardcoded; + } + } +} + +async function validateBinary(binPath: string, binName: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const p = spawn(binPath, ["-version"], { shell: false }); + + let out = ""; + let errOut = ""; + let timeExceeded = false; + + const timeout = setTimeout(() => { + timeExceeded = true; + p.kill(); + reject(new Error(`${binPath} validation timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + p.stdout.on("data", d => { + out += d.toString(); + }); + p.stderr.on("data", d => { + errOut += d.toString(); + }); + + p.on("close", code => { + clearTimeout(timeout); + if (timeExceeded) return; + + const combined = `${out}\n${errOut}`.toLowerCase(); + if (code !== 0 || !combined.includes(binName)) { + reject(new Error(`${binPath} is not a valid ${binName} binary`)); + } else { + resolve(); + } + }); + + p.on("error", err => { + clearTimeout(timeout); + if (!timeExceeded) reject(err); + }); + }); +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +export function pullBinary(bin: "ffmpeg" | "ffprobe"): string { + const binPath = cache[bin]; + if (!binPath) throw new Error("requested binary has not been resolved"); + return binPath; +} + +export async function resolveBinary( + userPath: string | undefined, + bin: "ffmpeg" | "ffprobe", +): Promise { + if (cache[bin]) return cache[bin] as string; + + if (userPath) { + if (!path.isAbsolute(userPath)) { + throw new Error(`${bin} path must be absolute`); + } + if (!(await fileExists(userPath))) { + throw new Error(`${bin} not found at ${userPath}`); + } + + await validateBinary(userPath, bin); + cache[bin] = userPath; + return userPath; + } + + for (const candidate of candidatePaths(bin)) { + if (!(await fileExists(candidate))) continue; + + try { + await validateBinary(candidate, bin); + cache[bin] = candidate; + return candidate; + } catch { + continue; + } + } + + throw new Error(`${bin} not found, either needs installation or define a custom path`); +}