-
-
Save adam-james-v/6b2ee824d0040b82f91dbdd1f7fd5330 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bb | |
(ns vidwiz.main | |
"This is a prototype script for automating a portion of my video editing using ffmpeg." | |
(:require [clojure.java.shell :refer [sh]] | |
[clojure.string :as st] | |
[cheshire.core :refer [parse-string]])) | |
;; util | |
(defn get-extension | |
[fname] | |
(re-find #"\.[A-Za-z\d+]+" fname)) | |
;; thanks to Burin (@burinc) for an improved impl. | |
(defn get-resolution | |
[fname] | |
(when-let [{:keys [width height]} | |
(-> (sh "ffprobe" | |
"-v" | |
"error" | |
"-select_streams" | |
"v:0" | |
"-show_entries" | |
"stream=width,height" | |
"-of" "json" | |
fname) | |
:out | |
(parse-string true) | |
:streams | |
first)] | |
[width height])) | |
(defn overlay-offsets | |
[{:keys [border base-dims overlay-dims pos gap fname]}] | |
(let [{:keys [width]} border | |
[cw ch] (map #(+ (* 2 width) %) overlay-dims) | |
{:keys [h v]} pos | |
[sw sh] base-dims] | |
[(cond (= h :l) gap | |
(= h :c) (- (/ sw 2) (/ cw 2)) | |
(= h :r) (- sw gap cw)) | |
(cond (= v :t) gap | |
(= v :c) (- (/ sh 2) (/ ch 2)) | |
(= v :b) (- sh gap ch))])) | |
(defn get-bg-color | |
[fname] | |
(let [nfname (st/replace fname (get-extension fname) ".png")] | |
(sh "ffmpeg" "-i" fname | |
"-frames:v" "1" | |
"-filter_complex" | |
(str "[0:v]crop=4:4:100:500") | |
"-y" (str nfname)) | |
(sh "convert" nfname "-colors" "1" nfname) | |
(let [col (->> (sh "identify" "-verbose" nfname) | |
:out | |
(st/split-lines) | |
(drop-while #(not (st/includes? % "Histogram"))) | |
(second) | |
(re-find #"\#......"))] | |
(sh "rm" nfname) | |
col))) | |
(defn crop-pad-screen | |
"A multi-step transformation for screen recording footage. | |
The following sequence of transforms are handled using ffmpeg's 'filter_complex': | |
- crop and pad screen recording | |
- cut screen footage into left side and right side | |
- create a 1920x1080 image with the background color as the fill | |
- stitch left and right side back together | |
- overlay stitched screen recording onto the bg image with calculated offset values" | |
[{:keys [fname left right] :as m}] | |
(let [[w h] (get-resolution fname) | |
props (merge m {:border {:width 0 :color ""} | |
:base-dims [1920 1080] | |
:overlay-dims [(+ (:width left) (:width right)) h]}) | |
[ow oh] (overlay-offsets props) | |
col (get-bg-color fname)] | |
(sh "ffmpeg" | |
"-i" fname | |
"-f" "lavfi" | |
"-i" (str "color=" col ":s=1920x1080") | |
"-filter_complex" | |
(str "[0:v]crop=" (:width left) ":" h ":" (:offset left) ":0[l];" | |
"[0:v]crop=" (:width right) ":" h ":" (- w (:width right) (:offset right)) ":0[r];" | |
"[l][r]hstack=inputs=2[scr];" | |
"[1:v][scr]overlay=" ow ":" oh ":shortest=1") | |
"-c:a" "copy" "-y" "cropped-screen.mov"))) | |
(defn clap-time | |
"Find time in seconds at which a clap is detected in the audio stream of fname. | |
The detection assumes that a clap sound exists within the first 12 seconds of a given clip." | |
[fname] | |
(->> (sh "ffmpeg" "-i" fname | |
"-ss" "00:00:00" "-t" "00:00:12" | |
"-af" "silencedetect=noise=0.5:d=0.01" | |
"-f" "null" "-") | |
:err | |
(st/split-lines) | |
(drop-while #(not (st/includes? % "silence_end:"))) | |
(first) | |
(re-find #"silence_end: .+") | |
(re-find #"\d+\.\d+") | |
(read-string))) | |
(defn overlay-camera | |
"Composes the final footage by overlaying the camera footage onto the screen footage according to given properties. | |
The composition is handled using ffmpeg's 'filter_complex', and several actions occur: | |
- overlays camera footage with border onto screen footage | |
- given screen footage, camera footage, and border width and color create combined video | |
- calculate camera delay using clap times in footage. assumes screen recording is longer than cam | |
- calculate size of border for camera | |
- create border as a solid color frame | |
- scale camera down to given overlay-dims | |
- overlay camera onto border frame | |
- overlay bordered camera onto screen footage with calculated offsets" | |
[{:keys [border overlay-dims camf scrf] :as props}] | |
(let [{:keys [width color]} border | |
[cw ch] (map #(+ (* 2 width) %) overlay-dims) | |
[ow oh] (overlay-offsets (assoc props :fname scrf | |
:base-dims (get-resolution scrf))) | |
delay (- (clap-time scrf) (clap-time camf))] | |
(sh "ffmpeg" | |
"-i" scrf | |
"-i" camf | |
"-f" "lavfi" | |
"-i" (str "color=" color ":s=" cw "x" ch) | |
"-filter_complex" | |
(str "[1:v]scale=" (apply str (interpose "x" overlay-dims)) "[scv];" | |
"[2:v][scv]overlay=" width ":" width ":shortest=1[cam];" | |
"[cam]setpts=PTS-STARTPTS+" delay "/TB[dcam];" | |
"[0:v][dcam]overlay=" ow ":" oh ":shortest=1") | |
"-c:a" "copy" "-y" "merged.mov"))) | |
(defn fix-audio | |
"Fixes issue where mono audio track plays only to the Left channel." | |
[fname] | |
(sh "ffmpeg" "-i" fname | |
"-i" fname "-af" "pan=mono|c0=FL" | |
"-c:v" "copy" "-map" "0:v:0" "-map" "1:a:0" "fixed-audio.mov")) | |
#_(spit "props.edn" | |
{:screen | |
{:fname "scr.mov" | |
:left {:width 667 :offset 0} ;; offset from left side | |
:right {:width 750 :offset 0} ;; offset from right side | |
:gap 100 | |
:pos {:h :l :v :c}} ;; :h can be [:l :c :r] :v can be [:t :c :b] | |
:camera | |
{:camf "cam.mov" | |
:scrf "cropped-screen.mov" ;; this is the hardcoded output filename from (crop-pad-screen fname) | |
:border {:width 7 :color "cyan"} | |
:overlay-dims [480 270] ;; dims of camera excluding border | |
:gap 70 | |
:pos {:h :r :v :b}}}) | |
(defn main | |
"Main runs when vidwiz is run as a script. | |
You can run this program with babashka: | |
- chmod +x vidwiz.clj | |
- ./vidwiz props.edn" | |
[] | |
(let [fname (first *command-line-args*) | |
props (when (= (get-extension fname) ".edn") | |
(read-string (slurp fname)))] | |
(when props | |
(crop-pad-screen (:screen props)) | |
(overlay-camera (:camera props)) | |
(fix-audio "merged.mov")))) | |
(main) |
Comment https://gist.github.com/adam-james-v/6b2ee824d0040b82f91dbdd1f7fd5330#file-vidwiz-clj-L4-L12 can be moved to ns docstring.
Comment blocks before function declarations can be moved to function docstrings (if they documents those functions).
Serioga, thank you for pointing this out. A simple change, but definitely a good one for better code style and clarity. I appreciate it.
I found that get-resolution
method match something else that is not intended.
Since Babashka have support library for Json, in this case cheshire so I refactoring the code as follow:
(require '[cheshire.core :refer [parse-string]])
(defn get-resolution
[fname]
(when-let [{:keys [width height]}
(-> (sh "ffprobe"
"-v"
"error"
"-select_streams"
"v:0"
"-show_entries"
"stream=width,height"
"-of" "json"
fname)
:out
(parse-string true)
:streams
first)]
[width height]))
And it seems to be working better
I found that
get-resolution
method match something else that is not intended.
Since Babashka have support library for Json, in this case cheshire so I refactoring the code as follow:(require '[cheshire.core :refer [parse-string]]) (defn get-resolution [fname] (when-let [{:keys [width height]} (-> (sh "ffprobe" "-v" "error" "-select_streams" "v:0" "-show_entries" "stream=width,height" "-of" "json" fname) :out (parse-string true) :streams first)] [width height]))And it seems to be working better
@burinc, thanks for your contribution! I've updated the gist with your implementation. Gave it a try in my terminal and it's a nice improvement for sure :).
Comment https://gist.github.com/adam-james-v/6b2ee824d0040b82f91dbdd1f7fd5330#file-vidwiz-clj-L4-L12 can be moved to ns docstring.
Comment blocks before function declarations can be moved to function docstrings (if they documents those functions).