From f719e3c364dd2da628b71d8a849df04f09c85f9e Mon Sep 17 00:00:00 2001 From: deanxbox Date: Fri, 1 May 2026 00:12:30 +0100 Subject: [PATCH 1/5] feat: add GPU fallback compression and upload controls - prefer hardware encoders with CPU fallback - add compression ETA and size delta reporting - support cancelling compression and pending uploads - read compressed output through native IPC instead of file URLs --- README.md | 3 +- autoCompress/index.ts | 259 ---------------- autoCompress/native.ts | 200 ------------ autoCompress/resolve.ts | 121 -------- index.ts | 549 +++++++++++++++++++++++++++++++++ native.ts | 659 ++++++++++++++++++++++++++++++++++++++++ resolve.ts | 330 ++++++++++++++++++++ 7 files changed, 1540 insertions(+), 581 deletions(-) delete mode 100644 autoCompress/index.ts delete mode 100644 autoCompress/native.ts delete mode 100644 autoCompress/resolve.ts create mode 100644 index.ts create mode 100644 native.ts create mode 100644 resolve.ts diff --git a/README.md b/README.md index 1eb995f..bb1070b 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,5 @@ - set a limit a bit below your ideal size - 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 +- very large source videos may be retried at 1080p on GPU if the hardware encoder rejects the original resolution 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..8db4f1f --- /dev/null +++ b/index.ts @@ -0,0 +1,549 @@ +/* + * 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 { + DraftType, + SelectedChannelStore, + showToast, + Toasts, + UploadManager, +} from "@webpack/common"; + +type ProcessResult = + | { success: true; file: File; originalSizeMB: number; sizeMB: number; encoderUsed: string; } + | { success: false; fileName: string; error: string; cancelled?: true; }; + +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 CHUNK_SIZE = 4 * 1024 * 1024; + +const settings = definePluginSettings({ + 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.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" }, + ], + }, +}); + +const OVERLAY_ID = "autocompress-progress-overlay"; + +interface ProgressEstimate { + startedAt: number; + lastPercent: number; +} + +const progressEstimates = 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", + bottom: "60px", + right: "16px", + zIndex: "9999", + display: "flex", + flexDirection: "column", + gap: "6px", + pointerEvents: "none", + }); + document.body.appendChild(el); + } + return el; +} + +function createProgressCard(jobId: string, fileName: string, onCancel: () => void): 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", + minWidth: "260px", + maxWidth: "320px", + 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:200px;"; + 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 = "Cancel compression"; + 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 formatSize(sizeMB: number): string { + return sizeMB >= 100 + ? `${Math.round(sizeMB)} MB` + : `${sizeMB.toFixed(1)} 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 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 overlay = document.getElementById(OVERLAY_ID); + if (overlay && overlay.childElementCount === 0) overlay.remove(); +} + +function isValid(files: FileList | undefined): files is FileList { + return files !== undefined && files.length > 0; +} + +function makeCancelledError(): Error & { cancelled: true; } { + const err = new Error("cancelled") as Error & { cancelled: true; }; + err.cancelled = true; + return err; +} + +function createUploadCancelCard(channelId: string, fileCount: number) { + const jobId = `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`; + createProgressCard(jobId, `${fileCount} pending upload${fileCount === 1 ? "" : "s"}`, () => { + UploadManager.clearAll(channelId, DraftType.ChannelMessage); + showToast("Upload cancelled", Toasts.Type.MESSAGE); + removeProgressCard(jobId); + }); + updateProgressCard(jobId, 100, "Ready to upload"); + + setTimeout(() => removeProgressCard(jobId), 30_000); +} + +let validationCache: { ffmpegPath: string; ffprobePath: string; encoder: string; } | null = null; + +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; +} + +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); +} + +function hookDrag(event: DragEvent) { + const types = event.dataTransfer?.types; + if (types?.includes("Files")) { + event.preventDefault(); + event.stopPropagation(); + } +} + +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); + } else { + otherFiles.push(file); + } + } + + const channelId = SelectedChannelStore.getChannelId(); + if (!channelId) return; + + if (compressibleFiles.length === 0) { + if (otherFiles.length > 0) { + UploadManager.addFiles({ + channelId, + draftType: DraftType.ChannelMessage, + files: otherFiles.map(file => ({ file, platform: 1 })), + showLargeMessageDialog: false, + }); + createUploadCancelCard(channelId, otherFiles.length); + } + return; + } + + const results = await Promise.all(compressibleFiles.map(file => processFile(file))); + + 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 toUpload = [...successful.map(r => r.file), ...otherFiles]; + + if (cancelled.length === results.length) { + showToast("Compression cancelled", Toasts.Type.MESSAGE); + return; + } + + 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}` : "", + successful.length > 0 ? `Encoder: ${Array.from(new Set(successful.map(r => r.encoderUsed))).join(", ")}` : "", + successful.length > 0 + ? `Changes:\n${successful.map(r => `${r.file.name}: ${formatSize(r.originalSizeMB)} -> ${formatSize(r.sizeMB)} (${formatPercentChange(r.originalSizeMB, r.sizeMB)})`).join("\n")}` + : "", + ].filter(Boolean); + + if (toUpload.length > 0) { + UploadManager.addFiles({ + channelId, + draftType: DraftType.ChannelMessage, + files: toUpload.map(file => ({ file, platform: 1 })), + showLargeMessageDialog: false, + }); + createUploadCancelCard(channelId, toUpload.length); + } + + showNotification({ + title: "AutoCompress", + body: messageParts.join("\n"), + color: failed.length === 0 ? "#43b581" : successful.length === 0 ? "#f04747" : "#faa61a", + noPersist: false, + }); +} + +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 processFile(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, + settings.store.compressionTarget, + settings.store.compressionPreset, + settings.store.maxResolution, + settings.store.ffmpegTimeout * 1000, + ); + + if (!res.success) { + if (res.cancelled) { + return { success: false, fileName: file.name, error: "cancelled", cancelled: true }; + } + + return { success: false, fileName: file.name, error: res.error }; + } + + outPath = res.outPath; + const bytes = await Native.readFileBytes(outPath); + const compressedFile = new File([bytes], file.name, { type: file.type }); + return { + success: true, + file: compressedFile, + originalSizeMB: file.size / (1024 * 1024), + sizeMB: bytes.byteLength / (1024 * 1024), + encoderUsed: res.encoderUsed, + }; + } catch (err) { + if ((err as { cancelled?: boolean; })?.cancelled) { + return { success: false, fileName: file.name, error: "cancelled", cancelled: true }; + } + + return { + success: false, + 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 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 }); + document.removeEventListener("paste", hookPaste, { capture: true }); + document.getElementById(OVERLAY_ID)?.remove(); + validationCache = null; + }, +}); diff --git a/native.ts b/native.ts new file mode 100644 index 0000000..9fa9a87 --- /dev/null +++ b/native.ts @@ -0,0 +1,659 @@ +/* + * 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, 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(); + +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/"); +} + +export async function openTempFile(_: IpcMainInvokeEvent, fileName: string): Promise { + const ext = path.extname(fileName) || ".tmp"; + const tempPath = path.join(os.tmpdir(), `ac_in_${Date.now()}${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, + timeout: number, +): Promise { + const preferredExt = isAudioMime(mimeType) ? ".m4a" : ".mp4"; + const fileExt = path.extname(fileName) || preferredExt; + const outPath = path.join(os.tmpdir(), `ac_out_${Date.now()}${fileExt}`); + + try { + const duration = await getMediaDuration(filePath); + + const audioBitrate = 128; + const targetBits = target * 8 * 1024 * 1024; + const audioBits = audioBitrate * 1000 * duration; + const hasVideo = !isAudioMime(mimeType); + const videoBitrate = hasVideo + ? Math.floor((targetBits - audioBits) / duration / 1000) + : 0; + + if (hasVideo && videoBitrate < 100) { + return { success: false, error: `target bitrate too low (${videoBitrate}k)` }; + } + + 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 resolutionAttempts = resolution === "original" && encoder !== "libx264" + ? ["original", "1080"] + : [resolution]; + + for (const resolutionAttempt of resolutionAttempts) { + await unlink(outPath).catch(() => {}); + onProgress(0); + + try { + await compressVideo( + filePath, + outPath, + videoBitrate, + audioBitrate, + preset, + resolutionAttempt, + timeout, + encoder, + duration, + onProgress, + registerJob, + () => cancelledJobs.has(jobId), + ); + encoderUsed = encoder; + break encoderLoop; + } 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"}`); + } + } + } + + if (!encoderUsed) { + return { + success: false, + error: `all encoders failed:\n${errors.join("\n\n")}`, + }; + } + + activeJobs.delete(jobId); + return { success: true, outPath, encoderUsed }; + } else { + await compressAudio( + filePath, + outPath, + audioBitrate, + timeout, + duration, + onProgress, + registerJob, + () => cancelledJobs.has(jobId), + ); + + 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 buildScaleFilter(maxResolution: string): string | null { + 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): string | null { + const scaleFilter = buildScaleFilter(maxResolution); + if (scaleFilter) return `${scaleFilter},format=yuv420p`; + + return null; +} + +function compressVideo( + inputPath: string, + outputPath: string, + vidBitrate: number, + audioBitrate: number, + preset: string, + maxResolution: string, + ffmpegTimeout: number, + encoder: VideoEncoder, + 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, + "-map", "0:v:0", + "-map", "0:a?", + ...buildEncoderArgs(encoder, vidBitrate, preset), + ]; + + const filter = buildVideoFilter(maxResolution); + if (filter) { + args.push("-vf", filter); + } + + args.push( + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", `${audioBitrate}k`, + "-map_metadata", "0", + "-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`, + "-map_metadata", "0", + "-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..df8870c --- /dev/null +++ b/resolve.ts @@ -0,0 +1,330 @@ +/* + * 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"); + + console.log("[AutoCompress] starting encoder detection, ffmpeg:", ffmpegPath); + + const vendorEncoder = await getVendorEncoder(); + console.log("[AutoCompress] vendor encoder candidate:", vendorEncoder); + + if (vendorEncoder) { + console.log("[AutoCompress] test-encoding with", vendorEncoder, "..."); + const works = await testEncode(ffmpegPath, vendorEncoder); + console.log("[AutoCompress]", vendorEncoder, "test result:", works); + if (works) { + cachedEncoder = vendorEncoder; + console.log("[AutoCompress] selected encoder:", cachedEncoder); + return cachedEncoder; + } + } + + for (const encoder of GPU_ENCODERS) { + if (encoder === vendorEncoder) continue; + console.log("[AutoCompress] trying fallback encoder:", encoder); + const works = await testEncode(ffmpegPath, encoder); + console.log("[AutoCompress]", encoder, "test result:", works); + if (works) { + cachedEncoder = encoder; + console.log("[AutoCompress] selected encoder:", cachedEncoder); + return cachedEncoder; + } + } + + cachedEncoder = "libx264"; + console.log("[AutoCompress] no GPU encoder worked, falling back to:", cachedEncoder); + 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`); +} From 869c489b9b1a1888718f6310ddb4f537fb3f52ed Mon Sep 17 00:00:00 2001 From: deanxbox Date: Sat, 2 May 2026 14:39:51 +0100 Subject: [PATCH 2/5] feat: add image compression and size units - Support JPEG, PNG, and WebP compression in-browser - Add KB/MB/GB controls for compression target and threshold - Improve upload cancellation tracking for pending uploads - Prevent duplicate handling of paste/drop batches - Update progress overlay positioning and sizing - Refresh README notes for image support and size units --- README.md | 4 +- index.ts | 461 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 424 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index bb1070b..cf21e08 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 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. @@ -17,7 +17,9 @@ ## 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 - ensure you set a realistic time limit - lower resolution scaling can help encoding speed & artifacting - GPU encoders are preferred when available (`h264_nvenc`, `h264_amf`, `h264_qsv`, or `h264_videotoolbox`), with software `libx264` as the final fallback - 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/index.ts b/index.ts index 8db4f1f..2d15223 100644 --- a/index.ts +++ b/index.ts @@ -9,10 +9,16 @@ import { definePluginSettings } from "@api/Settings"; import definePlugin, { OptionType, PluginNative } from "@utils/types"; import { DraftType, + React, + Select, SelectedChannelStore, showToast, + Text, + TextInput, Toasts, + UploadAttachmentStore, UploadManager, + useState, } from "@webpack/common"; type ProcessResult = @@ -23,7 +29,7 @@ const Native = VencordNative.pluginHelpers.AutoCompress as PluginNative< typeof import("./native") >; -const FORMATS = new Set([ +const MEDIA_FORMATS = new Set([ "video/mp4", "video/quicktime", "video/x-msvideo", @@ -34,7 +40,134 @@ const FORMATS = new Set([ "audio/flac", ]); +const IMAGE_FORMATS = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + const CHUNK_SIZE = 4 * 1024 * 1024; +const DUPLICATE_BATCH_MS = 1500; + +type CompressionKind = "media" | "image"; +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" }, +]; + +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"; + + const rounded = Math.round(value * 1000) / 1000; + return Number.isInteger(rounded) ? String(rounded) : String(rounded); +} + +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, + }), + ), + ), + ); +} const settings = definePluginSettings({ ffmpegTimeout: { @@ -53,14 +186,26 @@ const settings = definePluginSettings({ default: "", }, compressionTarget: { - type: OptionType.NUMBER, - description: "File size to target with compression [MB]", - default: 9, + 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.NUMBER, - description: "Maximum file size before compression is used [MB]", - default: 10, + 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, + }), }, compressionPreset: { type: OptionType.SELECT, @@ -93,6 +238,7 @@ interface ProgressEstimate { } const progressEstimates = new Map(); +const uploadWatchers = new Map>(); function getOrCreateOverlay(): HTMLElement { let el = document.getElementById(OVERLAY_ID); @@ -101,20 +247,23 @@ function getOrCreateOverlay(): HTMLElement { el.id = OVERLAY_ID; Object.assign(el.style, { position: "fixed", - bottom: "60px", - right: "16px", + 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): HTMLElement { +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, { @@ -122,8 +271,7 @@ function createProgressCard(jobId: string, fileName: string, onCancel: () => voi border: "1px solid var(--background-modifier-accent, #4f545c)", borderRadius: "8px", padding: "10px 12px", - minWidth: "260px", - maxWidth: "320px", + width: "min(420px, calc(100vw - 32px))", pointerEvents: "all", boxShadow: "0 4px 16px rgba(0,0,0,0.4)", }); @@ -137,7 +285,7 @@ function createProgressCard(jobId: string, fileName: string, onCancel: () => voi }); 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:200px;"; + 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; @@ -152,7 +300,7 @@ function createProgressCard(jobId: string, fileName: string, onCancel: () => voi padding: "0 0 0 8px", lineHeight: "1", }); - cancelBtn.title = "Cancel compression"; + cancelBtn.title = cancelTitle; cancelBtn.onclick = () => { onCancel(); cancelBtn.disabled = true; @@ -195,7 +343,38 @@ function createProgressCard(jobId: string, fileName: string, onCancel: () => voi 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 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`; @@ -254,6 +433,11 @@ 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(); } @@ -262,25 +446,85 @@ function isValid(files: FileList | undefined): files is FileList { return files !== undefined && files.length > 0; } +function getCompressionKind(file: File): CompressionKind | null { + if (MEDIA_FORMATS.has(file.type)) return "media"; + if (IMAGE_FORMATS.has(file.type)) return "image"; + return null; +} + +function shouldCompressFile(file: File): boolean { + return getCompressionKind(file) !== null + && file.size > getCompressionThresholdBytes(); +} + +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 makeCancelledError(): Error & { cancelled: true; } { const err = new Error("cancelled") as Error & { cancelled: true; }; err.cancelled = true; return err; } -function createUploadCancelCard(channelId: string, fileCount: number) { +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 createUploadCancelCard(channelId: string, files: File[]) { const jobId = `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`; - createProgressCard(jobId, `${fileCount} pending upload${fileCount === 1 ? "" : "s"}`, () => { + 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"); - setTimeout(() => removeProgressCard(jobId), 30_000); + 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() ?? ""; @@ -313,38 +557,40 @@ async function validateBinaries(): Promise { async function hookPaste(event: ClipboardEvent) { const files = event.clipboardData?.files; - if (!isValid(files) || !(await validateBinaries())) return; + if (!isValid(files)) return; + + const allFiles = Array.from(files); + if (!allFiles.some(shouldCompressFile)) return; + if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; + event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - await handleFiles(files); + if (shouldSkipDuplicateBatch(allFiles)) return; + await handleFiles(allFiles); } async function hookDrop(event: DragEvent) { const files = event.dataTransfer?.files; - if (!isValid(files) || !(await validateBinaries())) return; + if (!isValid(files)) return; + + const allFiles = Array.from(files); + if (!allFiles.some(shouldCompressFile)) return; + if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; + event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - await handleFiles(files); + if (shouldSkipDuplicateBatch(allFiles)) return; + await handleFiles(allFiles); } -function hookDrag(event: DragEvent) { - const types = event.dataTransfer?.types; - if (types?.includes("Files")) { - event.preventDefault(); - event.stopPropagation(); - } -} - -async function handleFiles(files: FileList) { - const allFiles = Array.from(files); +async function handleFiles(allFiles: File[]) { 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) { + if (shouldCompressFile(file)) { compressibleFiles.push(file); } else { otherFiles.push(file); @@ -362,7 +608,7 @@ async function handleFiles(files: FileList) { files: otherFiles.map(file => ({ file, platform: 1 })), showLargeMessageDialog: false, }); - createUploadCancelCard(channelId, otherFiles.length); + createUploadCancelCard(channelId, otherFiles); } return; } @@ -396,7 +642,7 @@ async function handleFiles(files: FileList) { files: toUpload.map(file => ({ file, platform: 1 })), showLargeMessageDialog: false, }); - createUploadCancelCard(channelId, toUpload.length); + createUploadCancelCard(channelId, toUpload); } showNotification({ @@ -451,7 +697,140 @@ async function resolveInputPath( 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 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); + }); +} + +function replaceExtension(fileName: string, extension: string): string { + return fileName.includes(".") + ? fileName.replace(/\.[^/.]+$/, extension) + : `${fileName}${extension}`; +} + +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 outputType = file.type === "image/jpeg" ? "image/jpeg" : "image/webp"; + const targetBytes = getCompressionTargetBytes(); + let low = 0.35; + let high = 0.92; + let bestBlob = await canvasToBlob(canvas, outputType, high); + + for (let i = 0; i < 7; i++) { + if (cancelled) throw makeCancelledError(); + + const quality = (low + high) / 2; + const blob = await canvasToBlob(canvas, outputType, quality); + updateProgressCard(jobId, 15 + Math.round(((i + 1) / 7) * 80), "Compressing image..."); + + if (blob.size <= targetBytes) { + bestBlob = blob; + low = quality; + } else { + high = quality; + } + } + + if (bestBlob.size >= file.size) { + throw new Error("image compression did not reduce file size"); + } + + updateProgressCard(jobId, 100, "100%"); + const outputName = outputType === file.type ? file.name : replaceExtension(file.name, ".webp"); + const compressedFile = new File([bestBlob], outputName, { type: outputType }); + + return { + success: true, + file: compressedFile, + originalSizeMB: file.size / (1024 * 1024), + sizeMB: bestBlob.size / (1024 * 1024), + encoderUsed: outputType === "image/jpeg" ? "canvas-jpeg" : "canvas-webp", + }; + } catch (err) { + if ((err as { cancelled?: boolean; })?.cancelled) { + return { success: false, fileName: file.name, error: "cancelled", cancelled: true }; + } + + return { + success: false, + 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; @@ -484,7 +863,7 @@ async function processFile(file: File): Promise { inputPath, file.name, file.type, - settings.store.compressionTarget, + getCompressionTargetMB(), settings.store.compressionPreset, settings.store.maxResolution, settings.store.ffmpegTimeout * 1000, @@ -529,21 +908,23 @@ async function processFile(file: File): Promise { export default definePlugin({ name: "AutoCompress", - description: "Automatically compress videos/audio to reach a target size", + description: "Automatically compress videos, audio, and images 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 }); 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; }, }); From e34be8940c237edd7ece2440a306265b1c4f9ee8 Mon Sep 17 00:00:00 2001 From: deanxbox Date: Sun, 3 May 2026 22:55:10 +0100 Subject: [PATCH 3/5] feat: added advanced compression controls and diagnostics Add compression mode, failure fallback, original fallback, and concurrency settings Support custom image/video dimensions and optional hardware decoding Add diagnostics report button for ffmpeg/GPU troubleshooting Show post-compression previews and improve upload cancellation handling Document new settings and behavior in README --- README.md | 9 +- index.ts | 429 ++++++++++++++++++++++++++++++++++++++++++++++++++---- native.ts | 42 +++++- 3 files changed, 447 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index cf21e08..28ede4d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ - 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 +- diagnostics button in settings for ffmpeg/GPU encoder troubleshooting ## How It Works - works via reencoding media with ffmpeg @@ -18,6 +22,9 @@ - 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 +- 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 - GPU encoders are preferred when available (`h264_nvenc`, `h264_amf`, `h264_qsv`, or `h264_videotoolbox`), with software `libx264` as the final fallback diff --git a/index.ts b/index.ts index 2d15223..9ca9f67 100644 --- a/index.ts +++ b/index.ts @@ -22,8 +22,8 @@ import { } from "@webpack/common"; type ProcessResult = - | { success: true; file: File; originalSizeMB: number; sizeMB: number; encoderUsed: string; } - | { success: false; fileName: string; error: string; cancelled?: true; }; + | { 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") @@ -50,6 +50,8 @@ 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; }; @@ -65,6 +67,24 @@ const SIZE_UNIT_OPTIONS = [ { 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; }; @@ -169,7 +189,96 @@ function SizeSettingControl({ ); } +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, + 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, + }, ffmpegTimeout: { type: OptionType.NUMBER, description: "Duration per file before compression is aborted [seconds]", @@ -207,6 +316,16 @@ const settings = definePluginSettings({ setValue: props.setValue, }), }, + useOriginalIfWorse: { + type: OptionType.BOOLEAN, + description: "Upload the original file if compression makes it larger", + 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)", @@ -228,6 +347,35 @@ const settings = definePluginSettings({ { 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"; @@ -369,6 +517,36 @@ 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; @@ -380,6 +558,10 @@ function formatSize(sizeMB: number): string { : `${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%"; @@ -387,6 +569,29 @@ function formatPercentChange(originalSizeMB: number, sizeMB: number): string { 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)})`; +} + +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"; @@ -442,6 +647,36 @@ function removeProgressCard(jobId: string) { 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; } @@ -453,8 +688,13 @@ function getCompressionKind(file: File): CompressionKind | null { } function shouldCompressFile(file: File): boolean { - return getCompressionKind(file) !== null - && file.size > getCompressionThresholdBytes(); + const kind = getCompressionKind(file); + const mode = getCompressionMode(); + + if (kind === null || mode === "off") return false; + if (mode !== "auto" && kind !== mode) return false; + + return file.size > getCompressionThresholdBytes(); } function getBatchKey(files: File[]): string { @@ -476,6 +716,10 @@ function shouldSkipDuplicateBatch(files: File[]): boolean { 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; @@ -490,6 +734,22 @@ 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)); @@ -561,31 +821,57 @@ async function hookPaste(event: ClipboardEvent) { const allFiles = Array.from(files); if (!allFiles.some(shouldCompressFile)) return; - if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; + debugLog("paste intercepted", allFiles.map(file => ({ name: file.name, size: file.size, type: file.type, shouldCompress: shouldCompressFile(file) }))); event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); if (shouldSkipDuplicateBatch(allFiles)) return; + if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; + await handleFiles(allFiles); } +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(shouldCompressFile)) return; - if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; + debugLog("drop intercepted", allFiles.map(file => ({ name: file.name, size: file.size, type: file.type, shouldCompress: shouldCompressFile(file) }))); event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); if (shouldSkipDuplicateBatch(allFiles)) return; + if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; + await handleFiles(allFiles); } async function handleFiles(allFiles: File[]) { + try { + return await doHandleFiles(allFiles); + } catch (err) { + showNotification({ + title: "AutoCompress", + body: `Unexpected error: ${err instanceof Error ? err.message : String(err)}`, + color: "#f04747", + noPersist: false, + }); + } +} + +async function doHandleFiles(allFiles: File[]) { const compressibleFiles: File[] = []; const otherFiles: File[] = []; @@ -600,48 +886,76 @@ async function handleFiles(allFiles: 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) { - UploadManager.addFiles({ - channelId, - draftType: DraftType.ChannelMessage, - files: otherFiles.map(file => ({ file, platform: 1 })), - showLargeMessageDialog: false, - }); + await addFilesToUploadManager(channelId, otherFiles); createUploadCancelCard(channelId, otherFiles); } return; } - const results = await Promise.all(compressibleFiles.map(file => processFile(file))); + 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 toUpload = [...successful.map(r => r.file), ...otherFiles]; + const fallbackFiles = getFailedFileBehavior() === "upload-original" + ? failed.map(r => r.file).filter(file => file.size <= getCompressionTargetBytes()) + : []; + const skippedOversizedFallbacks = getFailedFileBehavior() === "upload-original" + ? failed.filter(r => r.file.size > getCompressionTargetBytes()) + : []; + 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`), + ...skippedOversizedFallbacks.map(file => `${file.fileName}: skipped original because it is above target`), + ]; + 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)` : "", + skippedOversizedFallbacks.length > 0 ? `Skipped oversized originals: ${skippedOversizedFallbacks.length}` : "", successful.length > 0 ? `Encoder: ${Array.from(new Set(successful.map(r => r.encoderUsed))).join(", ")}` : "", successful.length > 0 - ? `Changes:\n${successful.map(r => `${r.file.name}: ${formatSize(r.originalSizeMB)} -> ${formatSize(r.sizeMB)} (${formatPercentChange(r.originalSizeMB, r.sizeMB)})`).join("\n")}` + ? `Changes:\n${successful.map(formatResultLine).join("\n")}` : "", ].filter(Boolean); if (toUpload.length > 0) { - UploadManager.addFiles({ - channelId, - draftType: DraftType.ChannelMessage, - files: toUpload.map(file => ({ file, platform: 1 })), - showLargeMessageDialog: false, - }); + await addFilesToUploadManager(channelId, toUpload); createUploadCancelCard(channelId, toUpload); } @@ -653,6 +967,22 @@ async function handleFiles(allFiles: File[]) { }); } +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, @@ -715,6 +1045,20 @@ async function loadImage(file: File): Promise { } 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 }, @@ -795,9 +1139,16 @@ async function processImageFile(file: File): Promise { } 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) { + throw new Error(`compressed image is ${formatBytes(bestBlob.size)}, above target ${formatBytes(targetBytes)}`); + } + updateProgressCard(jobId, 100, "100%"); const outputName = outputType === file.type ? file.name : replaceExtension(file.name, ".webp"); const compressedFile = new File([bestBlob], outputName, { type: outputType }); @@ -805,17 +1156,20 @@ async function processImageFile(file: File): Promise { 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", }; } catch (err) { if ((err as { cancelled?: boolean; })?.cancelled) { - return { success: false, fileName: file.name, error: "cancelled", cancelled: true }; + 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), }; @@ -866,34 +1220,57 @@ async function processMediaFile(file: File): Promise { 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, fileName: file.name, error: "cancelled", cancelled: true }; + return { success: false, file, fileName: file.name, error: "cancelled", cancelled: true }; } - return { success: false, fileName: file.name, error: res.error }; + 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) { + return { + success: false, + file, + fileName: file.name, + error: `compressed file is ${formatBytes(bytes.byteLength)}, above target ${formatBytes(targetBytes)}`, + }; + } + const compressedFile = new File([bytes], file.name, { type: file.type }); return { success: true, file: compressedFile, + originalFileName: file.name, originalSizeMB: file.size / (1024 * 1024), sizeMB: bytes.byteLength / (1024 * 1024), encoderUsed: res.encoderUsed, + output: "compressed", }; } catch (err) { if ((err as { cancelled?: boolean; })?.cancelled) { - return { success: false, fileName: file.name, error: "cancelled", cancelled: true }; + 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), }; @@ -913,11 +1290,13 @@ export default definePlugin({ 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(); diff --git a/native.ts b/native.ts index 9fa9a87..8f09837 100644 --- a/native.ts +++ b/native.ts @@ -126,7 +126,7 @@ function isAudioMime(mimeType: string): boolean { export async function openTempFile(_: IpcMainInvokeEvent, fileName: string): Promise { const ext = path.extname(fileName) || ".tmp"; - const tempPath = path.join(os.tmpdir(), `ac_in_${Date.now()}${ext}`); + const tempPath = path.join(os.tmpdir(), `ac_in_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`); openStreams.set(tempPath, createWriteStream(tempPath)); return tempPath; } @@ -272,11 +272,14 @@ export async function handleFile( target: number, preset: string, resolution: string, + maxWidth: number, + maxHeight: number, + useHardwareDecode: boolean, timeout: number, ): Promise { const preferredExt = isAudioMime(mimeType) ? ".m4a" : ".mp4"; const fileExt = path.extname(fileName) || preferredExt; - const outPath = path.join(os.tmpdir(), `ac_out_${Date.now()}${fileExt}`); + const outPath = path.join(os.tmpdir(), `ac_out_${jobId}${fileExt}`); try { const duration = await getMediaDuration(filePath); @@ -310,7 +313,8 @@ export async function handleFile( throw err; } - const resolutionAttempts = resolution === "original" && encoder !== "libx264" + const hasCustomDimensions = getPositiveDimension(maxWidth) > 0 || getPositiveDimension(maxHeight) > 0; + const resolutionAttempts = resolution === "original" && !hasCustomDimensions && encoder !== "libx264" ? ["original", "1080"] : [resolution]; @@ -326,6 +330,9 @@ export async function handleFile( audioBitrate, preset, resolutionAttempt, + maxWidth, + maxHeight, + useHardwareDecode, timeout, encoder, duration, @@ -463,7 +470,23 @@ function parseProgressPercent(line: string, totalDuration: number): number | nul return null; } -function buildScaleFilter(maxResolution: string): string | 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'", @@ -473,8 +496,8 @@ function buildScaleFilter(maxResolution: string): string | null { return resolutionMap[maxResolution] ?? null; } -function buildVideoFilter(maxResolution: string): string | null { - const scaleFilter = buildScaleFilter(maxResolution); +function buildVideoFilter(maxResolution: string, maxWidth: number, maxHeight: number): string | null { + const scaleFilter = buildScaleFilter(maxResolution, maxWidth, maxHeight); if (scaleFilter) return `${scaleFilter},format=yuv420p`; return null; @@ -487,6 +510,9 @@ function compressVideo( audioBitrate: number, preset: string, maxResolution: string, + maxWidth: number, + maxHeight: number, + useHardwareDecode: boolean, ffmpegTimeout: number, encoder: VideoEncoder, duration: number, @@ -495,16 +521,18 @@ function compressVideo( 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?", ...buildEncoderArgs(encoder, vidBitrate, preset), ]; - const filter = buildVideoFilter(maxResolution); if (filter) { args.push("-vf", filter); } From f8494b0a520f31c787f0d237dffe490b70a52270 Mon Sep 17 00:00:00 2001 From: deanxbox Date: Mon, 4 May 2026 20:01:19 +0100 Subject: [PATCH 4/5] feat: allow uploading compressed files above target Add an uploadIfSmaller setting to keep compressed output when it is smaller than the original, even if it exceeds the configured target. Also improve result notes for above-target outputs, upload original fallbacks regardless of target size, tune media bitrate handling, clear progress estimates on stop, and remove noisy encoder detection logs. --- index.ts | 33 +++++++++++++++++++-------------- native.ts | 22 +++++++++------------- resolve.ts | 11 ----------- 3 files changed, 28 insertions(+), 38 deletions(-) diff --git a/index.ts b/index.ts index 9ca9f67..e1dd5cb 100644 --- a/index.ts +++ b/index.ts @@ -104,8 +104,7 @@ function normalizeSizeSetting(raw: unknown, fallbackValue: number, fallbackUnit: function formatSettingValue(value: number): string { if (!Number.isFinite(value)) return "0"; - const rounded = Math.round(value * 1000) / 1000; - return Number.isInteger(rounded) ? String(rounded) : String(rounded); + return String(Math.round(value * 1000) / 1000); } function convertSizeValue(value: number, fromUnit: SizeUnit, toUnit: SizeUnit): number { @@ -321,6 +320,11 @@ const settings = definePluginSettings({ 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", @@ -574,7 +578,7 @@ function formatResultLine(result: Extract): s 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)})`; + 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 { @@ -909,10 +913,7 @@ async function doHandleFiles(allFiles: File[]) { 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).filter(file => file.size <= getCompressionTargetBytes()) - : []; - const skippedOversizedFallbacks = getFailedFileBehavior() === "upload-original" - ? failed.filter(r => r.file.size > getCompressionTargetBytes()) + ? failed.map(r => r.file) : []; const toUpload = [...successful.map(r => r.file), ...fallbackFiles, ...otherFiles]; @@ -938,7 +939,6 @@ async function doHandleFiles(allFiles: File[]) { const previewLines = [ ...successful.map(formatResultLine), ...fallbackFiles.map(file => `${file.name}: uploaded original after compression failed`), - ...skippedOversizedFallbacks.map(file => `${file.fileName}: skipped original because it is above target`), ]; createPostCompressionPreview(previewLines, failed.length === 0 ? "#43b581" : "#faa61a"); @@ -947,7 +947,6 @@ async function doHandleFiles(allFiles: File[]) { 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)` : "", - skippedOversizedFallbacks.length > 0 ? `Skipped oversized originals: ${skippedOversizedFallbacks.length}` : "", successful.length > 0 ? `Encoder: ${Array.from(new Set(successful.map(r => r.encoderUsed))).join(", ")}` : "", successful.length > 0 ? `Changes:\n${successful.map(formatResultLine).join("\n")}` @@ -1119,9 +1118,10 @@ async function processImageFile(file: File): Promise { const outputType = file.type === "image/jpeg" ? "image/jpeg" : "image/webp"; const targetBytes = getCompressionTargetBytes(); - let low = 0.35; + let low = 0; let high = 0.92; - let bestBlob = await canvasToBlob(canvas, outputType, high); + let bestWithinTarget: Blob | null = null; + const lowestQualityBlob = await canvasToBlob(canvas, outputType, 0); for (let i = 0; i < 7; i++) { if (cancelled) throw makeCancelledError(); @@ -1131,13 +1131,15 @@ async function processImageFile(file: File): Promise { updateProgressCard(jobId, 15 + Math.round(((i + 1) / 7) * 80), "Compressing image..."); if (blob.size <= targetBytes) { - bestBlob = blob; + bestWithinTarget = blob; low = quality; } else { high = quality; } } + const bestBlob = bestWithinTarget ?? lowestQualityBlob; + if (bestBlob.size >= file.size) { const fallback = makeOriginalFallbackResult(file, "compression was larger"); if (fallback) return fallback; @@ -1145,7 +1147,7 @@ async function processImageFile(file: File): Promise { throw new Error("image compression did not reduce file size"); } - if (bestBlob.size > targetBytes) { + if (bestBlob.size > targetBytes && !settings.store.uploadIfSmaller) { throw new Error(`compressed image is ${formatBytes(bestBlob.size)}, above target ${formatBytes(targetBytes)}`); } @@ -1161,6 +1163,7 @@ async function processImageFile(file: File): Promise { 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) { @@ -1244,7 +1247,7 @@ async function processMediaFile(file: File): Promise { } const targetBytes = getCompressionTargetBytes(); - if (bytes.byteLength > targetBytes) { + if (bytes.byteLength > targetBytes && !settings.store.uploadIfSmaller) { return { success: false, file, @@ -1262,6 +1265,7 @@ async function processMediaFile(file: File): Promise { 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) { @@ -1305,5 +1309,6 @@ export default definePlugin({ validationCache = null; lastHandledBatchKey = ""; lastHandledBatchAt = 0; + progressEstimates.clear(); }, }); diff --git a/native.ts b/native.ts index 8f09837..c22def7 100644 --- a/native.ts +++ b/native.ts @@ -278,23 +278,22 @@ export async function handleFile( timeout: number, ): Promise { const preferredExt = isAudioMime(mimeType) ? ".m4a" : ".mp4"; - const fileExt = path.extname(fileName) || preferredExt; - const outPath = path.join(os.tmpdir(), `ac_out_${jobId}${fileExt}`); + const outPath = path.join(os.tmpdir(), `ac_out_${jobId}${preferredExt}`); try { const duration = await getMediaDuration(filePath); - const audioBitrate = 128; const targetBits = target * 8 * 1024 * 1024; - const audioBits = audioBitrate * 1000 * duration; const hasVideo = !isAudioMime(mimeType); - const videoBitrate = hasVideo + const rawAudioBitrateKbps = Math.floor((target * 8 * 1024) / duration); + const audioBitrate = hasVideo + ? 128 + : Math.min(192, Math.max(64, rawAudioBitrateKbps)); + const audioBits = audioBitrate * 1000 * duration; + const rawVideoBitrate = hasVideo ? Math.floor((targetBits - audioBits) / duration / 1000) : 0; - - if (hasVideo && videoBitrate < 100) { - return { success: false, error: `target bitrate too low (${videoBitrate}k)` }; - } + const videoBitrate = Math.max(100, rawVideoBitrate); const onProgress = (percent: number) => progressMap.set(jobId, percent); const registerJob = (proc: ChildProcess) => activeJobs.set(jobId, proc); @@ -497,10 +496,7 @@ function buildScaleFilter(maxResolution: string, maxWidth: number, maxHeight: nu } function buildVideoFilter(maxResolution: string, maxWidth: number, maxHeight: number): string | null { - const scaleFilter = buildScaleFilter(maxResolution, maxWidth, maxHeight); - if (scaleFilter) return `${scaleFilter},format=yuv420p`; - - return null; + return buildScaleFilter(maxResolution, maxWidth, maxHeight); } function compressVideo( diff --git a/resolve.ts b/resolve.ts index df8870c..0061520 100644 --- a/resolve.ts +++ b/resolve.ts @@ -143,37 +143,26 @@ export async function detectEncoder(): Promise { if (cachedEncoder !== null) return cachedEncoder; const ffmpegPath = pullBinary("ffmpeg"); - - console.log("[AutoCompress] starting encoder detection, ffmpeg:", ffmpegPath); - const vendorEncoder = await getVendorEncoder(); - console.log("[AutoCompress] vendor encoder candidate:", vendorEncoder); if (vendorEncoder) { - console.log("[AutoCompress] test-encoding with", vendorEncoder, "..."); const works = await testEncode(ffmpegPath, vendorEncoder); - console.log("[AutoCompress]", vendorEncoder, "test result:", works); if (works) { cachedEncoder = vendorEncoder; - console.log("[AutoCompress] selected encoder:", cachedEncoder); return cachedEncoder; } } for (const encoder of GPU_ENCODERS) { if (encoder === vendorEncoder) continue; - console.log("[AutoCompress] trying fallback encoder:", encoder); const works = await testEncode(ffmpegPath, encoder); - console.log("[AutoCompress]", encoder, "test result:", works); if (works) { cachedEncoder = encoder; - console.log("[AutoCompress] selected encoder:", cachedEncoder); return cachedEncoder; } } cachedEncoder = "libx264"; - console.log("[AutoCompress] no GPU encoder worked, falling back to:", cachedEncoder); return cachedEncoder; } From 54ec137d9b6fe9f25fa8fecbc7c86d259d5135c2 Mon Sep 17 00:00:00 2001 From: deanxbox Date: Mon, 11 May 2026 14:43:31 +0100 Subject: [PATCH 5/5] feat: add compression prompt and improve bitrate retry logic - Add optional "Prompt to compress after every media insertion" setting - Allow users to choose between compression and uploading originals per insertion - Improve format detection to check file extensions in addition to MIME types - Add bitrate scaling retry logic to get compressed files closer to target size - Refactor image compression into reusable compressCanvasToTarget function - Auto-select MP4 or M4A output format based on media type - Improve ffmpeg metadata handling with selective removal - Support GIF files as video media --- README.md | 4 + index.ts | 258 +++++++++++++++++++++++++++++++++++++++++++++--------- native.ts | 178 +++++++++++++++++++++++++------------ 3 files changed, 342 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 28ede4d..6df1671 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - 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 @@ -23,10 +24,13 @@ - 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 - 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/index.ts b/index.ts index e1dd5cb..36f4522 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ import { showNotification } from "@api/Notifications"; import { definePluginSettings } from "@api/Settings"; import definePlugin, { OptionType, PluginNative } from "@utils/types"; import { + Alerts, DraftType, React, Select, @@ -35,17 +36,44 @@ const MEDIA_FORMATS = new Set([ "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; @@ -204,6 +232,7 @@ function DiagnosticsControl() { validation, settings: { compressionMode: settings.store.compressionMode, + promptAfterInsertion: settings.store.promptAfterInsertion, compressionTarget: settings.store.compressionTarget, compressionThreshold: settings.store.compressionThreshold, compressionPreset: settings.store.compressionPreset, @@ -278,6 +307,11 @@ const settings = definePluginSettings({ 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]", @@ -685,20 +719,41 @@ 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 { - if (MEDIA_FORMATS.has(file.type)) return "media"; - if (IMAGE_FORMATS.has(file.type)) return "image"; + 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 shouldCompressFile(file: File): boolean { +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 file.size > getCompressionThresholdBytes(); + 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 { @@ -819,21 +874,81 @@ async function validateBinaries(): Promise { 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(shouldCompressFile)) return; + if (!allFiles.some(shouldInterceptInsertion)) return; - debugLog("paste intercepted", allFiles.map(file => ({ name: file.name, size: file.size, type: file.type, shouldCompress: shouldCompressFile(file) }))); event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - if (shouldSkipDuplicateBatch(allFiles)) return; - if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; - await handleFiles(allFiles); + await handleInsertedFiles(allFiles, "paste"); } function hookDragOver(event: DragEvent) { @@ -850,21 +965,18 @@ async function hookDrop(event: DragEvent) { if (!isValid(files)) return; const allFiles = Array.from(files); - if (!allFiles.some(shouldCompressFile)) return; + if (!allFiles.some(shouldInterceptInsertion)) return; - debugLog("drop intercepted", allFiles.map(file => ({ name: file.name, size: file.size, type: file.type, shouldCompress: shouldCompressFile(file) }))); event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - if (shouldSkipDuplicateBatch(allFiles)) return; - if (allFiles.some(file => shouldCompressFile(file) && getCompressionKind(file) === "media") && !(await validateBinaries())) return; - await handleFiles(allFiles); + await handleInsertedFiles(allFiles, "drop"); } -async function handleFiles(allFiles: File[]) { +async function handleFiles(allFiles: File[], forceCompression = false) { try { - return await doHandleFiles(allFiles); + return await doHandleFiles(allFiles, forceCompression); } catch (err) { showNotification({ title: "AutoCompress", @@ -875,12 +987,12 @@ async function handleFiles(allFiles: File[]) { } } -async function doHandleFiles(allFiles: File[]) { +async function doHandleFiles(allFiles: File[], forceCompression = false) { const compressibleFiles: File[] = []; const otherFiles: File[] = []; for (const file of allFiles) { - if (shouldCompressFile(file)) { + if (shouldCompressFile(file, forceCompression)) { compressibleFiles.push(file); } else { otherFiles.push(file); @@ -1086,12 +1198,88 @@ function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): }); } +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)}`; @@ -1116,29 +1304,15 @@ async function processImageFile(file: File): Promise { ctx.drawImage(image, 0, 0, dimensions.width, dimensions.height); - const outputType = file.type === "image/jpeg" ? "image/jpeg" : "image/webp"; const targetBytes = getCompressionTargetBytes(); - let low = 0; - let high = 0.92; - let bestWithinTarget: Blob | null = null; - const lowestQualityBlob = await canvasToBlob(canvas, outputType, 0); - - for (let i = 0; i < 7; i++) { - if (cancelled) throw makeCancelledError(); - - const quality = (low + high) / 2; - const blob = await canvasToBlob(canvas, outputType, quality); - updateProgressCard(jobId, 15 + Math.round(((i + 1) / 7) * 80), "Compressing image..."); - - if (blob.size <= targetBytes) { - bestWithinTarget = blob; - low = quality; - } else { - high = quality; - } - } - - const bestBlob = bestWithinTarget ?? lowestQualityBlob; + 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"); @@ -1152,6 +1326,7 @@ async function processImageFile(file: File): Promise { } 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 }); @@ -1256,7 +1431,8 @@ async function processMediaFile(file: File): Promise { }; } - const compressedFile = new File([bytes], file.name, { type: file.type }); + const output = getCompressedMediaOutput(file); + const compressedFile = new File([bytes], output.name, { type: output.type }); return { success: true, file: compressedFile, diff --git a/native.ts b/native.ts index c22def7..a56fa57 100644 --- a/native.ts +++ b/native.ts @@ -6,7 +6,7 @@ import { ChildProcess, spawn } from "node:child_process"; import { createWriteStream, WriteStream } from "node:fs"; -import { readFile, unlink } from "node:fs/promises"; +import { readFile, stat, unlink } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -30,6 +30,16 @@ 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 = { @@ -124,6 +134,33 @@ 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}`); @@ -277,23 +314,22 @@ export async function handleFile( useHardwareDecode: boolean, timeout: number, ): Promise { - const preferredExt = isAudioMime(mimeType) ? ".m4a" : ".mp4"; + 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 targetBits = target * 8 * 1024 * 1024; - const hasVideo = !isAudioMime(mimeType); - const rawAudioBitrateKbps = Math.floor((target * 8 * 1024) / duration); + 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 - ? 128 - : Math.min(192, Math.max(64, rawAudioBitrateKbps)); - const audioBits = audioBitrate * 1000 * duration; - const rawVideoBitrate = hasVideo - ? Math.floor((targetBits - audioBits) / duration / 1000) + ? getVideoAudioBitrate(totalBitrateKbps) + : getAudioOnlyBitrate(totalBitrateKbps); + const videoBitrate = hasVideo + ? Math.max(MIN_VIDEO_BITRATE_KBPS, totalBitrateKbps - audioBitrate) : 0; - const videoBitrate = Math.max(100, rawVideoBitrate); const onProgress = (percent: number) => progressMap.set(jobId, percent); const registerJob = (proc: ChildProcess) => activeJobs.set(jobId, proc); @@ -318,40 +354,53 @@ export async function handleFile( : [resolution]; for (const resolutionAttempt of resolutionAttempts) { - await unlink(outPath).catch(() => {}); - onProgress(0); - - try { - await compressVideo( - filePath, - outPath, - videoBitrate, - audioBitrate, - preset, - resolutionAttempt, - maxWidth, - maxHeight, - useHardwareDecode, - timeout, - encoder, - duration, - onProgress, - registerJob, - () => cancelledJobs.has(jobId), - ); - encoderUsed = encoder; - break encoderLoop; - } 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; + 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; } - - errors.push(`${encoder}${resolutionAttempt === resolution ? "" : ` (${resolutionAttempt}p retry)`}: ${maybeCancelled?.message || maybeCancelled?.toString() || "unknown error"}`); } } } @@ -366,16 +415,27 @@ export async function handleFile( activeJobs.delete(jobId); return { success: true, outPath, encoderUsed }; } else { - await compressAudio( - filePath, - outPath, - audioBitrate, - timeout, - duration, - onProgress, - registerJob, - () => cancelledJobs.has(jobId), - ); + 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" }; @@ -525,7 +585,7 @@ function compressVideo( ...(canUseHardwareDecode ? ["-hwaccel", "auto"] : []), "-i", inputPath, "-map", "0:v:0", - "-map", "0:a?", + "-map", "0:a:0?", ...buildEncoderArgs(encoder, vidBitrate, preset), ]; @@ -537,7 +597,9 @@ function compressVideo( "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", `${audioBitrate}k`, - "-map_metadata", "0", + "-ac", "2", + "-map_metadata", "-1", + "-map_chapters", "-1", "-movflags", "+faststart", "-progress", "pipe:1", "-nostats", @@ -619,7 +681,9 @@ function compressAudio( "-vn", "-c:a", "aac", "-b:a", `${audioBitrate}k`, - "-map_metadata", "0", + "-ac", "2", + "-map_metadata", "-1", + "-map_chapters", "-1", "-progress", "pipe:1", "-nostats", outputPath,