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, + }; + } +}