#!/usr/bin/env bash # exit if required variables are not set set -o nounset # subshells and functions inherit ERR traps set -E # parent path to generate video for (trailing slash required) input_directory="" # directory to store output videos output_directory="" # time to display each image, in seconds display_time="8" # resolution of the output, in pixels output_width="2560" output_height="1440" # minimum number of images needed to generate slideshow minimum_images="10" # commands required for script execution required_commands="convert ffmpeg parallel" # use four threads thread_count=4 # set colour codes for interactive use if [ -t 1 ]; then red=$(echo -e "setaf 1\nbold" | tput -S) green=$(echo -e "setaf 2\nbold" | tput -S) yellow=$(echo -e "setaf 3\nbold" | tput -S) blue=$(echo -e "setaf 4\nbold" | tput -S) reset=$(tput sgr0) else red="" green="" yellow="" blue="" reset="" fi function check_dependencies() { local missing_counter missing_counter=0 for command in ${required_commands}; do if ! hash "${command}" >/dev/null 2>&1; then printf "Command not found in PATH: %s\n" "${command}" >&2 ((missing_counter++)) fi done if ((missing_counter > 0)); then printf "%d or more commands are missing from PATH. Exiting.\n" "${missing_counter}" >&2 exit 1 fi } function check_directory() { if [ -d "${1}" ] && [ -n "$(find "${1}" -maxdepth 1 -type f)" ]; then if [[ "${1}" = *'.optimized' ]]; then return fi local files local filecount shopt -s nullglob && files=("${1}"/*) && filecount="${#files[@]}" && shopt -u nullglob if [ "${filecount}" -lt "${minimum_images}" ]; then echo " ${green}[SKIP]${reset} Source path ${blue}${1}${reset} has fewer than ${minimum_images} files." return fi local relative_directory relative_directory="$(echo "${1}" | sed "s_${input_directory}__g" | tr "/" " ")" local short_filename short_filename="${relative_directory//[\\\/\:\*\?\"<>|\ \-\_\'\!\.\,\(\)]/}" local output_filename output_filename="${short_filename}.webm" local new_hashfile new_hashfile="$(mktemp -p "${base_temp_dir}")" local old_hashfile old_hashfile="${output_directory}/.exists/${output_filename}" find "${1}" -type f -exec sha256sum {} \; | sort -k2 | cut -d " " -f1 > "${new_hashfile}" if [ -f "${old_hashfile}" ]; then if cmp -s "${new_hashfile}" "${old_hashfile}"; then echo " ${green}[EXISTS]${reset} Output file ${blue}${output_directory}/${output_filename}${reset} already exists." return else echo " ${yellow}[BUILD]${reset} Source path ${blue}${1}${reset} has changed." fi else echo " ${yellow}[BUILD]${reset} Output file ${blue}${output_directory}/${output_filename}${reset} does not exist." fi generate_video "${1}" fi } function cleanup() { if ! rm -rf "${base_temp_dir}"; then echo "Cleanup failed. You may need to manually remove ${base_temp_dir}." fi } function convert_timestamps() { local hours ((hours="${1}"/3600)) local minutes ((minutes=("${1}"%3600)/60)) local seconds ((seconds="${1}"%60)) printf "%02d:%02d:%02d\n" "${hours}" "${minutes}" "${seconds}" } function generate_timestamps() { local begin_number begin_number=$(("${1}"*"${display_time}")) local end_number end_number=$(((("${1}"+1)*"${display_time}")-1)) local subtitle_number subtitle_number=$(("${1}"+1)) local begin_timestamp begin_timestamp=$(convert_timestamps "${begin_number}") local end_timestamp end_timestamp=$(convert_timestamps "${end_number}") echo -e "${subtitle_number}\n${begin_timestamp},000 --> ${end_timestamp},999\n${basename}\n" >> "${subtitles}" } function generate_video() { local sequence_number sequence_number=0 local loop_temp_dir loop_temp_dir="${base_temp_dir}/${short_filename}" mkdir -p "${loop_temp_dir}" local webp_temp_dir webp_temp_dir="${loop_temp_dir}/webp" mkdir "${webp_temp_dir}" local subtitles subtitles="${loop_temp_dir}/subtitles.srt" local commands commands="${loop_temp_dir}/commands.txt" for file in "${1}"/*; do basename="$(basename "${file}")" generate_timestamps "${sequence_number}" sequence_number=$((sequence_number+1)) temp_filename="$(printf 'image%07d.webp' "${sequence_number}")" echo "convert -alpha off -define webp:lossless=true -define thread-level=1 \"${file}\" \"${webp_temp_dir}/${temp_filename}\" >/dev/null 2>&1 \ && echo \"${yellow}[CONVERT]${reset} Converted ${blue}${file}${reset}.\" || echo \"${red}[CONVERT]${reset} Failed to convert ${blue}${file}${reset}.\"" >> "${commands}" done if [ -s "${commands}" ]; then parallel --no-notice --jobs "${thread_count}" < "${commands}" else echo "No commands queued. Exiting." fi ffmpeg \ -hide_banner \ -loglevel error \ -stats \ -y \ -nostdin \ -f image2 \ -pattern_type glob \ -r 1/"${display_time}" \ -i "${webp_temp_dir}/*" \ -b:v 0 \ -filter_complex " \ [0]scale = \ w = $(echo "'")min(${output_width},iw)$(echo "'") : \ h = $(echo "'")min(${output_height},ih)$(echo "'") : \ force_original_aspect_ratio = decrease \ [a] ; \ [a]setsar = \ sar = 1 \ [b] ; \ [b]pad = \ w = ${output_width} : \ h = ${output_height} : \ x = (ow-iw) / 2 : \ y = (oh-ih) / 2 \ [c] ; \ [c]subtitles = \ filename = ${subtitles} \ [d] ; \ [d]format = \ pix_fmts = yuv420p \ [out]" \ -map "[out]" \ -c:v libvpx-vp9 \ -quality good \ -speed 4 \ -crf 24 \ -row-mt 1 \ -tile-columns 3 \ -threads "${thread_count}" \ -pass 1 \ -passlogfile "${base_temp_dir}/${output_filename}" \ -f null \ /dev/null && \ ffmpeg \ -hide_banner \ -loglevel error \ -stats \ -y \ -nostdin \ -f image2 \ -pattern_type glob \ -r 1/"${display_time}" \ -i "${webp_temp_dir}/*" \ -b:v 0 \ -filter_complex " \ [0]scale = \ w = $(echo "'")min(${output_width},iw)$(echo "'") : \ h = $(echo "'")min(${output_height},ih)$(echo "'") : \ force_original_aspect_ratio = decrease \ [a] ; \ [a]setsar = \ sar = 1 \ [b] ; \ [b]pad = \ w = ${output_width} : \ h = ${output_height} : \ x = (ow-iw) / 2 : \ y = (oh-ih) / 2 \ [c] ; \ [c]subtitles = \ filename = ${subtitles} \ [d] ; \ [d]format = \ pix_fmts = yuv420p \ [out]" \ -map "[out]" \ -c:v libvpx-vp9 \ -quality good \ -speed 4 \ -crf 24 \ -row-mt 1 \ -tile-columns 3 \ -threads "${thread_count}" \ -pass 2 \ -passlogfile "${base_temp_dir}/${output_filename}" \ "${output_directory}/${output_filename}" \ && if ! cp "${new_hashfile}" "${old_hashfile}"; then echo "Failed to update hashfile."; fi \ || echo "Generating video ${output_directory}/${output_filename} failed." if ! rm -rf "${loop_temp_dir}"; then echo "Failed to remove temporary directory ${loop_temp_dir}. You may need to manually remove it." fi } function main() { # create a temporary directory for storing commands to run base_temp_dir="$(mktemp -d)" # check for required script dependencies check_dependencies # create output directory mkdir -p "${output_directory}"/.exists >/dev/null 2>&1 || true # for the input directory and any subdirectories, convert all images to lossless webp then combine them into a slideshow while IFS=$'\n' read -r directory; do check_directory "${directory}" done < <(find "${input_directory}" -type d -exec readlink -f {} \; | sort) } # run the script main