Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
deanveloper committed Apr 16, 2023
0 parents commit 9c435d1
Show file tree
Hide file tree
Showing 10 changed files with 603 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.vscode
dev
ddmpeg
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ddmpeg

a personal project to make an ffmpeg wrapper that trims videos, sets target file size

## usage

`ddmpeg -i <input> -o <output> [-r <range>] [-s <size>] [-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: `[email protected]: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)
8 changes: 8 additions & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"fmt": {
"options": {
"useTabs": true,
"lineWidth": 120
}
}
}
5 changes: 5 additions & 0 deletions error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class FFTrimError extends Error {
constructor(msg: string) {
super(msg);
}
}
205 changes: 205 additions & 0 deletions ffwrapper.ts
Original file line number Diff line number Diff line change
@@ -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<VideoData> {
// 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<number> {
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<number> {
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<string, number>, 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;
}
87 changes: 87 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
@@ -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 <input> -o <output> [-t <[start]:[end]>] [-s <size>] [-m <w1>[,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)}]`;
}
Loading

0 comments on commit 9c435d1

Please sign in to comment.