Skip to content

Commit

Permalink
Add libavfilter command and improved control over FFmpeg's resource u…
Browse files Browse the repository at this point in the history
…sage
  • Loading branch information
dd-pardal committed Apr 16, 2023
1 parent 6476946 commit 63b1827
Show file tree
Hide file tree
Showing 5 changed files with 95 additions and 31 deletions.
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ API_TYPE=none
# Put ID of server to limit owner-only commands to
ADMIN_SERVER=

# Maximum number of media processing operations that can be running concurrently at any given time.
# Maximum amount of physical memory FFmpeg is allowed to use, in bytes
FFMPEG_MEMORY_LIMIT=1Gi

# Maximum number of media processing operations that can be running concurrently at any given time
MEDIA_CONCURRENCY_LIMIT=1

# Maximum size in pixels for each dimension of an output video
Expand All @@ -63,4 +66,7 @@ MAX_VIDEO_DIMENSIONS=1024
# (The size of the output file is slightly bigger than this value.)
MAX_VIDEO_SIZE=64Mi
# Stop processing a video after the following time (in milliseconds) has elapsed
VIDEO_TIMEOUT=60000
VIDEO_SOFT_TIMEOUT=60000
# Forcefully stop processing a video after the following time (in milliseconds) has elapsed
# (The output file might get corrupted if this time is reached.)
VIDEO_HARD_TIMEOUT=70000
15 changes: 14 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { reload, connect, connected } from "./utils/soundplayer.js";
// events
import { endBroadcast, startBroadcast } from "./utils/misc.js";
import { parseThreshold } from "./utils/tempimages.js";
import { parseNumberWithMultipliers } from "./utils/number-parsing.js";

const { types } = JSON.parse(readFileSync(new URL("./config/commands.json", import.meta.url)));
const esmBotVersion = JSON.parse(readFileSync(new URL("./package.json", import.meta.url))).version;
Expand Down Expand Up @@ -108,6 +109,12 @@ esmBot ${esmBotVersion} (${process.env.GIT_REV})
await parseThreshold();
}

if (process.env.FFMPEG_MEMORY_LIMIT) {
process.env.FFMPEG_MEMORY_LIMIT = parseNumberWithMultipliers(process.env.FFMPEG_MEMORY_LIMIT);
} else {
logger.warn("FFMPEG_MEMORY_LIMIT is unset. FFmpeg's memory usage will not be limited.");
}

// register commands and their info
logger.log("info", "Attempting to load commands...");
for await (const commandFile of getFiles(resolve(dirname(fileURLToPath(import.meta.url)), "./commands/"))) {
Expand Down Expand Up @@ -228,4 +235,10 @@ esmBot ${esmBotVersion} (${process.env.GIT_REV})
client.connect();
}

init();
init();

