Last active
August 13, 2026 18:48
-
-
Save alexmozaidze/f925f09710c229b25283d5b1c5dd371a to your computer and use it in GitHub Desktop.
A script for compressing a video to a certain length (usually to ~10MB for Discord)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {:deps {cli-matic/cli-matic {:mvn/version "0.5.2"}}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bb | |
| ;; vim:ft=clojure: | |
| (ns user | |
| (:require [clojure.string :as string] | |
| [cli-matic.core :refer [run-cmd]] | |
| [babashka.process :as process] | |
| [babashka.fs :as fs])) | |
| (set! *warn-on-reflection* true) | |
| (def tiny-bitrate-threshold 100000) | |
| (defn log | |
| [prefix & strs] | |
| (let [output (format "%s: %s" prefix (string/join " " strs))] | |
| (binding [*out* *err*] | |
| (println output)))) | |
| (defn get-duration | |
| "Gets the duration of a file." | |
| [path] | |
| (let [command ["ffprobe" | |
| "-i" path | |
| "-show_entries" "format=duration" | |
| "-v" "quiet" | |
| "-of" "csv=p=0"] | |
| {duration :out} (apply process/sh command) | |
| duration (string/trim duration)] | |
| (if-not (empty? duration) | |
| (try | |
| (Float/parseFloat duration) | |
| (catch NumberFormatException _ nil)) | |
| nil))) | |
| (defn calculate-video-bitrate | |
| "Calculates video bitrate to achieve the desired video file size." | |
| [target-size-in-mb input-video-duration audio-bitrate] | |
| (-> target-size-in-mb | |
| (* 1024 1024 8) ; converting to bits | |
| (/ input-video-duration) | |
| (- audio-bitrate) | |
| int)) | |
| (def tmpdir (delay | |
| (let [tmpdir (fs/create-temp-dir)] | |
| (-> (Runtime/getRuntime) | |
| (.addShutdownHook (Thread. #(fs/delete-tree tmpdir)))) | |
| tmpdir))) | |
| (defn build-complex-filter | |
| [scale audio-tracks] | |
| {:pre [(string? scale) | |
| (coll? audio-tracks) | |
| (every? int? audio-tracks)]} | |
| (let [video-pipeline (format "[0:v]format=yuv420p,scale=%s[vout]" scale) | |
| audio-mappings (->> audio-tracks | |
| (map #(format "[0:a:%d]" %)) | |
| (apply str)) | |
| audio-pipeline (format "%samix=inputs=%d[aout]" audio-mappings (count audio-tracks))] | |
| (str video-pipeline ";" audio-pipeline))) | |
| (defn -main | |
| [{:keys [input preset output fps scale] | |
| yes? :yes | |
| dry? :dry | |
| target-size-in-mb :size | |
| audio-tracks :m:a | |
| audio-codec :c:a | |
| audio-bitrate :b:a | |
| allow-tiny-bitrate? :allow-tiny-bitrate}] | |
| (let [input (fs/absolutize input) | |
| output (fs/absolutize output) | |
| audio-tracks (if (coll? audio-tracks) audio-tracks [audio-tracks]) | |
| duration (get-duration input) | |
| _ (when-not (and duration (pos? duration)) | |
| (log :error (format "Could not determine the duration of %s. Is it a valid video file?" input)) | |
| (System/exit 1)) | |
| target-video-bitrate (calculate-video-bitrate target-size-in-mb duration audio-bitrate) | |
| tiny-bitrate? (< target-video-bitrate tiny-bitrate-threshold) | |
| excessively-tiny-bitrate? (<= target-video-bitrate 0) | |
| args ["-i" input | |
| "-filter_complex" (build-complex-filter scale audio-tracks) | |
| "-c:v" "libx264" | |
| "-c:a" audio-codec | |
| "-b:v" target-video-bitrate | |
| "-b:a" audio-bitrate | |
| "-f" "mp4" | |
| "-map" "[vout]" | |
| "-map" "[aout]" | |
| "-r" fps | |
| "-preset" preset | |
| "-movflags" "+faststart"] | |
| first-pass-args (concat ["-y"] | |
| args | |
| ["-pass" 1 | |
| "/dev/null"]) | |
| second-pass-args (concat (when yes? ["-y"]) | |
| args | |
| ["-pass" 2 | |
| output]) | |
| run-command (fn [args] | |
| (let [command (into ["ffmpeg"] args)] | |
| (if-not dry? | |
| (apply process/shell | |
| {:dir @tmpdir :in :inherit :out :inherit :err :inherit} | |
| command) | |
| (->> command | |
| (string/join " ") | |
| println)))) | |
| compress (fn [] | |
| (run-command first-pass-args) | |
| (run-command second-pass-args) | |
| (when-not (string/ends-with? output ".mp4") | |
| (log :warning "The output does not end with `.mp4`. Keep in mind that the output is *always* an mp4 container, even if the file doesn't end with `.mp4`.")))] | |
| (when excessively-tiny-bitrate? | |
| (log :error "The target size is too small: the audio bitrate alone exceeds the total bitrate budget. Increase --size or lower --b:a.") | |
| (System/exit 1)) | |
| (when tiny-bitrate? | |
| (let [messages {:warning "The bitrate is extremely small. The target size will be achieved, however, the video will be extremely low quality." | |
| :error "The bitrate is extremely small. In order to permit small bitrate, pass --allow-tiny-bitrate to the command. However, keep in mind that the resulting video will be extremely low quality, to the point that it may not be watchable."} | |
| severity (cond | |
| (and tiny-bitrate? allow-tiny-bitrate?) :warning | |
| (and tiny-bitrate? (not allow-tiny-bitrate?)) :error)] | |
| (log severity (messages severity)) | |
| (when-not allow-tiny-bitrate? (System/exit 1)))) | |
| (compress))) | |
| (def cmd-spec | |
| {:command "ffcompress" | |
| :description "ffmpeg helper script for compressing a file to a desired file size." | |
| :version "0.0.1" | |
| :opts [{:option "yes" | |
| :short "y" | |
| :as "Whether to overwrite the output file." | |
| :type :with-flag | |
| :default false} | |
| {:option "dry" | |
| :short "d" | |
| :as "Just print what would've been done, not actually run it. Useful for testing." | |
| :type :with-flag | |
| :default false} | |
| {:option "input" | |
| :short "i" | |
| :as "Input video file to compress." | |
| :type :string | |
| :default :present} | |
| {:option "size" | |
| :short "s" | |
| :as "Target size for the video in MB." | |
| :type :float | |
| :spec #(< 2 %) | |
| :default 9} | |
| {:option "preset" | |
| :short "p" | |
| :as "Preset to use for encoding the video." | |
| :type :string | |
| :spec #{"ultrafast" | |
| "superfast" | |
| "veryfast" | |
| "faster" | |
| "fast" | |
| "medium" | |
| "slow" | |
| "slower" | |
| "veryslow" | |
| "placebo"} | |
| :default "medium"} | |
| {:option "m:a" | |
| :as "Audio track to use. More than one options merge audio tracks." | |
| :multiple true | |
| :type :int | |
| :spec (fn [x] (if (coll? x) (every? #(<= 0 %) x) (<= 0 x))) | |
| :default [0]} | |
| {:option "c:a" | |
| :as "Audio codec to use." | |
| :type :string | |
| :default "libopus"} | |
| {:option "b:a" | |
| :as "Audio bitrate for the output." | |
| :type :int | |
| :spec #(< 10000 %) | |
| :default 128000} | |
| {:option "allow-tiny-bitrate" | |
| :as (format "If true, the program will permit bitrate values below %d." tiny-bitrate-threshold) | |
| :type :with-flag | |
| :default false} | |
| {:option "fps" | |
| :short "r" | |
| :as "Video framerate." | |
| :type :int | |
| :spec #(< 1 %) | |
| :default 60} | |
| {:option "scale" | |
| :as "Video scale (resolution)." | |
| :type :string | |
| :spec #(->> (re-matches #"-?\d+:-?\d+" %) boolean) | |
| :default "1280:-2"} | |
| {:option "output" | |
| :short "o" | |
| :as "Compressed video file output path." | |
| :type :string | |
| :default :present}] | |
| :runs -main}) | |
| (when (= *file* (System/getProperty "babashka.file")) | |
| (run-cmd *command-line-args* cmd-spec)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment