summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-16 18:19:51 +0300
committerPaul Buetow <paul@buetow.org>2026-06-16 18:19:51 +0300
commitfe59a701586972793ea110b465f65d47b825fb8e (patch)
tree87ca3f64b695a0a8fa63d578be1aa4937bb59e01 /src/lib
parentcd7cf797bc633700c5ee7e986077f366c000f0b9 (diff)
in0 split album.source.sh into cohesive modules
Refactor the ~1442-line album.source.sh ([SRP] violation) into three cohesive modules, leaving album.source.sh a thin coordinator. Pure code move, no behavior change -- whole functions and their doc comments moved verbatim; the assembled bin/shuriken contains the same function set. - image-pipeline.source.sh: ImageMagick derivative generation (create_photo_derivatives, create_all_photo_derivatives) and photo asset prep (prepare_generation_photo_assets). - album-metadata.source.sh: EXIF identify caching, exif tooltip/details helpers, file counts, shuriken.json metadata, EXIF cache clearing and the dry-run plan. - album-render.source.sh: preview/view/details/splash/redirect page rendering, the parallel render-job orchestration and the ALBUM_VIEW_PAGE_BY_PHOTO global. - album.source.sh: thin coordinator (generate, refresh_splash, generate_stats_pages, site-asset/archive/html-clean helpers). Add the three modules to LIB_SOURCES (Justfile) and to the SHURIKEN_LIB_SOURCES markers in src/shuriken.sh, ordered after random and before album.source.sh / stats.source.sh. just build, just test, just shellcheck, just check-generated and git diff --check all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/album-metadata.source.sh493
-rw-r--r--src/lib/album-render.source.sh739
-rw-r--r--src/lib/album.source.sh1324
-rw-r--r--src/lib/image-pipeline.source.sh84
4 files changed, 1322 insertions, 1318 deletions
diff --git a/src/lib/album-metadata.source.sh b/src/lib/album-metadata.source.sh
new file mode 100644
index 0000000..9953fe3
--- /dev/null
+++ b/src/lib/album-metadata.source.sh
@@ -0,0 +1,493 @@
+photo_cache_signature() {
+ local -r photo="$1"; shift
+ local -r photo_path="$1"; shift
+ local stat_output
+
+ stat_output=$(stat -c '%s:%Y' "$photo_path")
+ printf '%s:%s\n' "$photo" "$stat_output"
+}
+
+print_cached_photo_identify_output() {
+ local -r cache_file="$1"; shift
+ local line
+ local skipped_signature=no
+
+ while IFS= read -r line || [ -n "$line" ]; do
+ if [ "$skipped_signature" = no ]; then
+ skipped_signature=yes
+ continue
+ fi
+ printf '%s\n' "$line"
+ done < "$cache_file"
+}
+
+cached_photo_identify_output() {
+ local -r photo="$1"; shift
+ local -r photo_path="$1"; shift
+ local cache_dir
+ local cache_file
+ local cached_signature=''
+ local current_signature
+
+ # Persist the EXIF cache in a volatile ./cache directory parallel to ./dist
+ # (the staging dir is a sibling of the final dist, so dirname "$DIST_DIR" is
+ # the working dir in both staging and direct contexts). Keeping it outside
+ # dist means it survives a fresh/cleared dist and is never deployed, so an
+ # unchanged photo skips the slow `identify -verbose` on every regenerate.
+ cache_dir="$(dirname "$DIST_DIR")/cache/exif"
+ cache_file="$cache_dir/$photo.txt"
+ current_signature=$(photo_cache_signature "$photo" "$photo_path")
+
+ # Reuse the cache when its signature still matches the source file. --force
+ # is handled once up front by clear_exif_cache (which empties this directory),
+ # so the first call per photo then rebuilds it and the rest of the run reuses
+ # it -- exactly one identify per photo even under force.
+ if [ -f "$cache_file" ]; then
+ IFS= read -r cached_signature < "$cache_file" || true
+ if [ "$cached_signature" = "$current_signature" ]; then
+ print_cached_photo_identify_output "$cache_file"
+ return
+ fi
+ fi
+
+ mkdir -p "$cache_dir"
+ printf '%s\n' "$current_signature" > "$cache_file"
+ imagemagick_identify -verbose "$photo_path" >> "$cache_file" 2>/dev/null \
+ || true
+ print_cached_photo_identify_output "$cache_file"
+}
+
+photo_exif_details_html() {
+ local -r photo="$1"; shift
+ local -r photo_path="$1"; shift
+ local key
+ local key_html
+ local line
+ local value
+ local value_html
+ local -i exif_count=0
+
+ while IFS= read -r line; do
+ if [[ "$line" =~ ^[[:space:]]*exif:([^:]+):[[:space:]]*(.*)$ ]]; then
+ key="exif:${BASH_REMATCH[1]}"
+ value="${BASH_REMATCH[2]}"
+ key_html=$(_html_escape "$key")
+ value_html=$(_html_escape "$value")
+
+ if (( exif_count == 0 )); then
+ printf '<table class="details">\n'
+ printf '<tbody>\n'
+ fi
+
+ printf '<tr><th>%s</th><td>%s</td></tr>\n' \
+ "$key_html" \
+ "$value_html"
+ (( ++exif_count ))
+ fi
+ done < <(cached_photo_identify_output "$photo" "$photo_path")
+
+ if (( exif_count == 0 )); then
+ printf '<p class="details-empty">No EXIF details available.</p>\n'
+ return
+ fi
+
+ printf '</tbody>\n'
+ printf '</table>\n'
+}
+
+_first_exif_value_to() {
+ local -n output_ref="$1"; shift
+ local -n exif_ref="$1"; shift
+ local key
+
+ output_ref=''
+ for key in "$@"; do
+ if [ -n "${exif_ref[$key]:-}" ]; then
+ # shellcheck disable=SC2034
+ output_ref="${exif_ref[$key]}"
+ return
+ fi
+ done
+}
+
+_photo_exif_values_to() {
+ local -n output_ref="$1"; shift
+ local -r photo="$1"; shift
+ local -r photo_path="$1"; shift
+ local line
+
+ output_ref=()
+ while IFS= read -r line; do
+ if [[ "$line" =~ ^[[:space:]]*exif:([^:]+):[[:space:]]*(.*)$ ]]; then
+ # output_ref writes to the caller-provided associative array.
+ # shellcheck disable=SC2034
+ output_ref["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}"
+ fi
+ done < <(cached_photo_identify_output "$photo" "$photo_path")
+}
+
+_photo_exif_tooltip_text_from_values() {
+ local -r exif_name="$1"; shift
+ local -n values_ref="$exif_name"
+ local aperture
+ local camera
+ local date_time
+ local iso
+ local key
+ local make
+ local model
+ local separator=''
+ local shutter_speed
+ local -a tooltip_parts=()
+
+ make="${values_ref[Make]:-}"
+ model="${values_ref[Model]:-}"
+ camera="$make"
+ if [ -n "$model" ]; then
+ if [ -n "$make" ]; then
+ case "$model" in
+ "$make"|"$make "*)
+ camera="$model"
+ ;;
+ *)
+ camera="$make $model"
+ ;;
+ esac
+ else
+ camera="$model"
+ fi
+ fi
+
+ _first_exif_value_to aperture "$exif_name" FNumber ApertureValue
+ _first_exif_value_to iso "$exif_name" \
+ ISOSpeedRatings PhotographicSensitivity ISO
+ _first_exif_value_to shutter_speed "$exif_name" \
+ ExposureTime ShutterSpeedValue
+ _first_exif_value_to date_time "$exif_name" \
+ DateTimeOriginal DateTimeDigitized DateTime
+
+ if [ -n "$camera" ]; then
+ tooltip_parts+=("Camera: $camera")
+ fi
+ if [ -n "$aperture" ]; then
+ tooltip_parts+=("Aperture: $aperture")
+ fi
+ if [ -n "$iso" ]; then
+ tooltip_parts+=("ISO: $iso")
+ fi
+ if [ -n "$shutter_speed" ]; then
+ tooltip_parts+=("Shutter speed: $shutter_speed")
+ fi
+ if [ -n "$date_time" ]; then
+ tooltip_parts+=("Taken: $date_time")
+ fi
+
+ for key in "${tooltip_parts[@]}"; do
+ printf '%s%s' "$separator" "$key"
+ separator='; '
+ done
+ if (( ${#tooltip_parts[@]} > 0 )); then
+ printf '\n'
+ fi
+}
+
+photo_exif_tooltip_text() {
+ local -r photo="$1"; shift
+ local -r photo_path="$1"; shift
+ # exif_values is populated and read through nameref helpers.
+ # shellcheck disable=SC2034
+ local -A exif_values=()
+
+ _photo_exif_values_to exif_values "$photo" "$photo_path"
+ _photo_exif_tooltip_text_from_values exif_values
+}
+
+count_files() {
+ local -r dir="$1"; shift
+ local -r name="${1:-}"; shift || true
+
+ if [ ! -d "$dir" ]; then
+ printf '0\n'
+ return
+ fi
+
+ if [ -n "$name" ]; then
+ find "$dir" -maxdepth 1 -type f -name "$name" | wc -l
+ else
+ find "$dir" -maxdepth 1 -type f | wc -l
+ fi
+}
+
+count_incoming_images() {
+ incoming_image_files | wc -l
+}
+
+tarball_name_plan() {
+ local base
+
+ base=$(basename "$INCOMING_DIR")
+ printf '%s-<timestamp>%s\n' "$base" "$TARBALL_SUFFIX"
+}
+
+generated_tarball_name() {
+ local base
+ local timestamp
+
+ base=$(basename "$INCOMING_DIR")
+ timestamp=$(current_timestamp_slug)
+ printf '%s-%s%s\n' "$base" "$timestamp" "$TARBALL_SUFFIX"
+}
+
+count_tree_files() {
+ local -r dir="$1"; shift
+ local -r name="$1"; shift
+
+ if [ ! -d "$dir" ]; then
+ printf '0\n'
+ return
+ fi
+
+ find "$dir" -type f -name "$name" | wc -l
+}
+
+_collect_generation_metadata() {
+ local -r tarball_file="$1"; shift
+
+ declare -gA _GENERATION_METADATA=()
+ _GENERATION_METADATA["generator_name"]='shuriken'
+ _GENERATION_METADATA["generator_version"]="$VERSION"
+ _GENERATION_METADATA["generated_at"]=$(current_timestamp_iso)
+ _GENERATION_METADATA["config_source"]="$SHURIKEN_CONFIG_SOURCE"
+ _GENERATION_METADATA["template_name"]=$(basename "$TEMPLATE_DIR")
+ _GENERATION_METADATA["template_directory"]="$TEMPLATE_DIR"
+ _GENERATION_METADATA["source_incoming_dir"]="$INCOMING_DIR"
+ _GENERATION_METADATA["source_image_count"]=$(count_incoming_images)
+ _GENERATION_METADATA["generated_photo_count"]=$(count_files "$DIST_DIR/photos")
+ _GENERATION_METADATA["generated_thumb_count"]=$(count_files "$DIST_DIR/thumbs")
+ _GENERATION_METADATA["generated_html_count"]=$(
+ count_tree_files "$DIST_DIR" '*.html'
+ )
+ _GENERATION_METADATA["tarball_included"]="$TARBALL_INCLUDE"
+ _GENERATION_METADATA["tarball_file"]="$tarball_file"
+ _GENERATION_METADATA["settings_title"]="$TITLE"
+ _GENERATION_METADATA["settings_height"]="$HEIGHT"
+ _GENERATION_METADATA["settings_thumbheight"]="$THUMBHEIGHT"
+ _GENERATION_METADATA["settings_maxpreviews"]="$MAXPREVIEWS"
+ _GENERATION_METADATA["settings_image_jobs"]="$IMAGE_JOBS"
+ _GENERATION_METADATA["settings_random_seed"]="$RANDOM_SEED"
+ _GENERATION_METADATA["settings_shuffle"]="$SHUFFLE"
+ _GENERATION_METADATA["settings_splash_page"]="$SPLASH_PAGE"
+ _GENERATION_METADATA["settings_stats_page"]="$STATS_PAGE"
+ _GENERATION_METADATA["settings_original_basepath"]="$ORIGINAL_BASEPATH"
+}
+
+_generation_metadata_json() {
+ printf '{\n'
+ printf ' "generator": {\n'
+ printf ' "name": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["generator_name"]}")"
+ printf ' "version": %s\n' \
+ "$(_json_string "${_GENERATION_METADATA["generator_version"]}")"
+ printf ' },\n'
+ printf ' "generated_at": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["generated_at"]}")"
+ printf ' "config_source": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["config_source"]}")"
+ printf ' "template": {\n'
+ printf ' "name": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["template_name"]}")"
+ printf ' "directory": %s\n' \
+ "$(_json_string "${_GENERATION_METADATA["template_directory"]}")"
+ printf ' },\n'
+ printf ' "source": {\n'
+ printf ' "incoming_dir": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["source_incoming_dir"]}")"
+ printf ' "image_count": %s\n' \
+ "${_GENERATION_METADATA["source_image_count"]}"
+ printf ' },\n'
+ printf ' "generated": {\n'
+ printf ' "photo_count": %s,\n' \
+ "${_GENERATION_METADATA["generated_photo_count"]}"
+ printf ' "thumb_count": %s,\n' \
+ "${_GENERATION_METADATA["generated_thumb_count"]}"
+ printf ' "html_count": %s\n' \
+ "${_GENERATION_METADATA["generated_html_count"]}"
+ printf ' },\n'
+ printf ' "tarball": {\n'
+ printf ' "included": %s,\n' \
+ "$(_json_bool "${_GENERATION_METADATA["tarball_included"]}")"
+ printf ' "file": %s\n' \
+ "$(_json_string "${_GENERATION_METADATA["tarball_file"]}")"
+ printf ' },\n'
+ printf ' "settings": {\n'
+ printf ' "title": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_title"]}")"
+ printf ' "height": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_height"]}")"
+ printf ' "thumbheight": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_thumbheight"]}")"
+ printf ' "maxpreviews": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_maxpreviews"]}")"
+ printf ' "image_jobs": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_image_jobs"]}")"
+ printf ' "random_seed": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_random_seed"]}")"
+ printf ' "shuffle": %s,\n' \
+ "$(_json_bool "${_GENERATION_METADATA["settings_shuffle"]}")"
+ printf ' "splash_page": %s,\n' \
+ "$(_json_bool "${_GENERATION_METADATA["settings_splash_page"]}")"
+ printf ' "stats_page": %s,\n' \
+ "$(_json_bool "${_GENERATION_METADATA["settings_stats_page"]}")"
+ printf ' "original_basepath": %s\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_original_basepath"]}")"
+ printf ' }\n'
+ printf '}\n'
+}
+
+write_generation_metadata() {
+ local -r tarball_file="$1"; shift
+
+ _collect_generation_metadata "$tarball_file"
+ {
+ _generation_metadata_json
+ } > "$DIST_DIR/shuriken.json"
+}
+
+# Empty the volatile EXIF cache (./cache/exif, parallel to ./dist) so a --force
+# run re-runs `identify` from scratch. Done once up front; the cache then
+# repopulates and is reused for the rest of the run (one identify per photo).
+clear_exif_cache() {
+ local -r cache_dir="$(dirname "$DIST_DIR")/cache/exif"
+
+ log_verbose "Force generation; clearing EXIF cache $cache_dir"
+ rm -rf "$cache_dir"
+}
+
+dry_run() {
+ # shellcheck disable=SC2034
+ local -A dry_run_plan=()
+
+ collect_dry_run_plan dry_run_plan
+ print_dry_run_plan dry_run_plan
+}
+
+collect_dry_run_page_plan() {
+ local -r plan_name="$1"; shift
+ # shellcheck disable=SC2178
+ local -n plan_ref="$plan_name"
+ local -r image_count="$1"; shift
+ local -i page_count=0
+ local -i redirect_count=0
+
+ plan_ref["html_index_count"]=1
+ plan_ref["page_count"]=0
+ plan_ref["redirect_count"]=0
+ plan_ref["details_count"]=0
+
+ if (( image_count > 0 )); then
+ page_count=$(( (image_count + MAXPREVIEWS - 1) / MAXPREVIEWS ))
+ redirect_count=$(( page_count * 4 + 2 ))
+ plan_ref["details_count"]="$image_count"
+ plan_ref["page_count"]="$page_count"
+ plan_ref["redirect_count"]="$redirect_count"
+ fi
+}
+
+collect_dry_run_plan() {
+ local -r plan_name="$1"; shift
+ # shellcheck disable=SC2178
+ local -n plan_ref="$plan_name"
+ local -i image_count=0
+
+ image_count=$(count_incoming_images)
+ plan_ref=()
+ plan_ref["config_source"]="$SHURIKEN_CONFIG_SOURCE"
+ plan_ref["incoming_dir"]="$INCOMING_DIR"
+ plan_ref["dist_dir"]="$DIST_DIR"
+ plan_ref["template_dir"]="$TEMPLATE_DIR"
+ plan_ref["title"]="$TITLE"
+ plan_ref["height"]="$HEIGHT"
+ plan_ref["thumbheight"]="$THUMBHEIGHT"
+ plan_ref["maxpreviews"]="$MAXPREVIEWS"
+ plan_ref["image_jobs"]="$IMAGE_JOBS"
+ plan_ref["random_seed"]="$RANDOM_SEED"
+ plan_ref["shuffle"]="$SHUFFLE"
+ plan_ref["splash_page"]="$SPLASH_PAGE"
+ plan_ref["stats_page"]="$STATS_PAGE"
+ plan_ref["image_count"]="$image_count"
+ plan_ref["tarball_include"]="$TARBALL_INCLUDE"
+ plan_ref["tarball_name_plan"]='not planned'
+
+ if [ "$TARBALL_INCLUDE" = yes ]; then
+ plan_ref["tarball_name_plan"]=$(tarball_name_plan)
+ fi
+
+ collect_dry_run_page_plan "$plan_name" "$image_count"
+}
+
+print_dry_run_plan() {
+ local -r plan_name="$1"; shift
+ # shellcheck disable=SC2178
+ local -n plan_ref="$plan_name"
+
+ printf 'Dry run: no files will be written.\n'
+ printf 'Config source: %s\n' "${plan_ref["config_source"]}"
+ printf 'Incoming directory: %s\n' "${plan_ref["incoming_dir"]}"
+ printf 'Output directory: %s\n' "${plan_ref["dist_dir"]}"
+ printf 'Template directory: %s\n' "${plan_ref["template_dir"]}"
+ printf 'Title: %s\n' "${plan_ref["title"]}"
+ printf 'Height: %s\n' "${plan_ref["height"]}"
+ printf 'Thumb height: %s\n' "${plan_ref["thumbheight"]}"
+ printf 'Max previews per page: %s\n' "${plan_ref["maxpreviews"]}"
+ printf 'Image jobs: %s\n' "${plan_ref["image_jobs"]}"
+ printf 'Random seed: %s\n' "${plan_ref["random_seed"]}"
+ printf 'Shuffle: %s\n' "${plan_ref["shuffle"]}"
+ printf 'Splash page: %s\n' "${plan_ref["splash_page"]}"
+ printf 'Stats page: %s\n' "${plan_ref["stats_page"]}"
+ printf 'Image count: %s\n' "${plan_ref["image_count"]}"
+ printf 'Tarball setting: %s\n' "${plan_ref["tarball_include"]}"
+ printf 'Tarball name plan: %s\n' "${plan_ref["tarball_name_plan"]}"
+
+ printf 'Planned directories:\n'
+ printf ' %s\n' "${plan_ref["dist_dir"]}"
+ printf ' %s/photos\n' "${plan_ref["dist_dir"]}"
+ printf ' %s/thumbs\n' "${plan_ref["dist_dir"]}"
+ printf ' %s/blurs\n' "${plan_ref["dist_dir"]}"
+
+ printf 'Planned generated files:\n'
+ if [ "${plan_ref["splash_page"]}" = yes ]; then
+ printf ' %s/index.html (%s splash page)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["html_index_count"]}"
+ else
+ printf ' %s/index.html (%s album index redirect)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["html_index_count"]}"
+ fi
+ printf ' %s/favicon.ico\n' "${plan_ref["dist_dir"]}"
+ printf ' %s/shuriken.json\n' "${plan_ref["dist_dir"]}"
+ printf ' %s/photos/* (%s image files)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["image_count"]}"
+ printf ' %s/thumbs/* (%s image files)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["image_count"]}"
+ printf ' %s/blurs/* (%s image files)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["image_count"]}"
+ printf ' %s/page-*.html (%s preview pages)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["page_count"]}"
+ printf ' %s/[page]-[image].html (%s view pages)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["image_count"]}"
+ printf ' %s/[page]-[image]-details.html (%s details pages)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["details_count"]}"
+ printf ' %s/[redirect].html (%s navigation redirects)\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["redirect_count"]}"
+ if [ "${plan_ref["stats_page"]}" = yes ]; then
+ # The exact filter mini-album set needs EXIF aggregation, which dry-run
+ # does not perform, so list them as a wildcard under stats/.
+ printf ' %s/stats/index.html (EXIF stats page)\n' \
+ "${plan_ref["dist_dir"]}"
+ printf ' %s/stats/*/ (filter mini-albums)\n' \
+ "${plan_ref["dist_dir"]}"
+ fi
+ if [ "${plan_ref["tarball_include"]}" = yes ]; then
+ printf ' %s/%s\n' \
+ "${plan_ref["dist_dir"]}" "${plan_ref["tarball_name_plan"]}"
+ fi
+}
diff --git a/src/lib/album-render.source.sh b/src/lib/album-render.source.sh
new file mode 100644
index 0000000..ff86727
--- /dev/null
+++ b/src/lib/album-render.source.sh
@@ -0,0 +1,739 @@
+# Maps each album photo filename to its view-page basename ("<page>-<preview>")
+# as assigned during render_album_pages. The stats filter mini-albums read this
+# so each view page can link "Details" to the album's own details page for the
+# photo. Declared globally so it always exists for callers even when no album
+# was rendered.
+declare -gA ALBUM_VIEW_PAGE_BY_PHOTO=()
+
+album_photo_files() {
+ local -r photos_dir="$1"; shift
+
+ find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \
+ | maybe_shuffle
+}
+
+start_preview_page() {
+ local -r photos_dir="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r page_name="$1"; shift
+ local -r header_bar="$1"; shift
+ local background_image
+
+ background_image=$(randomphoto "$photos_dir" "$page_name")
+ template header "$page_name.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ blurs_dir "$blurs_dir" \
+ background_image "$background_image" \
+ show_header_bar "$header_bar"
+}
+
+finish_preview_page() {
+ local -r page_name="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+
+ template footer "$page_name.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ tarball_name "$tarball_name"
+}
+
+finish_preview_page_with_next() {
+ local -r page_name="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+ local -r next_page="$1"; shift
+ local -r prev_page="$1"; shift
+
+ template next "$page_name.html" \
+ html_dir "$html_dir" \
+ next "$next_page" \
+ prev "$prev_page"
+ finish_preview_page "$page_name" "$html_dir" "$backhref" "$tarball_name"
+}
+
+render_previous_page_link() {
+ local -r page_name="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r prev_page="$1"; shift
+
+ template prev "$page_name.html" \
+ html_dir "$html_dir" \
+ prev "$prev_page"
+}
+
+# Assemble one complete preview page (page-N.html) in a single call: header,
+# optional previous-page link, every thumbnail in order, then the footer (with
+# a next-page link unless this is the last page). The appends to the one page
+# file happen here, in sequence, so the per-page ordering is preserved even when
+# this whole function runs as one backgrounded render job (parallelism is only
+# ACROSS pages, never within a page). The page's photos are the trailing
+# positional arguments; each photo's preview index is its 1-based position.
+render_full_preview_page() {
+ local -r photos_dir="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r thumbs_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+ local -r page_name="$1"; shift
+ local -r page_num="$1"; shift
+ local -r header_bar="$1"; shift
+ local -r prev_page="$1"; shift
+ local -r next_page="$1"; shift
+ local -i preview_num=0
+ local photo
+
+ start_preview_page \
+ "$photos_dir" "$html_dir" "$blurs_dir" "$backhref" "$page_name" \
+ "$header_bar"
+ if [ -n "$prev_page" ]; then
+ render_previous_page_link "$page_name" "$html_dir" "$prev_page"
+ fi
+
+ for photo in "$@"; do
+ (( ++preview_num ))
+ render_preview_thumbnail \
+ "$page_name" "$html_dir" "$thumbs_dir" "$backhref" \
+ "$page_num" "$preview_num" "$photo"
+ done
+
+ if [ -n "$next_page" ]; then
+ finish_preview_page_with_next \
+ "$page_name" "$html_dir" "$backhref" "$tarball_name" \
+ "$next_page" "$prev_page"
+ else
+ finish_preview_page "$page_name" "$html_dir" "$backhref" "$tarball_name"
+ fi
+}
+
+# Enqueue one complete preview page as a background render job, throttled to
+# IMAGE_JOBS via the shared template render job pool. Mirrors
+# queue_album_view_render_job: wait for a free slot, background the whole-page
+# assembly, then track its pid/label so a failed job flips render_failed and
+# makes generation fail loudly. The page's photos are passed as trailing
+# positional args; the background subshell forks a private copy of them, so the
+# caller is free to reuse its per-page accumulator for the next page.
+queue_preview_page_render_job() {
+ local -r photos_dir="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r thumbs_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+ local -r page_name="$1"; shift
+ local -r page_num="$1"; shift
+ local -r header_bar="$1"; shift
+ local -r prev_page="$1"; shift
+ local -r next_page="$1"; shift
+ # shellcheck disable=SC2178
+ local -n render_job_pids_ref="$1"; shift
+ # Passed by name to wait_for_template_render_job_slot.
+ # shellcheck disable=SC2034,SC2178
+ local -n render_job_statuses_ref="$1"; shift
+ # Passed by name to wait_for_template_render_job_slot and assigned below.
+ # shellcheck disable=SC2034,SC2178
+ local -n render_job_labels_ref="$1"; shift
+ # shellcheck disable=SC2178
+ local -n render_failed_ref="$1"; shift
+ # Remaining positional args ("$@") are this page's photos in order.
+
+ wait_for_template_render_job_slot \
+ render_job_pids_ref \
+ render_job_statuses_ref \
+ render_job_labels_ref \
+ render_failed_ref
+ render_full_preview_page \
+ "$photos_dir" \
+ "$html_dir" \
+ "$thumbs_dir" \
+ "$blurs_dir" \
+ "$backhref" \
+ "$tarball_name" \
+ "$page_name" \
+ "$page_num" \
+ "$header_bar" \
+ "$prev_page" \
+ "$next_page" \
+ "$@" &
+ render_job_pids_ref+=("$!")
+ render_job_labels_ref["$!"]="template render job for preview $page_name"
+}
+
+render_preview_thumbnail() {
+ local -r page_name="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r thumbs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r page_num="$1"; shift
+ local -r preview_num="$1"; shift
+ local -r photo_file="$1"; shift
+ local animation_class
+
+ animation_class=$(random_animation_css_class slow "$photo_file")
+ template preview "$page_name.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ thumbs_dir "$thumbs_dir" \
+ page_num "$page_num" \
+ preview_num "$preview_num" \
+ photo "$photo_file" \
+ animation_class "$animation_class"
+}
+
+render_view_page() {
+ local -r html_dir="$1"; shift
+ local -r photos_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+ local -r page_num="$1"; shift
+ local -r preview_num="$1"; shift
+ local -r photo_file="$1"; shift
+ local animation_class
+ local exif_tooltip_text
+
+ template header "$page_num-$preview_num.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ blurs_dir "$blurs_dir" \
+ background_image "$photo_file" \
+ show_header_bar 'no'
+
+ animation_class=$(random_animation_css_class fast "$photo_file")
+ # Reuse the same EXIF tooltip as the details page so hovering the image in
+ # the normal view shows the camera/exposure summary (reads the shared
+ # identify cache, so no extra ImageMagick work).
+ exif_tooltip_text=$(
+ photo_exif_tooltip_text "$photo_file" "$INCOMING_DIR/$photo_file"
+ )
+ template view "$page_num-$preview_num.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ photos_dir "$photos_dir" \
+ page_num "$page_num" \
+ preview_num "$preview_num" \
+ photo "$photo_file" \
+ animation_class "$animation_class" \
+ exif_tooltip "$exif_tooltip_text"
+ template footer "$page_num-$preview_num.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ tarball_name "$tarball_name"
+}
+
+render_details_page() {
+ local -r html_dir="$1"; shift
+ local -r photos_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+ local -r page_num="$1"; shift
+ local -r preview_num="$1"; shift
+ local -r photo_file="$1"; shift
+ local animation_class
+ local exif_details_html
+ local exif_tooltip_text
+
+ template header "$page_num-$preview_num-details.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ blurs_dir "$blurs_dir" \
+ background_image "$photo_file" \
+ show_header_bar 'no'
+
+ animation_class=$(random_animation_css_class fast "$photo_file")
+ exif_details_html=$(
+ photo_exif_details_html "$photo_file" "$INCOMING_DIR/$photo_file"
+ )
+ exif_tooltip_text=$(
+ photo_exif_tooltip_text "$photo_file" "$INCOMING_DIR/$photo_file"
+ )
+ template details "$page_num-$preview_num-details.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ photos_dir "$photos_dir" \
+ page_num "$page_num" \
+ preview_num "$preview_num" \
+ photo "$photo_file" \
+ animation_class "$animation_class" \
+ exif_details "$exif_details_html" \
+ exif_tooltip "$exif_tooltip_text"
+ template footer "$page_num-$preview_num-details.html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ tarball_name "$tarball_name"
+}
+
+render_photo_view_and_details() {
+ local -r photos_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+ local -r page_num="$1"; shift
+ local -r preview_num="$1"; shift
+ local -r photo="$1"; shift
+
+ render_view_page \
+ "$html_dir" \
+ "$photos_dir" \
+ "$blurs_dir" \
+ "$backhref" \
+ "$tarball_name" \
+ "$page_num" \
+ "$preview_num" \
+ "$photo"
+ render_details_page \
+ "$html_dir" \
+ "$photos_dir" \
+ "$blurs_dir" \
+ "$backhref" \
+ "$tarball_name" \
+ "$page_num" \
+ "$preview_num" \
+ "$photo"
+}
+
+record_rendered_view_page() {
+ # shellcheck disable=SC2178
+ local -n view_pages_ref="$1"; shift
+ # shellcheck disable=SC2178
+ local -n last_views_ref="$1"; shift
+ local -r page="$1"; shift
+ local -r preview="$1"; shift
+
+ if [ -z "${last_views_ref[$page]+set}" ]; then
+ view_pages_ref+=("$page")
+ fi
+ last_views_ref["$page"]="$preview"
+}
+
+# Render every navigation redirect for a single view page (the prev/next
+# wrap-around stubs that bounce N-0 / N-(last+1) to the neighbouring page).
+# Each redirect is its own self-contained file (the template overwrites it), so
+# this whole group is safe to run as one independent background job; only the
+# files for distinct pages are produced here. The wrap-around redirects for the
+# very last page (0-MAXPREVIEWS and the loop-to-1 links) are emitted as part of
+# that page's group.
+render_page_view_redirects() {
+ local -r html_dir="$1"; shift
+ local -ri page="$1"; shift
+ local -ri lastview="$1"; shift
+ local -ri max_page="$1"; shift
+ local -r prevredirect="${page}-0"
+ local -r nextredirect="${page}-$(( lastview + 1 ))"
+
+ template redirect "$prevredirect.html" \
+ html_dir "$html_dir" \
+ redirect_page "$(( page - 1 ))-${MAXPREVIEWS}"
+ template redirect "$prevredirect-details.html" \
+ html_dir "$html_dir" \
+ redirect_page "$(( page - 1 ))-${MAXPREVIEWS}-details"
+
+ if (( page == max_page )); then
+ template redirect "0-$MAXPREVIEWS.html" \
+ html_dir "$html_dir" \
+ redirect_page "${page}-$lastview"
+ template redirect "0-$MAXPREVIEWS-details.html" \
+ html_dir "$html_dir" \
+ redirect_page "${page}-$lastview-details"
+ template redirect "$nextredirect.html" \
+ html_dir "$html_dir" \
+ redirect_page '1-1'
+ template redirect "$nextredirect-details.html" \
+ html_dir "$html_dir" \
+ redirect_page '1-1-details'
+ else
+ template redirect "$nextredirect.html" \
+ html_dir "$html_dir" \
+ redirect_page "$(( page + 1 ))-1"
+ template redirect "$nextredirect-details.html" \
+ html_dir "$html_dir" \
+ redirect_page "$(( page + 1 ))-1-details"
+ fi
+}
+
+# Render all navigation redirects in parallel, one background job per view page,
+# throttled to IMAGE_JOBS via the shared template render job pool. This blocks
+# until every redirect is on disk (and fails loudly if any job failed), so
+# callers can rely on the redirects existing once it returns -- the redirect
+# groups are mutually independent, so only timing changes, not output.
+render_view_redirects() {
+ local -r html_dir="$1"; shift
+ # shellcheck disable=SC2178
+ local -n view_pages_ref="$1"; shift
+ # shellcheck disable=SC2178
+ local -n redirect_last_views_ref="$1"; shift
+ local max_page
+ local page
+ # Render job pool, throttled to IMAGE_JOBS by the job-pool helpers.
+ # shellcheck disable=SC2034
+ local -a render_job_pids=()
+ # shellcheck disable=SC2034
+ local -A render_job_statuses=()
+ # shellcheck disable=SC2034
+ local -A render_job_labels=()
+ local -i render_failed=0
+
+ if (( ${#view_pages_ref[@]} == 0 )); then
+ return
+ fi
+
+ max_page=${view_pages_ref[$(( ${#view_pages_ref[@]} - 1 ))]}
+
+ for page in "${view_pages_ref[@]}"; do
+ wait_for_template_render_job_slot \
+ render_job_pids render_job_statuses render_job_labels render_failed
+ render_page_view_redirects \
+ "$html_dir" "$page" "${redirect_last_views_ref[$page]}" \
+ "$max_page" &
+ render_job_pids+=("$!")
+ # shellcheck disable=SC2034
+ render_job_labels["$!"]="template render job for redirect page $page"
+ done
+
+ wait_for_template_render_jobs \
+ render_job_pids render_job_statuses render_job_labels render_failed
+ if (( render_failed != 0 )); then
+ return 1
+ fi
+}
+
+render_album_index_redirect() {
+ local -r html_dir="$1"; shift
+
+ template 'redirect' 'index.html' \
+ html_dir "$html_dir" \
+ redirect_page 'page-1'
+}
+
+render_album_splash_page() {
+ local -r photos_dir="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local html='index.html'
+ local photo
+
+ if (( $# > 0 )); then
+ html="$1"
+ shift
+ fi
+
+ photo=$(random_splash_photo "$photos_dir" "$blurs_dir")
+ template 'splash' "$html" \
+ html_dir "$html_dir" \
+ backhref "$backhref" \
+ blurs_dir "$blurs_dir" \
+ background_image "$photo" \
+ photos_dir "$photos_dir" \
+ photo "$photo" \
+ enter_page 'page-1'
+}
+
+render_album_index() {
+ local -r photos_dir="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r backhref="$1"; shift
+
+ if [ "$SPLASH_PAGE" = yes ]; then
+ render_album_splash_page "$photos_dir" "$html_dir" "$blurs_dir" \
+ "$backhref"
+ else
+ render_album_index_redirect "$html_dir"
+ fi
+}
+
+album_page_name() {
+ local -r page_num="$1"; shift
+
+ printf 'page-%s\n' "$page_num"
+}
+
+queue_album_view_render_job() {
+ local -r photos_dir="$1"; shift
+ local -r blurs_dir="$1"; shift
+ local -r html_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r tarball_name="$1"; shift
+ local -r page_num="$1"; shift
+ local -r preview_num="$1"; shift
+ local -r photo="$1"; shift
+ # shellcheck disable=SC2178
+ local -n render_job_pids_ref="$1"; shift
+ # Passed by name to wait_for_template_render_job_slot.
+ # shellcheck disable=SC2034,SC2178
+ local -n render_job_statuses_ref="$1"; shift
+ # Passed by name to wait_for_template_render_job_slot and assigned below.
+ # shellcheck disable=SC2034,SC2178
+ local -n render_job_labels_ref="$1"; shift
+ # shellcheck disable=SC2178
+ local -n render_failed_ref="$1"; shift
+
+ wait_for_template_render_job_slot \
+ render_job_pids_ref \
+ render_job_statuses_ref \
+ render_job_labels_ref \
+ render_failed_ref
+ render_photo_view_and_details \
+ "$photos_dir" \
+ "$blurs_