diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d14430a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.vscode
+dev
+ddmpeg
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3b41aae
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 deanveloper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2e1284e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+# ddmpeg
+
+a personal project to make an ffmpeg wrapper that trims videos, sets target file size
+
+## usage
+
+`ddmpeg -i -o [-r ] [-s ] [-m <1[,...]>] [-d]`
+
+| flag | description | usage |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
+| `input` or `i` | specify input file | `-i infile.mp4` |
+| `output` or `o` | specify output file | `-o outfile.mp4` |
+| `trim` or `t` | specify trim range. must contain a dash, remove one argument to specify the start/end of the video | `-t 2m40s:3m` |
+| `size` or `s` | specify target size of video | `-s 4m` (4 MB) |
+| `merge` or `m` | merge audio tracks into one track, can optionally specify weights for each track. All unlisted tracks assume `1` as the weight. | `-m` or `-m 2,3` |
+
+## examples
+
+#### trim a video
+
+- Trim video to only include 3min to 3min 30sec
+ - `ddmpeg -i in.mp4 -o out.mp4 -t 3m:3m30s`
+- Trim video to include everything after 4mins 34.3secs
+ - `ddmpeg -i in.mp4 -o out.mp4 -t 4m34.3s:`
+- Trim video to only include the first 30 seconds
+ - `ddmpeg -i in.mp4 -o out.mp4 -t :30s`
+
+#### audio stuff
+
+- Merge audio tracks with equal weights
+ - `ddmpeg -i in.mp4 -o out.mp4 -m 1`
+- Merge audio tracks, with the second track being louder than the first
+ - `ddmpeg -i in.mp4 -o out.mp4 -m 1,5`
+
+#### set target size
+
+- Try to shoot for a 30MB video
+ - `ddmpeg -i in.mp4 -o out.mp4 -s 30m`
+
+#### putting it all together
+
+- Share your Valorant clip!
+ - Your clipping software captures 5 minutes, but you only want the last 30 seconds.
+ - The first audio stream has your desktop sounds, and the second audio stream has your microphone sounds.
+ - Your microphone was pretty loud, so you want to make sure that has less weight when we mix the streams.
+ - You're going to share this on Discord, so you can't let it go over 100MB
+ - `ddmpeg -i in.mp4 -o out.mp4 -t 4m30s: -m 3,1 -s 75m`
+
+## installation (from source)
+
+1. install [deno](https://deno.land/#installation)
+2. clone this repo
+ 1. https: `git clone https://github.com/deanveloper/ddmpeg`
+ 2. ssh: `git@github.com:deanveloper/ddmpeg.git`
+3. `deno compile --allow-read --allow-run main.ts`
+4. `cp` the newly created binary somewhere in your PATH (ie `/usr/local/bin` on *nix)
diff --git a/deno.jsonc b/deno.jsonc
new file mode 100644
index 0000000..4dc856a
--- /dev/null
+++ b/deno.jsonc
@@ -0,0 +1,8 @@
+{
+ "fmt": {
+ "options": {
+ "useTabs": true,
+ "lineWidth": 120
+ }
+ }
+}
diff --git a/error.ts b/error.ts
new file mode 100644
index 0000000..c7d0d49
--- /dev/null
+++ b/error.ts
@@ -0,0 +1,5 @@
+export class FFTrimError extends Error {
+ constructor(msg: string) {
+ super(msg);
+ }
+}
diff --git a/ffwrapper.ts b/ffwrapper.ts
new file mode 100644
index 0000000..495e694
--- /dev/null
+++ b/ffwrapper.ts
@@ -0,0 +1,205 @@
+import { readStringDelim } from "https://deno.land/std/io/mod.ts";
+import { readAll } from "https://deno.land/std/streams/mod.ts";
+import { clockToSeconds } from "./timeFormats.ts";
+import { Weights } from "./weights.ts";
+
+export type VideoData = {
+ durationSeconds: number;
+ streams: Stream[];
+};
+export type Stream = {
+ index: number;
+ type: "video" | "audio";
+ codec: string;
+};
+
+export type TrimArgs = {
+ start: number | undefined;
+ end: number | undefined;
+ bitrate: number | undefined;
+ inputFile: string;
+ outputFile: string;
+ audioWeights?: Weights;
+ copyAudio: boolean;
+};
+
+export async function getVideoData(file: string): Promise {
+ // deno-fmt-ignore
+ const p = Deno.run({
+ cmd: [
+ "ffprobe",
+ file,
+ "-v", "error",
+ "-print_format", "json",
+ "-show_streams", "-show_format"
+ ],
+ stdout: "piped",
+ stderr: "null",
+ });
+
+ const output = JSON.parse(new TextDecoder().decode(await readAll(p.stdout)));
+ const videoData = {
+ durationSeconds: Number(output.format.duration),
+ streams: output.streams.map((stream: any) => ({
+ index: stream.index,
+ type: stream.codec_type,
+ codec: stream.codec_name,
+ })),
+ };
+ if (!validateVideoData(videoData)) {
+ throw new Error(`invalid video data: ${JSON.stringify(videoData)}`);
+ }
+ return videoData;
+}
+
+export async function* trim(options: TrimArgs): AsyncGenerator {
+ const {
+ start,
+ end,
+ bitrate,
+ inputFile,
+ audioWeights,
+ outputFile,
+ copyAudio,
+ } = options;
+
+ const startArgs = start ? ["-ss", start.toString(10)] : [];
+ const endArgs = end ? ["-to", end.toString(10)] : [];
+ const bitrateArgs = bitrate ? ["-b:v", bitrate.toString(10)] : [];
+ const audioWeightsArgs = audioWeights ? parseAudioWeightsArgs(audioWeights) : [];
+ const copyAudioArgs = copyAudio ? ["-c:a", "copy"] : [];
+
+ const cmd = [
+ "ffmpeg",
+
+ "-i",
+ inputFile,
+ ...startArgs,
+ ...endArgs,
+ ...bitrateArgs,
+ ...audioWeightsArgs,
+ ...copyAudioArgs,
+ "-loglevel",
+ "level+info",
+ "-y",
+ outputFile,
+ ];
+
+ console.log(cmd.map((s) => `"${s}"`).join(" "));
+
+ const p = Deno.run({
+ cmd,
+ stdout: "null",
+ stderr: "piped",
+ });
+
+ yield* readProgressUpdates(p.stderr);
+}
+
+// returns an async generator that yields the number of seconds that the encoder has currently encoded.
+async function* readProgressUpdates(
+ stderr: Deno.Reader,
+): AsyncGenerator {
+ const progressRegex = /time=([0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{2,}))/;
+
+ let startedReading = false;
+ for await (const progressUpdateLine of readStringDelim(stderr, "\r")) {
+ const groups = progressRegex.exec(progressUpdateLine);
+ const result = groups?.[1];
+
+ if (startedReading && !result) {
+ break;
+ }
+ if (result) {
+ startedReading = true;
+ yield clockToSeconds(result);
+ }
+ }
+}
+
+// audioTracks:
+// represents which weights go to which tracks
+function parseAudioWeightsArgs(audioWeights: Weights): string[] {
+ // if empty, we remove all audio tracks
+ if (audioWeights.type === "none") {
+ return [
+ "-map",
+ "0",
+ "-map",
+ "0:a",
+ ];
+ }
+
+ if (audioWeights.type === "single") {
+ return [
+ "-map",
+ "0:v:0",
+ "-map",
+ `0:a:${audioWeights.weights.keys().next().value}`,
+ ];
+ }
+
+ if (audioWeights.type === "multi") {
+ // deno-fmt-ignore
+ return [
+ "-filter_complex", amix(audioWeights.weights, false),
+ "-map", "0:V:0",
+ "-map", "[aout]",
+ ];
+ }
+
+ // if (audioWeights.type === 'weighted') ...
+ // deno-fmt-ignore
+ return [
+ "-filter_complex", amix(audioWeights.weights, true),
+ "-map", "0:V:0",
+ "-map", "[aout]",
+ ];
+}
+
+// takes weights, returns amix filter
+function amix(weights: Map, useWeights: boolean): string {
+ let amix = "";
+ for (const track of weights.keys()) {
+ amix += `[0:a:${track}]`;
+ }
+
+ amix += `amix=inputs=${weights.size}:duration=longest`;
+
+ if (!useWeights) {
+ amix += `:weights=${[...weights.values()].join(" ")}`;
+ }
+ amix += "[aout]";
+
+ return amix;
+}
+
+function validateVideoData(data: any): data is VideoData {
+ if (!data || typeof data !== "object") {
+ return false;
+ }
+
+ if (typeof data.durationSeconds !== "number") {
+ return false;
+ }
+ if (isNaN(data.durationSeconds)) {
+ return false;
+ }
+
+ for (const stream of data?.streams) {
+ if (!stream || typeof stream !== "object") {
+ return false;
+ }
+ if (typeof stream.index !== "number") {
+ return false;
+ }
+ if (stream.type !== "video" && stream.type !== "audio") {
+ return false;
+ }
+ if (typeof stream.codec !== "string") {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/main.ts b/main.ts
new file mode 100644
index 0000000..c43ebe3
--- /dev/null
+++ b/main.ts
@@ -0,0 +1,87 @@
+import { Flags, parseFlags } from "./parseFlags.ts";
+import { getVideoData, trim } from "./ffwrapper.ts";
+import { FFTrimError } from "./error.ts";
+import { parseWeightsArray } from "./weights.ts";
+
+let flags: Flags;
+try {
+ flags = parseFlags();
+} catch (err) {
+ if (err instanceof FFTrimError) {
+ console.log(
+ `usage: ddmpeg -i -o [-t <[start]:[end]>] [-s ] [-m [,w2,w3...]]`,
+ );
+ console.error("caused by: " + err);
+ Deno.exit(1);
+ } else {
+ throw err;
+ }
+}
+let {
+ start,
+ end,
+ size,
+ inputFile,
+ outputFile,
+ mergeWeights,
+} = flags;
+
+const videoData = await getVideoData(inputFile);
+const audioStreams = videoData.streams.filter((stream) => stream.type === "audio");
+
+// set defaults for start/end
+if (start === undefined) {
+ start = 0;
+}
+if (end === undefined) {
+ end = videoData.durationSeconds;
+}
+
+// parse audio weights
+if (mergeWeights) {
+ for (let i = mergeWeights.length; i < audioStreams.length; i++) {
+ mergeWeights[i] = 1;
+ }
+}
+const audioWeights = parseWeightsArray(mergeWeights);
+
+const duration = end - start;
+
+const copyAudio = audioWeights.canCopyAudio;
+
+// trim the video (async)
+const progress = trim({
+ start,
+ end,
+ bitrate: size ? size / duration : undefined,
+ inputFile,
+ outputFile,
+ audioWeights,
+ copyAudio,
+});
+
+// print out progress on the trimming
+const encoder = new TextEncoder();
+for await (const secondsDone of progress) {
+ const percent = secondsDone / duration;
+ Deno.stdout.writeSync(
+ encoder.encode(
+ `\r${progressBar(30, percent)} (${(percent * 100).toFixed(1)}%)`,
+ ),
+ );
+}
+
+// write a newline
+Deno.stdout.writeSync(new Uint8Array([10]));
+
+// ======== UTILITY FUNCTIONS =========
+
+// simple progress bar
+function progressBar(width: number, percent: number) {
+ percent = Math.max(0, Math.min(percent, 1));
+
+ const equalsSigns = Math.floor((width - 2) * percent);
+ const spaces = Math.ceil((width - 2) * (1 - percent));
+
+ return `[${"=".repeat(equalsSigns)}${" ".repeat(spaces)}]`;
+}
diff --git a/parseFlags.ts b/parseFlags.ts
new file mode 100644
index 0000000..239c565
--- /dev/null
+++ b/parseFlags.ts
@@ -0,0 +1,121 @@
+import { parse } from "https://deno.land/std/flags/mod.ts";
+import { FFTrimError } from "./error.ts";
+import { hmsRangeToSeconds } from "./timeFormats.ts";
+
+export type Flags = {
+ start: number | undefined;
+ end: number | undefined;
+ size: number | undefined;
+ mergeWeights: number[] | undefined;
+ inputFile: string;
+ outputFile: string;
+};
+
+export function parseFlags(): Flags {
+ const {
+ trim: trimRaw,
+ size: sizeRaw,
+ inputFile,
+ outputFile,
+ merge = "",
+ } = parseFlagsRaw();
+
+ const [start, end] = trimRaw === undefined ? [undefined, undefined] : hmsRangeToSeconds(trimRaw);
+ const audioWeightsParsed = merge === "" ? undefined : parseAudioWeights(merge);
+ const size = sizeRaw ? shortFormToBytes(sizeRaw) : undefined;
+
+ if (inputFile === undefined) {
+ throw new FFTrimError("input file is required (-i)");
+ }
+ if (outputFile === undefined) {
+ throw new FFTrimError("output file is required (-o)");
+ }
+
+ return {
+ start,
+ end,
+ size,
+ inputFile,
+ outputFile,
+ mergeWeights: audioWeightsParsed,
+ };
+}
+
+function shortFormToBytes(shortForm: string): number {
+ const lastChar = shortForm.charAt(shortForm.length - 1).toLocaleLowerCase();
+
+ // if the last character is a number, it's likely bytes
+ if (!isNaN(parseFloat(lastChar))) {
+ const num = parseInt(shortForm, 10);
+ if (isNaN(num)) {
+ throw new FFTrimError("invalid number: " + shortForm);
+ }
+ return num;
+ }
+
+ const shortFormNumPart = shortForm.substring(0, shortForm.length - 1);
+ const num = parseInt(shortFormNumPart, 10);
+ if (isNaN(num)) {
+ throw new FFTrimError("invalid number: " + shortFormNumPart);
+ }
+
+ switch (lastChar) {
+ case "b":
+ return num;
+ case "k":
+ return num * 1_000;
+ case "m":
+ return num * 1_000_000;
+ case "g":
+ return num * 1_000_000_000;
+ default:
+ throw new FFTrimError(
+ `invalid unit: ${lastChar} (must be one of {b,k,m,g})`,
+ );
+ }
+}
+
+function parseAudioWeights(weights: string): number[] {
+ try {
+ return weights.split(",").map((str) => {
+ const i = parseInt(str);
+ if (isNaN(i)) {
+ throw new FFTrimError("audio weights must be in the form w1,w2,w3...");
+ }
+ return i;
+ });
+ } catch (e) {
+ throw new FFTrimError("audio weights must be in the form w1,w2,w3...");
+ }
+}
+
+// returns the raw, unormalized form of all of the flags
+function parseFlagsRaw() {
+ const flags = parse(Deno.args, {
+ string: [
+ "trim",
+ "t",
+ "input",
+ "i",
+ "output",
+ "o",
+ "size",
+ "s",
+ "merge",
+ "m",
+ ],
+ boolean: [
+ "dampen",
+ "d",
+ ],
+ });
+
+ return {
+ trim: flags.trim ?? flags.t,
+ size: flags.size ?? flags.s,
+ inputFile: flags.input ?? flags.i ?? flags._[0],
+ outputFile: flags.output ?? flags.o,
+ merge: flags.merge ?? flags.m,
+ dampenAudio: flags.dampen || flags.d,
+ };
+}
diff --git a/timeFormats.ts b/timeFormats.ts
new file mode 100644
index 0000000..48a6ca0
--- /dev/null
+++ b/timeFormats.ts
@@ -0,0 +1,57 @@
+// parses a hms range (ie 5h2m20.5s-5m2m30s) to a pair of seconds (ie [18140.5, 18150])
+export function hmsRangeToSeconds(range: string): [number?, number?] {
+ const split = range.split(":");
+ if (split.length !== 2) {
+ throw new Error(`invalid time range format ${range}, should be "XhYmZs:XhYmZs"`);
+ }
+ const [start, end] = (
+ split.map((time) => time.trim())
+ .map((time) => time === "" ? undefined : hmsToSeconds(time))
+ );
+ return [start, end];
+}
+
+// parses a clock time (ie "05:20:20.5") to a number of seconds (ie 18140.5)
+export function clockToSeconds(time: string): number {
+ let [hours, minutes, seconds] = time.split(":").map((str) => parseFloat(str));
+
+ return hours * 60 * 60 + minutes * 60 + seconds;
+}
+
+// parses an hms time (ie "5h2m20.5s") to a number of seconds (ie 18140.5)
+export function hmsToSeconds(time: string): number {
+ let hourStr = "";
+ let minuteStr = "";
+ let secondStr = "";
+
+ let currentNumber = "";
+
+ for (const c of time) {
+ const cCode = c.charCodeAt(0);
+ const isNumber = cCode >= "0".charCodeAt(0) && cCode <= "9".charCodeAt(0);
+ if (isNumber || c === ".") {
+ currentNumber += c;
+ } else {
+ switch (c) {
+ case "h":
+ hourStr = currentNumber;
+ break;
+ case "m":
+ minuteStr = currentNumber;
+ break;
+ case "s":
+ secondStr = currentNumber;
+ break;
+ default:
+ throw new Error("unexpected timestamp unit: " + c);
+ }
+ currentNumber = "";
+ }
+ }
+
+ let [hours, minutes, seconds] = [hourStr, minuteStr, secondStr]
+ .map((str) => str || "0")
+ .map((str) => parseFloat(str));
+
+ return hours * 60 * 60 + minutes * 60 + seconds;
+}
diff --git a/weights.ts b/weights.ts
new file mode 100644
index 0000000..572f837
--- /dev/null
+++ b/weights.ts
@@ -0,0 +1,40 @@
+export type Weights = {
+ type: "none" | "single" | "multi" | "weighted";
+ weights: Map;
+ canCopyAudio: boolean;
+};
+
+export function parseWeightsArray(weightsArray: number[] | undefined): Weights {
+ if (!weightsArray) {
+ weightsArray = [1];
+ }
+
+ const nonzeroWeights = new Map(Object.entries(weightsArray).filter(([, weight]) => weight !== 0));
+ if (nonzeroWeights.size === 0) {
+ return { type: "none", weights: new Map(), canCopyAudio: false };
+ }
+ if (nonzeroWeights.size === 1) {
+ return {
+ type: "single",
+ weights: nonzeroWeights,
+ canCopyAudio: true,
+ };
+ }
+
+ const firstWeight = nonzeroWeights.values().next().value;
+ const allSame = [...nonzeroWeights.values()].every((weight) => weight === firstWeight);
+
+ if (allSame) {
+ return {
+ type: "multi",
+ weights: nonzeroWeights,
+ canCopyAudio: false,
+ };
+ } else {
+ return {
+ type: "weighted",
+ weights: nonzeroWeights,
+ canCopyAudio: false,
+ };
+ }
+}