#!/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="${HOME}/photos/" # directory to store output videos output_directory="/storage/x265" # time to display each image, in seconds display_time="4" # resolution of the output, in pixels output_width="2560" output_height="1440" # commands required for script execution required_commands="convert ffmpeg parallel systemd-run" # location to store/check for pidfile pidfile="/run/user/$(id -u)/generate-misc-x265.pid" # build systemd resource limits convert_systemd_command="systemd-run --quiet --user --scope" convert_systemd_command+=" -p CPUAccounting=true -p CPUQuota=75%" convert_systemd_command+=" -p IOAccounting=true -p IOWeight=50" convert_systemd_command+=" -p MemoryAccounting=true -p MemoryHigh=6% -p MemoryMax=8%" # build systemd resource limits ffmpeg_systemd_command="systemd-run --quiet --user --scope" ffmpeg_systemd_command+=" -p CPUAccounting=true -p CPUQuota=1200%" ffmpeg_systemd_command+=" -p IOAccounting=true -p IOWeight=50" ffmpeg_systemd_command+=" -p MemoryAccounting=true -p MemoryHigh=30% -p MemoryMax=35%" # use threads equal to half core count, rounded up thread_count="$(($(($(nproc)+1))/2))" # 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() { 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 local files local filecount shopt -s nullglob && files=("${1}"/*) && filecount="${#files[@]}" && shopt -u nullglob if [ "${filecount}" -lt 3 ]; then echo " ${green}[SKIP]${reset} Source path ${blue}${1}${reset} has fewer than 3 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}.mp4" 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 check_pid() { if [ -f "${pidfile}" ]; then echo "Another instance seems to already be running. Please kill it before running again." exit 1 else if ! { echo $$ > "${pidfile}"; } 2>/dev/null; then echo "Specified pidfile location ${pidfile} is not writable. Exiting." exit 1 fi fi } function cleanup { if ! { rm -rf "${base_temp_dir}" && rm "${pidfile}"; }; then echo "Cleanup failed. You may need to manually remove ${base_temp_dir} and/or ${pidfile}." 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_systemd_command} 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_systemd_command} ffmpeg \ -hide_banner \ -loglevel error \ -stats \ -y \ -nostdin \ -f image2 \ -pattern_type glob \ -r 1/"${display_time}" \ -i "${webp_temp_dir}/*" \ -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 libx265 \ -tag:v hvc1 \ -x265-params "log-level=error:keyint=25:min-keyint=25:scenecut=1" \ -crf 28 \ -preset medium \ -movflags +faststart \ -r 25 \ "${output_directory}/${output_filename}" \ || 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 if ! cp "${new_hashfile}" "${old_hashfile}"; then echo "Failed to update hashfile." fi } function main() { # check if another instance of the script is running, or didn't exit cleanly check_pid # create a temporary directory for storing commands to run base_temp_dir="$(mktemp -d)" # clean up pidfile and intermediate files on exit trap cleanup EXIT # 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 combines 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