From 9c435d115c6d41d5d38e7af9ceca35799cee040a Mon Sep 17 00:00:00 2001 From: dean Date: Sun, 16 Apr 2023 13:26:57 -0700 Subject: [PATCH] initial commit --- .gitignore | 3 + LICENSE | 21 +++++ README.md | 56 ++++++++++++++ deno.jsonc | 8 ++ error.ts | 5 ++ ffwrapper.ts | 205 +++++++++++++++++++++++++++++++++++++++++++++++++ main.ts | 87 +++++++++++++++++++++ parseFlags.ts | 121 +++++++++++++++++++++++++++++ timeFormats.ts | 57 ++++++++++++++ weights.ts | 40 ++++++++++ 10 files changed, 603 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 deno.jsonc create mode 100644 error.ts create mode 100644 ffwrapper.ts create mode 100644 main.ts create mode 100644 parseFlags.ts create mode 100644 timeFormats.ts create mode 100644 weights.ts 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, + }; + } +}