function stop() {
process.exit();
}
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
76 changes: 49 additions & 27 deletions classes/mediaCommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export const ffmpegConfig = {
// const limiter = new ConcurrencyLimiter(Number.parseInt(process.env.MEDIA_CONCURRENCY_LIMIT));
const limiter = new ConcurrencyLimiter(1);

const childProcesses = new Set();
process.on("exit", () => {
for (const child of childProcesses) {
child.kill("SIGKILL");
}
});

class MediaCommand extends Command {
async criteria() {
return true;
Expand Down Expand Up @@ -83,43 +90,58 @@ class MediaCommand extends Command {
}

return limiter.runWhenFree(async () => {
if (media.mediaType === "video") {
if (this.params === undefined || media.mediaType === "video") {
const params = this.ffmpegParams(media.url);
// TODO: Stream the data from FFmpeg directly to Discord and to a file in TEMPDIR. If
// there is an error, abort the request and remove the file. If the request body exceeds
// 8MiB, abort the request and, when processing has finished, send the embed with the
// link to TMP_DOMAIN and keep the file. If there is no error and the request doesn't
// exceed 8MiB, remove the file.

const softTimeout = setTimeout(() => {
promise.child.kill("SIGTERM");
}, Number.parseInt(process.env.VIDEO_SOFT_TIMEOUT));
const hardTimeout = setTimeout(() => {
promise.child.kill("SIGKILL");
}, Number.parseInt(process.env.VIDEO_HARD_TIMEOUT));

const output = await execFileP("ffmpeg/ffmpeg", [
// Global options
"-y", "-nostats", "-nostdin", "-hide_banner", ...(process.env.FFMPEG_MEMORY_LIMIT ? ["-memorylimit", process.env.FFMPEG_MEMORY_LIMIT] : []),
// Input
"-i", media.url,
// Filter
"-vf", `fps=25, scale='min(${process.env.MAX_VIDEO_DIMENSIONS},iw)':'min(${process.env.MAX_VIDEO_DIMENSIONS},ih)':force_original_aspect_ratio=decrease:sws_flags=fast_bilinear, ${params.filterGraph}, scale='min(${process.env.MAX_VIDEO_DIMENSIONS},iw)':'min(${process.env.MAX_VIDEO_DIMENSIONS},ih)':force_original_aspect_ratio=decrease:sws_flags=fast_bilinear`,
// Video encoding
"-c:v", "libvpx", "-minrate", "500k", "-b:v", "2000k", "-maxrate", "5000k", "-cpu-used", "5", "-auto-alt-ref", "0",
// Audio encoding
"-c:a", "libopus", "-b:a", "64000",
// Output
"-fs", process.env.MAX_VIDEO_SIZE, "-f", "webm", "/tmp/output.webm",
]);
childProcesses.add(promise.child);

try {
const params = this.ffmpegParams(media.url);
// TODO: Stream the data from FFmpeg directly to Discord and to a file in TEMPDIR. If
// there is an error, abort the request and remove the file. If the request body exceeds
// 8MiB, abort the request and, when processing has finished, send the embed with the
// link to TMP_DOMAIN and keep the file. If there is no error and the request doesn't
// exceed 8MiB, remove the file.
const output = await execFileP("ffmpeg/ffmpeg", [
// Global options
"-y", "-nostats",
// Input
"-i", media.url,
// Filter
"-vf", `fps=25, scale='min(${process.env.MAX_VIDEO_DIMENSIONS},iw)':'min(${process.env.MAX_VIDEO_DIMENSIONS},ih)':force_original_aspect_ratio=decrease:sws_flags=fast_bilinear, ${params.filterGraph}, scale='min(${process.env.MAX_VIDEO_DIMENSIONS},iw)':'min(${process.env.MAX_VIDEO_DIMENSIONS},ih)':force_original_aspect_ratio=decrease:sws_flags=fast_bilinear`,
// Video encoding
"-c:v", "libvpx", "-minrate", "500k", "-b:v", "2000k", "-maxrate", "5000k", "-cpu-used", "5", "-auto-alt-ref", "0",
// Audio encoding
"-c:a", "libopus", "-b:a", "64000",
// Output
"-fs", process.env.MAX_VIDEO_SIZE, "-f", "webm", "/tmp/output.webm",
], {
// TODO: Send a SIGKILL some seconds after the timeout in case FFmpeg hangs
timeout: Number.parseInt(process.env.VIDEO_TIMEOUT)
});
return {
contents: await readFile("/tmp/output.webm"),
name: "output.webm"
await promise;
} catch (err) {
if (err.code !== 255) {
throw err.stderr;
}
} finally {
childProcesses.delete(promise.child);
clearTimeout(softTimeout);
clearTimeout(hardTimeout);
try {
if (status) await status.delete();
} catch {
// no-op
}
runningCommands.delete(this.author.id);
}
return {
contents: await readFile("/tmp/output.webm"),
name: "output.webm"
};
} else {
imageParams.path = media.path;
imageParams.params.type = media.type;
Expand Down
23 changes: 23 additions & 0 deletions commands/media-editing/lavfilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import MediaCommand from "../../classes/mediaCommand.js";

class LavfilterCommand extends MediaCommand {
ffmpegParams(url) {
const newArgs = this.options.text ?? this.args.filter(item => !item.includes(url)).join(" ");
return {
filterGraph: newArgs
};
}

static description = "Processes a video using a libavfilter filtergraph. See <https://ffmpeg.org/ffmpeg-filters.html>.";
static aliases = ["lavfi"];
static arguments = ["[filtergraph description]"];

static requiresText = true;
static noText = "You need to provide a filtergraph description!";
static noImage = "You need to provide an image/GIF to generate a meme!";
static command = "libavfilter";

static acceptsVideo = true;
}

export default LavfilterCommand;

0 comments on commit 63b1827

Please sign in to comment.