├── LICENSE ├── README.md ├── bash-video.sh ├── subtitles.srt ├── tests.sh └── tests └── test-1-2-3_14s.mp4 /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, James 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bash-video 2 | bash cli video editing "suite" which ... just wraps ffmpeg 3 | 4 | I just kept forgetting all the little configuration bits. 5 | 6 | I have used this to splice up and put together demo videos, my workflow is the following: 7 | 8 | 1) Record a demo video using ( https://obsproject.com/download ) 9 | 2) I use full video sink, and an audio source connected to my headset 10 | 3) Open the clip using VLC, jot down the times to splice up the video 11 | 12 | Example video editing flow, where I do a speedup, and remove a section of video because of dead air: 13 | 14 | ``` 15 | alias bv="$(pwd)/bash-video.sh" 16 | cat tests.sh 17 | 18 | # speedup 2x 19 | bv speedup ./2024-04-15\ 23-03-47.mp4 2 hubspot-email.mp4 20 | 21 | # check it 22 | open hubspot-email.mp4 23 | 24 | # documentation check 25 | cat tests.sh 26 | 27 | # start splicing 28 | bv popright hubspot-email.mp4 75 left.mp4 29 | bv popleft hubspot-email.mp4 95 right.mp4 30 | 31 | # join the left and right clips 32 | bv join left.mp4 right.mp4 hubspot-email-multiseat.mp4 33 | 34 | # check the final cut 35 | open hubspot-email-multiseat.mp4 36 | ``` 37 | 38 | # install 39 | 40 | ``` 41 | git clone git@github.com:allen-munsch/bash-video.git 42 | cd bash-video 43 | 44 | alias bv="$(pwd)/bash-video.sh" 45 | 46 | # example usage can be seen in the tests 47 | ./tests.sh 48 | 49 | ~$ bv 50 | 51 | Usage: bash_video.sh [arguments...] 52 | 53 | Available operations: 54 | splice - Cut a video segment 55 | join - Join multiple videos 56 | speedup - Change playback speed 57 | optimize - Optimize video to reduce size 58 | popleft - Remove a segment from the beginning of the video 59 | popright - Remove a segment from the end of the video 60 | trim - Trim video by start and end times 61 | extractaudio - Extract audio from video 62 | addaudio - Add audio to video 63 | resize - Resize video 64 | rotate - Rotate video (90, 180, 270) 65 | record - Record screen 66 | addsubtitle - Add subtitle to video 67 | filter - Apply video filter 68 | overlay - Overlay image on video 69 | thumbnail - Generate video thumbnail 70 | 71 | ``` 72 | 73 | # Contributing 74 | 75 | feel free to 76 | 77 | 78 | ## filter docs 79 | 80 | - https://ffmpeg.org/ffmpeg-filters.html 81 | -------------------------------------------------------------------------------- /bash-video.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## bash_video.sh 4 | ## 5 | ## Utility Functions 6 | ## 7 | 8 | # Show usage information 9 | function show_usage() { 10 | echo "Usage: bash_video.sh [arguments...]" 11 | echo "Available operations:" 12 | echo " splice - Cut a video segment" 13 | echo " join - Join multiple videos" 14 | echo " speedup - Change playback speed" 15 | echo " optimize - Optimize video to reduce size" 16 | echo " popleft - Remove a segment from the beginning of the video" 17 | echo " popright - Remove a segment from the end of the video" 18 | echo " trim - Trim video by start and end times" 19 | echo " extractaudio - Extract audio from video" 20 | echo " addaudio - Add audio to video" 21 | echo " resize - Resize video" 22 | echo " rotate - Rotate video (90, 180, 270)" 23 | echo " record - Record screen" 24 | echo " addsubtitle - Add subtitle to video" 25 | echo " filter - Apply video filter" 26 | echo " overlay - Overlay image on video" 27 | echo " thumbnail - Generate video thumbnail" 28 | } 29 | 30 | # Parse a time string 31 | function parse_time() { 32 | TIME_STRING=$1 33 | SECONDS=$(echo "$TIME_STRING" | awk -F':' '{ print ($1 * 3600) + ($2 * 60) + $3 }') 34 | } 35 | 36 | # Check if a file exists and is compatible with FFmpeg 37 | function verify_file() { 38 | FILE_PATH=$1 39 | if ! ffprobe "$FILE_PATH" &>/dev/null; then 40 | echo "Error: $FILE_PATH is not a valid video file or not compatible with FFmpeg." 41 | exit 1 42 | fi 43 | } 44 | 45 | ## 46 | ## Video Editing Operations 47 | ## 48 | 49 | # Cut a video segment 50 | function splice_video() { 51 | INPUT_FILE=$1 52 | START_TIME=$2 53 | END_TIME=$3 54 | OUTPUT_FILE=$4 55 | verify_file "$INPUT_FILE" 56 | parse_time "$START_TIME" 57 | START_SECONDS=$SECONDS 58 | parse_time "$END_TIME" 59 | END_SECONDS=$SECONDS 60 | ffmpeg -ss "$START_SECONDS" -i "$INPUT_FILE" -t "$((END_SECONDS - START_SECONDS))" -c copy "$OUTPUT_FILE" || exit 1 61 | } 62 | 63 | # Join multiple videos 64 | function join_videos() { 65 | INPUT_FILE1=$1 66 | INPUT_FILE2=$2 67 | OUTPUT_FILE=$3 68 | verify_file "$INPUT_FILE1" 69 | verify_file "$INPUT_FILE2" 70 | 71 | # Create a temporary file to store the list of input files 72 | TEMP_FILE=$(mktemp) 73 | echo "file '$(realpath "$INPUT_FILE1")'" > "$TEMP_FILE" 74 | echo "file '$(realpath "$INPUT_FILE2")'" >> "$TEMP_FILE" 75 | 76 | # Use the concat demuxer with the temporary file 77 | ffmpeg -f concat -safe 0 -i "$TEMP_FILE" -c copy -y "$(realpath "$OUTPUT_FILE")" || exit 1 78 | 79 | # Remove the temporary file 80 | rm "$TEMP_FILE" 81 | } 82 | 83 | function change_speed() { 84 | INPUT_FILE=$1 85 | SPEED_FACTOR=$2 86 | OUTPUT_FILE=$3 87 | verify_file "$INPUT_FILE" 88 | 89 | RECIPROCAL_FACTOR=$(awk "BEGIN {print 1/$SPEED_FACTOR}") 90 | ffmpeg -i "$INPUT_FILE" -filter:v "setpts=$RECIPROCAL_FACTOR*PTS" -filter:a "atempo=$SPEED_FACTOR" "$OUTPUT_FILE" || exit 1 91 | } 92 | 93 | # Optimize video to reduce size 94 | function optimize_video() { 95 | INPUT_FILE=$1 96 | OUTPUT_FILE=$2 97 | verify_file "$INPUT_FILE" 98 | ffmpeg -i "$INPUT_FILE" -vcodec libx264 -crf 32 -filter:v fps=15 -b:a 96k "$OUTPUT_FILE" || exit 1 99 | } 100 | 101 | # Remove a segment from the beginning of the video 102 | function popleft() { 103 | INPUT_FILE=$1 104 | DURATION=$2 105 | OUTPUT_FILE=$3 106 | verify_file "$INPUT_FILE" 107 | ffmpeg -ss "$DURATION" -i "$INPUT_FILE" -c copy "$OUTPUT_FILE" || exit 1 108 | } 109 | 110 | # Remove a segment from the end of the video 111 | function popright() { 112 | INPUT_FILE=$1 113 | DURATION=$2 114 | OUTPUT_FILE=$3 115 | verify_file "$INPUT_FILE" 116 | DURATION_SECONDS=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$INPUT_FILE") 117 | END_SECONDS=$(echo "$DURATION_SECONDS - $DURATION" | bc) 118 | ffmpeg -ss 0 -i "$INPUT_FILE" -t "$END_SECONDS" -c copy "$OUTPUT_FILE" || exit 1 119 | } 120 | 121 | # Trim video by start and end times 122 | function trim_video() { 123 | INPUT_FILE=$1 124 | START_TIME=$2 125 | END_TIME=$3 126 | OUTPUT_FILE=$4 127 | verify_file "$INPUT_FILE" 128 | ffmpeg -ss "$START_TIME" -i "$INPUT_FILE" -to "$END_TIME" -c copy "$OUTPUT_FILE" || exit 1 129 | } 130 | 131 | # Extract audio from video 132 | function extract_audio() { 133 | INPUT_FILE=$1 134 | OUTPUT_FILE=$2 135 | verify_file "$INPUT_FILE" 136 | ffmpeg -i "$INPUT_FILE" -vn -acodec libmp3lame -b:a 128k "$OUTPUT_FILE" || exit 1 137 | } 138 | 139 | # Add audio to video 140 | function add_audio() { 141 | VIDEO_FILE=$1 142 | AUDIO_FILE=$2 143 | OUTPUT_FILE=$3 144 | verify_file "$VIDEO_FILE" 145 | verify_file "$AUDIO_FILE" 146 | ffmpeg -i "$VIDEO_FILE" -i "$AUDIO_FILE" -c:v copy -c:a aac -map 0:v:0 -map 1:a:0 "$OUTPUT_FILE" || exit 1 147 | } 148 | 149 | # Resize video 150 | function resize_video() { 151 | INPUT_FILE=$1 152 | WIDTH=$2 153 | HEIGHT=$3 154 | OUTPUT_FILE=$4 155 | verify_file "$INPUT_FILE" 156 | ffmpeg -i "$INPUT_FILE" -vf "scale=$WIDTH:$HEIGHT" "$OUTPUT_FILE" || exit 1 157 | } 158 | 159 | # Rotate video 160 | function rotate_video() { 161 | INPUT_FILE=$1 162 | ROTATION=$2 163 | OUTPUT_FILE=$3 164 | verify_file "$INPUT_FILE" 165 | case "$ROTATION" in 166 | "90") 167 | TRANSPOSE="transpose=1" 168 | ;; 169 | "180") 170 | TRANSPOSE="hflip,vflip" 171 | ;; 172 | "270") 173 | TRANSPOSE="transpose=2" 174 | ;; 175 | *) 176 | echo "Error: Invalid rotation value. Allowed values are 90, 180, or 270." 177 | exit 1 178 | ;; 179 | esac 180 | ffmpeg -i "$INPUT_FILE" -vf "$TRANSPOSE" "$OUTPUT_FILE" || exit 1 181 | } 182 | 183 | # Record screen, not sure if this works, i use obs studio 184 | function record_screen() { 185 | OUTPUT_FILE=$1 186 | DURATION=$2 187 | ffmpeg -f x11grab -video_size $(xdpyinfo | grep dimensions | awk '{print $2}') -i :0.0 -t "$DURATION" "$OUTPUT_FILE" || exit 1 188 | } 189 | 190 | # Add subtitle to video 191 | function add_subtitle() { 192 | INPUT_FILE=$1 193 | SUBTITLE_FILE=$2 194 | OUTPUT_FILE=$3 195 | verify_file "$INPUT_FILE" 196 | ffmpeg -i "$INPUT_FILE" -vf "subtitles=$SUBTITLE_FILE" "$OUTPUT_FILE" || exit 1 197 | } 198 | 199 | # Apply video filter 200 | function apply_filter() { 201 | INPUT_FILE=$1 202 | FILTER=$2 203 | OUTPUT_FILE=$3 204 | verify_file "$INPUT_FILE" 205 | ffmpeg -i "$INPUT_FILE" -vf "$FILTER" "$OUTPUT_FILE" || exit 1 206 | } 207 | 208 | # Overlay image on video 209 | function overlay_image() { 210 | INPUT_FILE=$1 211 | IMAGE_FILE=$2 212 | POSITION=$3 213 | OUTPUT_FILE=$4 214 | verify_file "$INPUT_FILE" 215 | ffmpeg -i "$INPUT_FILE" -i "$IMAGE_FILE" -filter_complex "overlay=$POSITION" "$OUTPUT_FILE" || exit 1 216 | } 217 | 218 | # Generate video thumbnail 219 | function generate_thumbnail() { 220 | INPUT_FILE=$1 221 | OUTPUT_FILE=$2 222 | TIMESTAMP=$3 223 | verify_file "$INPUT_FILE" 224 | ffmpeg -ss "$TIMESTAMP" -i "$INPUT_FILE" -vframes 1 "$OUTPUT_FILE" || exit 1 225 | } 226 | 227 | # Add title to video 228 | function add_title() { 229 | INPUT_FILE=$1 230 | TITLE=$2 231 | FONT_SIZE=$3 232 | FONT_COLOR=$4 233 | POSITION=$5 234 | DURATION=$6 235 | OUTPUT_FILE=$7 236 | verify_file "$INPUT_FILE" 237 | ffmpeg -i "$INPUT_FILE" -vf "drawtext=fontfile=/path/to/font.ttf:fontsize=$FONT_SIZE:fontcolor=$FONT_COLOR:box=1:boxcolor=black@0.5:boxborderw=5:x=(w-text_w)/2:y=$POSITION:enable='between(t,0,$DURATION)'" -codec:a copy "$OUTPUT_FILE" || exit 1 238 | } 239 | 240 | # Add fade in effect 241 | function fade_in() { 242 | INPUT_FILE=$1 243 | DURATION=$2 244 | OUTPUT_FILE=$3 245 | verify_file "$INPUT_FILE" 246 | ffmpeg -i "$INPUT_FILE" -vf "fade=t=in:st=0:d=$DURATION" -codec:a copy "$OUTPUT_FILE" || exit 1 247 | } 248 | 249 | # Add fade out effect 250 | function fade_out() { 251 | INPUT_FILE=$1 252 | DURATION=$2 253 | OUTPUT_FILE=$3 254 | verify_file "$INPUT_FILE" 255 | DURATION_SECONDS=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$INPUT_FILE") 256 | START_TIME=$(echo "$DURATION_SECONDS - $DURATION" | bc) 257 | ffmpeg -i "$INPUT_FILE" -vf "fade=t=out:st=$START_TIME:d=$DURATION" -codec:a copy "$OUTPUT_FILE" || exit 1 258 | } 259 | 260 | ## 261 | ## Main Entry Point 262 | ## 263 | 264 | if [ $# -lt 2 ]; then 265 | show_usage 266 | exit 1 267 | fi 268 | 269 | OPERATION=$1 270 | INPUT_FILE=$2 271 | 272 | case "$OPERATION" in 273 | "splice") 274 | if [ $# -ne 5 ]; then 275 | show_usage 276 | exit 1 277 | fi 278 | START_TIME=$3 279 | END_TIME=$4 280 | OUTPUT_FILE=$5 281 | splice_video "$INPUT_FILE" "$START_TIME" "$END_TIME" "$OUTPUT_FILE" 282 | ;; 283 | "join") 284 | if [ $# -ne 4 ]; then 285 | show_usage 286 | exit 1 287 | fi 288 | INPUT_FILE2=$3 289 | OUTPUT_FILE=$4 290 | join_videos "$INPUT_FILE" "$INPUT_FILE2" "$OUTPUT_FILE" 291 | ;; 292 | "speedup") 293 | if [ $# -ne 4 ]; then 294 | show_usage 295 | exit 1 296 | fi 297 | SPEED_FACTOR=$3 298 | OUTPUT_FILE=$4 299 | change_speed "$INPUT_FILE" "$SPEED_FACTOR" "$OUTPUT_FILE" 300 | ;; 301 | "optimize") 302 | if [ $# -ne 3 ]; then 303 | show_usage 304 | exit 1 305 | fi 306 | OUTPUT_FILE=$3 307 | optimize_video "$INPUT_FILE" "$OUTPUT_FILE" 308 | ;; 309 | "popleft") 310 | if [ $# -ne 4 ]; then 311 | show_usage 312 | exit 1 313 | fi 314 | DURATION=$3 315 | OUTPUT_FILE=$4 316 | popleft "$INPUT_FILE" "$DURATION" "$OUTPUT_FILE" 317 | ;; 318 | "popright") 319 | if [ $# -ne 4 ]; then 320 | show_usage 321 | exit 1 322 | fi 323 | DURATION=$3 324 | OUTPUT_FILE=$4 325 | popright "$INPUT_FILE" "$DURATION" "$OUTPUT_FILE" 326 | ;; 327 | "trim") 328 | if [ $# -ne 5 ]; then 329 | show_usage 330 | exit 1 331 | fi 332 | START_TIME=$3 333 | END_TIME=$4 334 | OUTPUT_FILE=$5 335 | trim_video "$INPUT_FILE" "$START_TIME" "$END_TIME" "$OUTPUT_FILE" 336 | ;; 337 | "extractaudio") 338 | if [ $# -ne 3 ]; then 339 | show_usage 340 | exit 1 341 | fi 342 | OUTPUT_FILE=$3 343 | extract_audio "$INPUT_FILE" "$OUTPUT_FILE" 344 | ;; 345 | "addaudio") 346 | if [ $# -ne 4 ]; then 347 | show_usage 348 | exit 1 349 | fi 350 | AUDIO_FILE=$3 351 | OUTPUT_FILE=$4 352 | add_audio "$INPUT_FILE" "$AUDIO_FILE" "$OUTPUT_FILE" 353 | ;; 354 | "resize") 355 | if [ $# -ne 5 ]; then 356 | show_usage 357 | exit 1 358 | fi 359 | WIDTH=$3 360 | HEIGHT=$4 361 | OUTPUT_FILE=$5 362 | resize_video "$INPUT_FILE" "$WIDTH" "$HEIGHT" "$OUTPUT_FILE" 363 | ;; 364 | "rotate") 365 | if [ $# -ne 4 ]; then 366 | show_usage 367 | exit 1 368 | fi 369 | ROTATION=$3 370 | OUTPUT_FILE=$4 371 | rotate_video "$INPUT_FILE" "$ROTATION" "$OUTPUT_FILE" 372 | ;; 373 | "record") 374 | if [ $# -ne 3 ]; then 375 | show_usage 376 | exit 1 377 | fi 378 | OUTPUT_FILE=$2 379 | DURATION=$3 380 | record_screen "$OUTPUT_FILE" "$DURATION" 381 | ;; 382 | "addsubtitle") 383 | if [ $# -ne 4 ]; then 384 | show_usage 385 | exit 1 386 | fi 387 | SUBTITLE_FILE=$3 388 | OUTPUT_FILE=$4 389 | add_subtitle "$INPUT_FILE" "$SUBTITLE_FILE" "$OUTPUT_FILE" 390 | ;; 391 | "filter") 392 | if [ $# -ne 4 ]; then 393 | show_usage 394 | exit 1 395 | fi 396 | FILTER=$3 397 | OUTPUT_FILE=$4 398 | apply_filter "$INPUT_FILE" "$FILTER" "$OUTPUT_FILE" 399 | ;; 400 | "overlay") 401 | if [ $# -ne 5 ]; then 402 | show_usage 403 | exit 1 404 | fi 405 | IMAGE_FILE=$3 406 | POSITION=$4 407 | OUTPUT_FILE=$5 408 | overlay_image "$INPUT_FILE" "$IMAGE_FILE" "$POSITION" "$OUTPUT_FILE" 409 | ;; 410 | "thumbnail") 411 | if [ $# -ne 4 ]; then 412 | show_usage 413 | exit 1 414 | fi 415 | OUTPUT_FILE=$3 416 | TIMESTAMP=$4 417 | generate_thumbnail "$INPUT_FILE" "$OUTPUT_FILE" "$TIMESTAMP" 418 | ;; 419 | *) 420 | show_usage 421 | exit 1 422 | ;; 423 | esac -------------------------------------------------------------------------------- /subtitles.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:00:00,000 --> 00:00:05,000 3 | Hello, World! 4 | 5 | 2 6 | 00:00:05,000 --> 00:00:10,000 7 | This is a simple subtitle example. 8 | 9 | 3 10 | 00:00:10,000 --> 00:00:13,000 11 | Subtitles are displayed at the specified timestamps. 12 | -------------------------------------------------------------------------------- /tests.sh: -------------------------------------------------------------------------------- 1 | ./bash-video.sh popright tests/test-1-2-3_14s.mp4 9 tests/popright.mp4 2 | ./bash-video.sh popleft tests/popright.mp4 2 tests/popleft.mp4 3 | ./bash-video.sh trim tests/test-1-2-3_14s.mp4 2 4 tests/trim.mp4 4 | ./bash-video.sh join tests/popleft.mp4 tests/trim.mp4 tests/join.mp4 5 | ./bash-video.sh speedup tests/test-1-2-3_14s.mp4 '4' tests/speedup.mp4 6 | ./bash-video.sh optimize tests/test-1-2-3_14s.mp4 tests/optimize.mp4 7 | ./bash-video.sh extractaudio tests/popleft.mp4 tests/audio.mp3 8 | ./bash-video.sh resize tests/test-1-2-3_14s.mp4 100 100 tests/resize-100-100.mp4 9 | ./bash-video.sh rotate tests/resize-100-100.mp4 90 tests/rotate-90.mp4 10 | ./bash-video.sh addsubtitle tests/test-1-2-3_14s.mp4 ./subtitles.srt tests/subtitle.mp4 11 | ./bash-video.sh filter tests/popleft.mp4 'hue=h=90:s=1:b=0.5' tests/filter.mp4 -------------------------------------------------------------------------------- /tests/test-1-2-3_14s.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allen-munsch/bash-video/921501cb1ee154b4eac04232b04d0da1fe5cc250/tests/test-1-2-3_14s.mp4 --------------------------------------------------------------------------------