From 4d593cf710240c72b1138f796c6d3f855689ac31 Mon Sep 17 00:00:00 2001 From: Quadrinex <153446328+quadrinex@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:23:32 +0200 Subject: [PATCH] Fixing <#1513197912660906035> on my own plugin. (#1163) * Fixing <#1513197912660906035> on my own plugin. * #1163: Update src/equicordplugins/clipUpload.desktop/ffmpeg.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * #1163: Update src/equicordplugins/clipUpload.desktop/upload.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * #1163: Update src/equicordplugins/clipUpload.desktop/upload.ts * chore(clipUpload): document unavoidable path exposure Added documentation to in the main process and in the renderer. This clarifies that exposing the absolute filesystem path is necessary because Discord's is a renderer-only module and requires an absolute path to function. Co-authored-by: Gemini <198982749+gemini-code-assist@users.noreply.github.com> * Conflict Fix * [autofix.ci] apply automated fixes * Fix Missing upper-bound size check on renderer-supplied bytes --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Gemini <198982749+gemini-code-assist@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../clipUpload.desktop/ffmpeg.ts | 91 +++++++++ .../clipUpload.desktop/native.ts | 31 +++ .../clipUpload.desktop/upload.ts | 181 +++++++++++++----- 3 files changed, 259 insertions(+), 44 deletions(-) create mode 100644 src/equicordplugins/clipUpload.desktop/ffmpeg.ts diff --git a/src/equicordplugins/clipUpload.desktop/ffmpeg.ts b/src/equicordplugins/clipUpload.desktop/ffmpeg.ts new file mode 100644 index 000000000..d34f545d3 --- /dev/null +++ b/src/equicordplugins/clipUpload.desktop/ffmpeg.ts @@ -0,0 +1,91 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import { Logger } from "@utils/Logger"; + +const logger = new Logger("ClipUpload"); + +let ffmpeg: FFmpeg | null = null; +let ffmpegLoading: Promise | null = null; +let conversionCounter = 0; + +async function loadFFmpeg() { + if (ffmpeg?.loaded) return ffmpeg; + if (ffmpegLoading) return ffmpegLoading; + + ffmpegLoading = (async () => { + const instance = new FFmpeg(); + const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm"; + const classWorkerRaw = "/// \n/// \n/// \nconst MIME_TYPE_JAVASCRIPT = \"text/javascript\";\nconst MIME_TYPE_WASM = \"application/wasm\";\nconst CORE_VERSION = \"0.12.6\";\nconst CORE_URL = `https://cdn.jsdelivr.net/npm/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.js`;\nvar FFMessageType;\n(function(FFMessageType) {\n FFMessageType[\"LOAD\"] = \"LOAD\";\n FFMessageType[\"EXEC\"] = \"EXEC\";\n FFMessageType[\"WRITE_FILE\"] = \"WRITE_FILE\";\n FFMessageType[\"READ_FILE\"] = \"READ_FILE\";\n FFMessageType[\"DELETE_FILE\"] = \"DELETE_FILE\";\n FFMessageType[\"RENAME\"] = \"RENAME\";\n FFMessageType[\"CREATE_DIR\"] = \"CREATE_DIR\";\n FFMessageType[\"LIST_DIR\"] = \"LIST_DIR\";\n FFMessageType[\"DELETE_DIR\"] = \"DELETE_DIR\";\n FFMessageType[\"ERROR\"] = \"ERROR\";\n FFMessageType[\"DOWNLOAD\"] = \"DOWNLOAD\";\n FFMessageType[\"PROGRESS\"] = \"PROGRESS\";\n FFMessageType[\"LOG\"] = \"LOG\";\n FFMessageType[\"MOUNT\"] = \"MOUNT\";\n FFMessageType[\"UNMOUNT\"] = \"UNMOUNT\";\n})(FFMessageType || (FFMessageType = {}));\n\n\nconst ERROR_UNKNOWN_MESSAGE_TYPE = new Error(\"unknown message type\");\nconst ERROR_NOT_LOADED = new Error(\"ffmpeg is not loaded, call `await ffmpeg.load()` first\");\nconst ERROR_TERMINATED = new Error(\"called FFmpeg.terminate()\");\nconst ERROR_IMPORT_FAILURE = new Error(\"failed to import ffmpeg-core.js\");\n\nlet ffmpeg;\nconst load = async ({ coreURL: _coreURL, wasmURL: _wasmURL, workerURL: _workerURL, }) => {\n const first = !ffmpeg;\n try {\n if (!_coreURL)\n _coreURL = CORE_URL;\n importScripts(_coreURL);\n }\n catch {\n if (!_coreURL)\n _coreURL = CORE_URL.replace('/umd/', '/esm/');\n self.createFFmpegCore = (await import(\n /* webpackIgnore: true */ /* @vite-ignore */ _coreURL)).default;\n if (!self.createFFmpegCore) {\n throw ERROR_IMPORT_FAILURE;\n }\n }\n const coreURL = _coreURL;\n const wasmURL = _wasmURL ? _wasmURL : _coreURL.replace(/.js$/g, \".wasm\");\n const workerURL = _workerURL\n ? _workerURL\n : _coreURL.replace(/.js$/g, \".worker.js\");\n ffmpeg = await self.createFFmpegCore({\n mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL, workerURL }))}`,\n });\n ffmpeg.setLogger((data) => self.postMessage({ type: FFMessageType.LOG, data }));\n ffmpeg.setProgress((data) => self.postMessage({\n type: FFMessageType.PROGRESS,\n data,\n }));\n return first;\n};\nconst exec = ({ args, timeout = -1 }) => {\n ffmpeg.setTimeout(timeout);\n ffmpeg.exec(...args);\n const ret = ffmpeg.ret;\n ffmpeg.reset();\n return ret;\n};\nconst writeFile = ({ path, data }) => {\n ffmpeg.FS.writeFile(path, data);\n return true;\n};\nconst readFile = ({ path, encoding }) => ffmpeg.FS.readFile(path, { encoding });\nconst deleteFile = ({ path }) => {\n ffmpeg.FS.unlink(path);\n return true;\n};\nconst rename = ({ oldPath, newPath }) => {\n ffmpeg.FS.rename(oldPath, newPath);\n return true;\n};\nconst createDir = ({ path }) => {\n ffmpeg.FS.mkdir(path);\n return true;\n};\nconst listDir = ({ path }) => {\n const names = ffmpeg.FS.readdir(path);\n const nodes = [];\n for (const name of names) {\n const stat = ffmpeg.FS.stat(`${path}/${name}`);\n const isDir = ffmpeg.FS.isDir(stat.mode);\n nodes.push({ name, isDir });\n }\n return nodes;\n};\nconst deleteDir = ({ path }) => {\n ffmpeg.FS.rmdir(path);\n return true;\n};\nconst mount = ({ fsType, options, mountPoint }) => {\n const str = fsType;\n const fs = ffmpeg.FS.filesystems[str];\n if (!fs)\n return false;\n ffmpeg.FS.mount(fs, options, mountPoint);\n return true;\n};\nconst unmount = ({ mountPoint }) => {\n ffmpeg.FS.unmount(mountPoint);\n return true;\n};\nself.onmessage = async ({ data: { id, type, data: _data }, }) => {\n const trans = [];\n let data;\n try {\n if (type !== FFMessageType.LOAD && !ffmpeg)\n throw ERROR_NOT_LOADED;\n switch (type) {\n case FFMessageType.LOAD:\n data = await load(_data);\n break;\n case FFMessageType.EXEC:\n data = exec(_data);\n break;\n case FFMessageType.WRITE_FILE:\n data = writeFile(_data);\n break;\n case FFMessageType.READ_FILE:\n data = readFile(_data);\n break;\n case FFMessageType.DELETE_FILE:\n data = deleteFile(_data);\n break;\n case FFMessageType.RENAME:\n data = rename(_data);\n break;\n case FFMessageType.CREATE_DIR:\n data = createDir(_data);\n break;\n case FFMessageType.LIST_DIR:\n data = listDir(_data);\n break;\n case FFMessageType.DELETE_DIR:\n data = deleteDir(_data);\n break;\n case FFMessageType.MOUNT:\n data = mount(_data);\n break;\n case FFMessageType.UNMOUNT:\n data = unmount(_data);\n break;\n default:\n throw ERROR_UNKNOWN_MESSAGE_TYPE;\n }\n }\n catch (e) {\n self.postMessage({\n id,\n type: FFMessageType.ERROR,\n data: e.toString(),\n });\n return;\n }\n if (data instanceof Uint8Array) {\n trans.push(data.buffer);\n }\n self.postMessage({ id, type, data }, trans);\n};\n\n"; + const classWorkerBlob = new Blob([(new TextEncoder()).encode(classWorkerRaw)], { type: "text/javascript" }); + const classWorkerURL = URL.createObjectURL(classWorkerBlob); + + try { + await instance.load({ + coreURL: `${baseURL}/ffmpeg-core.js`, + wasmURL: `${baseURL}/ffmpeg-core.wasm`, + workerURL: `${baseURL}/ffmpeg-core.worker.js`, + classWorkerURL + }); + + ffmpeg = instance; + logger.info("FFmpeg loaded."); + return instance; + } catch (error) { + instance.terminate(); + ffmpeg = null; + throw error; + } finally { + URL.revokeObjectURL(classWorkerURL); + ffmpegLoading = null; + } + })(); + + return ffmpegLoading; +} + +function getInputName(fileName: string, id: number) { + return `input_${id}${fileName.match(/\.[a-z0-9]+$/i)?.[0].toLowerCase() ?? ".video"}`; +} + +export async function convertClipToMp4(file: File, fileName: string) { + const id = conversionCounter++; + const inputName = getInputName(file.name, id); + const outputName = `output_${id}.mp4`; + const ff = await loadFFmpeg(); + + try { + await ff.writeFile(inputName, new Uint8Array(await file.arrayBuffer())); + + const exitCode = await ff.exec([ + "-i", inputName, + "-map", "0:v:0", + "-map", "0:a:0?", + "-c:v", "libx264", + "-preset", "veryfast", + "-profile:v", "high", + "-level:v", "4.0", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "128k", + "-movflags", "+faststart", + outputName + ]); + + if (exitCode !== 0) throw new Error("Couldn't convert the selected file."); + + const data = await ff.readFile(outputName); + if (typeof data === "string") throw new Error("Couldn't read the converted file."); + + return new File([new Uint8Array(data)], fileName, { type: "video/mp4" }); + } finally { + await Promise.all([ + ff.deleteFile(inputName).catch(() => undefined), + ff.deleteFile(outputName).catch(() => undefined) + ]); + } +} diff --git a/src/equicordplugins/clipUpload.desktop/native.ts b/src/equicordplugins/clipUpload.desktop/native.ts index 152180922..df2f51758 100644 --- a/src/equicordplugins/clipUpload.desktop/native.ts +++ b/src/equicordplugins/clipUpload.desktop/native.ts @@ -28,6 +28,7 @@ const pendingTokens = new Map(); const tempEntries = new Map(); const CLIP_UPLOAD_DIR = join(DATA_DIR, "clipUpload"); const ALLOWED_EXTENSIONS = new Set([".mp4", ".m4v"]); +const MAX_CLIP_SIZE = 500 * 1024 * 1024; // 500MB const MIME_TYPES: Record = { ".mp4": "video/mp4", ".m4v": "video/mp4", @@ -109,6 +110,36 @@ export async function createTempVideoFile(_: IpcMainInvokeEvent, token: string): } } +export async function createTempVideoFileFromBytes(_: IpcMainInvokeEvent, name: string, data: Uint8Array): Promise { + if (typeof name !== "string" || !(data instanceof Uint8Array) || data.byteLength === 0 || data.byteLength > MAX_CLIP_SIZE) return null; + + const fileName = basename(name); + if (fileName !== name || !ALLOWED_EXTENSIONS.has(extname(fileName).toLowerCase())) return null; + + try { + const tmpDir = join(CLIP_UPLOAD_DIR, randomUUID()); + const tmpPath = join(tmpDir, fileName); + + if (!ensureSafePath(tmpDir, fileName)) return null; + + await mkdir(tmpDir, { recursive: true }); + await writeFile(tmpPath, data); + + const tmpToken = randomUUID(); + tempEntries.set(tmpToken, { tmpDir, tmpPath }); + return tmpToken; + } catch { + return null; + } +} + +// Note: Exposing the absolute path to the renderer is unavoidable here. +// Discord's MediaEngineStore is a renderer-only module, and its +// updateClipMetadata method requires an absolute filesystem path. +export function getTempVideoFilePath(_: IpcMainInvokeEvent, token: string): string | null { + return tempEntries.get(token)?.tmpPath ?? null; +} + export async function readVideoFile(_: IpcMainInvokeEvent, token: string): Promise { const entry = tempEntries.get(token); if (!entry) return null; diff --git a/src/equicordplugins/clipUpload.desktop/upload.ts b/src/equicordplugins/clipUpload.desktop/upload.ts index 6124a7c9d..f5440302e 100644 --- a/src/equicordplugins/clipUpload.desktop/upload.ts +++ b/src/equicordplugins/clipUpload.desktop/upload.ts @@ -4,10 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { Logger } from "@utils/Logger"; import { isObject } from "@utils/misc"; import type { PluginNative } from "@utils/types"; -import { User } from "@vencord/discord-types"; -import { Constants, RestAPI, showToast, SnowflakeUtils, Toasts } from "@webpack/common"; +import type { User } from "@vencord/discord-types"; +import { Constants, MediaEngineStore, RestAPI, showToast, SnowflakeUtils, Toasts } from "@webpack/common"; + +import { convertClipToMp4 } from "./ffmpeg"; +const logger = new Logger("ClipUpload"); const Native = VencordNative.pluginHelpers.ClipUpload as PluginNative; @@ -21,7 +25,7 @@ export interface ClipMetadata { createdAt?: number | string | Date; users?: User[]; remoteClipId?: string | number; - eventsTimeline?: any; + eventsTimeline?: unknown; } export interface ClipUploadOptions { @@ -52,6 +56,10 @@ interface RestResponse { text?: string; } +interface ClipMetadataUpdater { + updateClipMetadata(clipId: string, metadata: string): Promise; +} + export function getString(value: unknown) { return typeof value === "string" ? value : undefined; } @@ -107,11 +115,19 @@ function getFileExtension(fileName: string) { return fileName.match(/\.[a-z0-9]+$/i)?.[0].toLowerCase() ?? ".mp4"; } +function getTempVideoFileName(fileName: string) { + if (compatibleExtensions.includes(getFileExtension(fileName))) return fileName; + + return `${getClipTitleFromName(fileName) || defaultFileName}.mp4`; +} + async function readStampedVideoFile(token: string, name: string, type: string) { const tmpToken = await Native.createTempVideoFile(token); if (!tmpToken) throw new Error("Couldn't prepare the selected file."); try { + await updateTempVideoMetadata(tmpToken); + const data = await Native.readVideoFile(tmpToken); if (!data) throw new Error("Couldn't read the selected file."); @@ -121,6 +137,38 @@ async function readStampedVideoFile(token: string, name: string, type: string) { } } +async function updateTempVideoMetadata(tmpToken: string) { + // We need the absolute path here because Discord's MediaEngineStore requires it + // and it is a renderer-only module, so this cannot be done in the main process. + const tmpPath = await Native.getTempVideoFilePath(tmpToken); + if (!tmpPath) throw new Error("Couldn't prepare the selected file."); + + const mediaEngine = MediaEngineStore.getMediaEngine(); + if (!isClipMetadataUpdater(mediaEngine)) throw new Error("Couldn't update clip metadata."); + + await mediaEngine.updateClipMetadata(tmpPath, "{}"); +} + +async function stampVideoFile(file: File, fileName: string) { + const tmpToken = await Native.createTempVideoFileFromBytes(getTempVideoFileName(fileName), new Uint8Array(await file.arrayBuffer())); + if (!tmpToken) throw new Error("Couldn't prepare the selected file."); + + try { + await updateTempVideoMetadata(tmpToken); + + const data = await Native.readVideoFile(tmpToken); + if (!data) throw new Error("Couldn't read the selected file."); + + return new File([new Uint8Array(data)], fileName, { type: "video/mp4" }); + } finally { + await Native.deleteTempVideoFile(tmpToken); + } +} + +function isClipMetadataUpdater(value: unknown): value is ClipMetadataUpdater { + return isObject(value) && typeof (value as { updateClipMetadata?: unknown; }).updateClipMetadata === "function"; +} + export interface ParsedClipMetadata { id?: string; applicationId?: string; @@ -187,19 +235,11 @@ async function reserveClipUpload(options: ClipUploadOptions, file: File) { return parseAttachmentUploadResponse(response).attachments?.[0]; } -export async function uploadClipFile(file: File, options: ClipUploadOptions) { - let uploadAbortController: AbortController | undefined; +async function uploadReservedClip(attachment: NonNullable>>, uploadFile: File) { + const uploadAbortController = new AbortController(); + uploadAbortControllers.add(uploadAbortController); try { - showToast("Checking clip file.", Toasts.Type.MESSAGE); - - const uploadFile = await prepareClipFile(file, options.fileName); - const attachment = await reserveClipUpload(options, uploadFile); - if (!attachment) throw new Error("Discord did not return an upload slot."); - - uploadAbortController = new AbortController(); - uploadAbortControllers.add(uploadAbortController); - const uploadResponse = await fetch(attachment.upload_url, { method: "PUT", body: uploadFile, @@ -211,48 +251,101 @@ export async function uploadClipFile(file: File, options: ClipUploadOptions) { }); if (!uploadResponse.ok) throw new Error("Upload failed."); + } finally { + uploadAbortControllers.delete(uploadAbortController); + } +} - const messageResponse = await RestAPI.post({ - url: Constants.Endpoints.MESSAGES(options.channelId), - body: { - content: options.message, - nonce: SnowflakeUtils.fromTimestamp(Date.now()), - channel_id: options.channelId, - sticker_ids: [], - type: 0, - attachments: [{ - id: "0", - filesize: uploadFile.size, - filename: options.fileName, - uploaded_filename: attachment.upload_filename, - is_clip: true, - is_spoiler: options.spoiler, - is_remix: options.remix, - is_thumbnail: options.thumbnail, - title: options.title, - application_id: options.applicationId, - clip_created_at: options.createdAt, - clip_participant_ids: options.participants, - clip_remote_id: options.remoteClipId, - clip_events_timeline: options.eventsTimeline - }] - } - }) as RestResponse; +async function sendClipUpload(uploadFile: File, options: ClipUploadOptions) { + const attachment = await reserveClipUpload(options, uploadFile); + if (!attachment) throw new Error("Discord did not return an upload slot."); - if (messageResponse.ok === false) throw messageResponse; + await uploadReservedClip(attachment, uploadFile); + + const messageResponse = await RestAPI.post({ + url: Constants.Endpoints.MESSAGES(options.channelId), + body: { + content: options.message, + nonce: SnowflakeUtils.fromTimestamp(Date.now()), + channel_id: options.channelId, + sticker_ids: [], + type: 0, + attachments: [{ + id: "0", + filesize: uploadFile.size, + filename: options.fileName, + uploaded_filename: attachment.upload_filename, + is_clip: true, + is_spoiler: options.spoiler, + is_remix: options.remix, + is_thumbnail: options.thumbnail, + title: options.title, + application_id: options.applicationId, + clip_created_at: options.createdAt, + clip_participant_ids: options.participants, + clip_remote_id: options.remoteClipId, + clip_events_timeline: options.eventsTimeline + }] + } + }) as RestResponse; + + if (messageResponse.ok === false) throw messageResponse; +} + +function getSearchableErrorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if (!isObject(error)) return ""; + + const { body, message, text } = error as { body?: unknown; message?: unknown; text?: unknown; }; + return [ + typeof message === "string" ? message : "", + typeof text === "string" ? text : "", + typeof body === "string" ? body : "", + isObject(body) ? JSON.stringify(body) : "" + ].join("\n"); +} + +function getRestErrorCode(error: unknown) { + if (!isObject(error)) return undefined; + + const { body } = error as { body?: unknown; }; + if (!isObject(body)) return undefined; + + const { code } = body as Record; + return typeof code === "number" ? code : undefined; +} + +function shouldRetryWithFFmpeg(error: unknown) { + const errorText = getSearchableErrorText(error).toLowerCase(); + return getRestErrorCode(error) === 50174 || errorText.includes("not a valid clip"); +} + +export async function uploadClipFile(file: File, options: ClipUploadOptions) { + try { + showToast("Checking clip file.", Toasts.Type.MESSAGE); + + const uploadFile = await prepareClipFile(file, options.fileName); + + try { + await sendClipUpload(uploadFile, options); + } catch (error) { + if (!shouldRetryWithFFmpeg(error)) throw error; + + showToast("Converting clip file.", Toasts.Type.MESSAGE); + await sendClipUpload(await stampVideoFile(await convertClipToMp4(file, options.fileName), options.fileName), options); + } showToast("Clip uploaded.", Toasts.Type.SUCCESS); return true; } catch (error) { + logger.error(error); showToast(getErrorMessage(error), Toasts.Type.FAILURE); return false; - } finally { - if (uploadAbortController) uploadAbortControllers.delete(uploadAbortController); } } export function getErrorMessage(error: unknown): string { - console.error(error); if (error instanceof DOMException && error.name === "AbortError") return "Upload canceled."; if (error instanceof Error) return error.message; return "Failed to upload clip.";