summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 09:52:57 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 09:52:57 +0300
commit6945a0ae9afb18313636c09f34959ee5b225f78d (patch)
treee74aa7b861543f5407de863f9667b4ccc994bbbd
parent79d78a296d2919155fe4d4bb2fac73d2fa7adeec (diff)
Add dynamic subdivided thumbnail tiles
Make the album preview grid livelier: with a configurable probability (THUMB_SUBDIVIDE_PERCENT, default 30%) a square thumbnail tile is subdivided into several smaller thumbnails packed into the same square footprint, chosen at random from: - quad: 2x2 squares (4 photos) - two-wide: two stacked full-width strips (2 photos) - squares+wide: two squares plus one full-width strip, strip on the top or the bottom (3 photos) Each sub-thumbnail stays its own clickable photo with its own view page; subdivision only groups consecutive photos visually, so preview numbering and the view/details/redirect pages are unchanged. No new images are generated (CSS object-fit crops the existing aspect-correct thumbs into squares or wide strips). Sub-thumbnails get the same random entry animation and the same dramatic hover (flip/scale/rotate/filter) as full thumbs. The layout choice reuses the seeded random_index, so builds stay reproducible under RANDOM_SEED. THUMB_SUBDIVIDE_PERCENT=0 reproduces the previous output byte-for-byte. The new option is wired through the config defaults, validation (0..100), --print-config, the --subdivide CLI flag, the usage text, and the shuriken.json / --dry-run metadata, with docs and tests updated. Generated HTML and CSS pass the W3C Nu HTML checker and CSS validator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--AGENTS.md19
-rwxr-xr-xbin/shuriken213
-rw-r--r--docs/configuration.md5
-rw-r--r--docs/generation.md6
-rw-r--r--docs/usage.md1
-rw-r--r--share/templates/default/header.tmpl53
-rw-r--r--src/lib/album-metadata.source.sh5
-rw-r--r--src/lib/album-render.source.sh187
-rw-r--r--src/lib/bootstrap.source.sh1
-rw-r--r--src/lib/config.print.source.sh1
-rw-r--r--src/lib/config.source.sh3
-rw-r--r--src/lib/config.validate.source.sh14
-rw-r--r--src/shuriken.default.conf5
-rwxr-xr-xsrc/shuriken.sh2
-rwxr-xr-xtests/cli.sh54
15 files changed, 498 insertions, 71 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 0bc5a71..f827492 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,3 +13,22 @@ Expected checks for Bash changes:
- `just shellcheck`
- `just check-generated`
- `git diff --check`
+
+## Manual visual testing
+
+To eyeball generated output (thumbnail grid, tile subdivision, splash, stats),
+regenerate the real `irregular.ninja` album with the freshly built binary and
+template, then open it in Firefox:
+
+```sh
+just build
+cd ~/git/irregular.ninja/irregular.ninja
+/path/to/shuriken.sh/bin/shuriken --generate \
+ --template /path/to/shuriken.sh/share/templates/default
+firefox "file://$PWD/dist/index.html"
+```
+
+Use the in-tree `bin/shuriken` and `share/templates/default` (not the installed
+copies) so local changes are exercised. Existing thumbnails/blurs are reused, so
+only the HTML is re-rendered. This regenerates locally only — do **not** run
+`shuriken --sync` / `just sync`, which would publish to the live web servers.
diff --git a/bin/shuriken b/bin/shuriken
index 2cd6c8b..7727cde 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -37,6 +37,7 @@ declare -ra CLI_CONFIG_OVERRIDE_TARGETS=(
HEIGHT
THUMBHEIGHT
MAXPREVIEWS
+ THUMB_SUBDIVIDE_PERCENT
IMAGE_JOBS
RANDOM_SEED
SHUFFLE
@@ -57,6 +58,7 @@ declare -Ar CLI_OPTION_SPEC=(
[--height]='kind=value config=HEIGHT'
[--thumbheight]='kind=value config=THUMBHEIGHT'
[--maxpreviews]='kind=value config=MAXPREVIEWS'
+ [--subdivide]='kind=value config=THUMB_SUBDIVIDE_PERCENT'
[--image-jobs]='kind=value config=IMAGE_JOBS'
[--random-seed]='kind=value config=RANDOM_SEED'
[--shuffle]='kind=flag value=yes config=SHUFFLE'
@@ -151,6 +153,7 @@ usage() {
--height VALUE
--thumbheight VALUE
--maxpreviews N
+ --subdivide PERCENT
--image-jobs N
--random-seed VALUE
--splash
@@ -2199,6 +2202,7 @@ _collect_generation_metadata() {
_GENERATION_METADATA["settings_height"]="$HEIGHT"
_GENERATION_METADATA["settings_thumbheight"]="$THUMBHEIGHT"
_GENERATION_METADATA["settings_maxpreviews"]="$MAXPREVIEWS"
+ _GENERATION_METADATA["settings_subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
_GENERATION_METADATA["settings_image_jobs"]="$IMAGE_JOBS"
_GENERATION_METADATA["settings_random_seed"]="$RANDOM_SEED"
_GENERATION_METADATA["settings_shuffle"]="$SHUFFLE"
@@ -2254,6 +2258,8 @@ _generation_metadata_json() {
"$(_json_string "${_GENERATION_METADATA["settings_thumbheight"]}")"
printf ' "maxpreviews": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_maxpreviews"]}")"
+ printf ' "subdivide_percent": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_subdivide_percent"]}")"
printf ' "image_jobs": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_image_jobs"]}")"
printf ' "random_seed": %s,\n' \
@@ -2335,6 +2341,7 @@ collect_dry_run_plan() {
plan_ref["height"]="$HEIGHT"
plan_ref["thumbheight"]="$THUMBHEIGHT"
plan_ref["maxpreviews"]="$MAXPREVIEWS"
+ plan_ref["subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
plan_ref["image_jobs"]="$IMAGE_JOBS"
plan_ref["random_seed"]="$RANDOM_SEED"
plan_ref["shuffle"]="$SHUFFLE"
@@ -2365,6 +2372,7 @@ print_dry_run_plan() {
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 'Subdivide percent: %s\n' "${plan_ref["subdivide_percent"]}"
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"]}"
@@ -2521,8 +2529,6 @@ render_full_preview_page() {
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" \
@@ -2534,13 +2540,12 @@ render_full_preview_page() {
# Batch all of this page's thumbnails into ONE template call. Building the
# markup in bash and emitting it via the raw "preview_thumbs" field collapses
# what used to be N "template preview" renders (one env -i bash per
- # thumbnail) into a single previewpage render per page.
+ # thumbnail) into a single previewpage render per page. append_preview_grid
+ # also groups the page's photos into tiles (some subdivided into smaller
+ # thumbnails), so the per-page ordering and preview numbering stay here.
local preview_thumbs=''
- for photo in "$@"; do
- (( ++preview_num ))
- append_preview_thumbnail preview_thumbs \
- "$thumbs_dir" "$backhref" "$page_num" "$preview_num" "$photo"
- done
+ append_preview_grid preview_thumbs \
+ "$thumbs_dir" "$backhref" "$page_num" "$@"
template previewpage "$page_name.html" \
html_dir "$html_dir" \
preview_thumbs "$preview_thumbs"
@@ -2607,38 +2612,151 @@ queue_preview_page_render_job() {
render_job_labels_ref["$!"]="template render job for preview $page_name"
}
-# Append one thumbnail's markup to a page's accumulating thumbnail-grid buffer.
-# Produces the per-thumbnail preview.tmpl block (the
-# <a id=... href=...><img class='thumb <anim>' alt=... src=...></a> block), so
-# batching all thumbnails into one previewpage render stays consistent. Every
-# interpolated value is HTML-escaped like the template's context_html fields; the
-# seeded "slow" animation class is preserved exactly. Blocks are separated by a
-# newline; the previewpage template adds the single trailing newline, matching
-# the old N sequential renders.
-append_preview_thumbnail() {
+# Build a page's whole thumbnail-grid buffer by walking its photos and grouping
+# them into tiles. Most tiles are a single square thumbnail, but (controlled by
+# THUMB_SUBDIVIDE_PERCENT) some are subdivided into several smaller thumbnails
+# packed into the same square footprint. Each photo keeps its 1-based page
+# position as its preview number (subdivision only groups CONSECUTIVE photos
+# visually, never reorders them), so the view-page links stay correct. Tile
+# blocks are separated by a single newline; the previewpage template adds the
+# trailing newline, matching the old per-thumbnail rendering.
+append_preview_grid() {
local -n buffer_ref="$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
+ local -a photos=("$@")
+ local -i i=0
+ local -i count
+ local layout
local block
- animation_class=$(random_animation_css_class slow "$photo_file")
- block=$(build_preview_thumbnail \
- "$thumbs_dir" "$backhref" "$page_num" "$preview_num" "$photo_file" \
- "$animation_class")
- if [ -z "$buffer_ref" ]; then
- buffer_ref="$block"
- else
- buffer_ref+=$'\n'"$block"
+ while (( i < ${#photos[@]} )); do
+ # Decide this tile's layout from the photos still available; the first
+ # photo's name is the seeded-random context so the choice is stable.
+ read -r layout count < <(
+ tile_layout_for "$(( ${#photos[@]} - i ))" "${photos[i]}"
+ )
+ block=$(build_tile_block \
+ "$thumbs_dir" "$backhref" "$page_num" "$layout" "$(( i + 1 ))" \
+ "${photos[@]:i:count}")
+ if [ -z "$buffer_ref" ]; then
+ buffer_ref="$block"
+ else
+ buffer_ref+=$'\n'"$block"
+ fi
+ (( i += count ))
+ done
+}
+
+# Decide the layout for the next tile, printing "<layout> <photo-count>". A tile
+# is subdivided with THUMB_SUBDIVIDE_PERCENT probability, but only into a layout
+# that fits the photos still remaining on the page; otherwise it is a single
+# square thumbnail. Randomness reuses the seeded random_index, so builds are
+# reproducible when RANDOM_SEED is set (and varied otherwise).
+tile_layout_for() {
+ local -ri remaining="$1"; shift
+ local -r context="$1"; shift
+ local -a names=(two_wide)
+ local -a counts=(2)
+ local -i roll choice
+
+ # A single tile when subdivision is disabled, too few photos remain to fill
+ # even the smallest subdivided layout (two_wide needs 2), or the roll misses.
+ if (( remaining < 2 || THUMB_SUBDIVIDE_PERCENT == 0 )); then
+ printf 'single 1\n'
+ return
+ fi
+ roll=$(random_index "subdivide:$context" 100)
+ if (( roll >= THUMB_SUBDIVIDE_PERCENT )); then
+ printf 'single 1\n'
+ return
+ fi
+
+ # Offer only the subdivision layouts that fit the remaining photo count.
+ if (( remaining >= 3 )); then
+ names+=(squares_wide_top squares_wide_bottom)
+ counts+=(3 3)
+ fi
+ if (( remaining >= 4 )); then
+ names+=(quad)
+ counts+=(4)
+ fi
+
+ choice=$(random_index "sublayout:$context" "${#names[@]}")
+ printf '%s %s\n' "${names[choice]}" "${counts[choice]}"
+}
+
+# Render one tile's markup (no trailing newline). A "single" tile is the plain
+# square thumbnail, byte-identical to the previous per-thumbnail output; any
+# other layout is a subdivided tile. The tile's photos are the trailing args and
+# their preview numbers run from start_preview upward.
+build_tile_block() {
+ local -r thumbs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r page_num="$1"; shift
+ local -r layout="$1"; shift
+ local -ri start_preview="$1"; shift
+ local -a photos=("$@")
+ local animation_class
+
+ if [ "$layout" = single ]; then
+ animation_class=$(random_animation_css_class slow "${photos[0]}")
+ build_preview_thumbnail \
+ "$thumbs_dir" "$backhref" "$page_num" "$start_preview" \
+ "${photos[0]}" "$animation_class"
+ return
fi
+ build_subdivided_tile \
+ "$thumbs_dir" "$backhref" "$page_num" "$layout" "$start_preview" \
+ "${photos[@]}"
+}
+
+# Emit a subdivided tile: a <div class='tile'> wrapping the smaller
+# sub-thumbnails (class 'subthumb'). Each sub-thumbnail is still its own
+# clickable photo/view page; only its size and (for the full-width strip) an
+# extra 'wide' anchor class differ from a normal square thumbnail. The anchor
+# ORDER encodes the layout for the CSS grid's auto-placement:
+# two_wide -> two full-width strips (both 'wide'), stacked
+# squares_wide_top -> strip first (top row), then two squares (bottom row)
+# squares_wide_bottom -> two squares first (top row), then strip (bottom row)
+# quad -> four squares (2x2)
+build_subdivided_tile() {
+ local -r thumbs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r page_num="$1"; shift
+ local -r layout="$1"; shift
+ local -ri start_preview="$1"; shift
+ local -a photos=("$@")
+ local -a anchor_classes
+ local -i k
+ local animation_class
+
+ case "$layout" in
+ two_wide) anchor_classes=(wide wide) ;;
+ squares_wide_top) anchor_classes=(wide '' '') ;;
+ squares_wide_bottom) anchor_classes=('' '' wide) ;;
+ quad) anchor_classes=('' '' '' '') ;;
+ esac
+
+ printf "<div class='tile'>\n"
+ for (( k = 0; k < ${#photos[@]}; k++ )); do
+ animation_class=$(random_animation_css_class slow "${photos[k]}")
+ build_preview_thumbnail \
+ "$thumbs_dir" "$backhref" "$page_num" "$(( start_preview + k ))" \
+ "${photos[k]}" "$animation_class" subthumb "${anchor_classes[k]:-}"
+ printf '\n'
+ done
+ printf '</div>'
}
# Render the HTML for a single preview thumbnail (HTML-escaping every value the
# way preview.tmpl's context_html fields did). Returned without a trailing
-# newline so callers control separators.
+# newline so callers control separators. img_class defaults to 'thumb' (the full
+# square); subdivided tiles pass 'subthumb'. anchor_class is an optional extra
+# class on the <a> ('wide' marks the full-width strip inside a subdivided tile);
+# when empty the <a> has no class attribute, keeping the single-tile output
+# byte-identical to before.
build_preview_thumbnail() {
local -r thumbs_dir="$1"; shift
local -r backhref="$1"; shift
@@ -2646,19 +2764,30 @@ build_preview_thumbnail() {
local -r preview_num="$1"; shift
local -r photo_file="$1"; shift
local -r animation_class="$1"; shift
+ local -r img_class="${1:-thumb}"
+ local -r anchor_class="${2:-}"
local photo_html
local anim_html
local backhref_html
local thumbs_dir_html
+ local anchor_class_html
photo_html=$(_html_escape "$photo_file")
anim_html=$(_html_escape "$animation_class")
backhref_html=$(_html_escape "$backhref")
thumbs_dir_html=$(_html_escape "$thumbs_dir")
- printf '<a id=%s href=%s>\n' \
- "'$photo_html'" "'$page_num-$preview_num.html'"
- printf " <img class='thumb %s' alt='%s' src='%s/%s/%s'>\n</a>" \
- "$anim_html" "$photo_html" "$backhref_html" "$thumbs_dir_html" "$photo_html"
+ if [ -n "$anchor_class" ]; then
+ anchor_class_html=$(_html_escape "$anchor_class")
+ printf '<a id=%s class=%s href=%s>\n' \
+ "'$photo_html'" "'$anchor_class_html'" \
+ "'$page_num-$preview_num.html'"
+ else
+ printf '<a id=%s href=%s>\n' \
+ "'$photo_html'" "'$page_num-$preview_num.html'"
+ fi
+ printf " <img class='%s %s' alt='%s' src='%s/%s/%s'>\n</a>" \
+ "$img_class" "$anim_html" "$photo_html" "$backhref_html" \
+ "$thumbs_dir_html" "$photo_html"
}
render_view_page() {
@@ -4716,6 +4845,9 @@ apply_config_defaults() {
SOURCE_URL="${SOURCE_URL:-https://codeberg.org/snonux/shuriken.sh}"
SPLASH_PAGE="${SPLASH_PAGE:-yes}"
STATS_PAGE="${STATS_PAGE:-no}"
+ # Optional with a default (unlike the required THUMBHEIGHT): the percent
+ # chance a preview tile is subdivided into smaller thumbnails. 0 disables it.
+ THUMB_SUBDIVIDE_PERCENT="${THUMB_SUBDIVIDE_PERCENT:-30}"
SYNC_DELETE="${SYNC_DELETE:-yes}"
TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}"
TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}"
@@ -4764,6 +4896,7 @@ print_config() {
print_shell_assignment HEIGHT "$HEIGHT"
print_shell_assignment THUMBHEIGHT "$THUMBHEIGHT"
print_shell_assignment MAXPREVIEWS "$MAXPREVIEWS"
+ print_shell_assignment THUMB_SUBDIVIDE_PERCENT "$THUMB_SUBDIVIDE_PERCENT"
print_shell_assignment IMAGE_JOBS "$IMAGE_JOBS"
print_shell_assignment IMAGEMAGICK_TIMEOUT "$IMAGEMAGICK_TIMEOUT"
print_shell_assignment RANDOM_SEED "$RANDOM_SEED"
@@ -5098,6 +5231,19 @@ validate_optional_positive_integer_config_var() {
fi
}
+# A percentage config var: an integer in the inclusive range 0..100. Unlike
+# validate_positive_integer_config_var, 0 is allowed (it is the natural "off"
+# value, e.g. THUMB_SUBDIVIDE_PERCENT=0 disables tile subdivision entirely).
+validate_percentage_config_var() {
+ local -r name="$1"; shift
+ local -r value="${!name}"
+
+ if [[ ! "$value" =~ ^[0-9]+$ ]] || (( value > 100 )); then
+ config_error "$name must be an integer between 0 and 100"
+ return 1
+ fi
+}
+
validate_yes_no_config_var() {
local -r name="$1"; shift
local -r value="${!name}"
@@ -5339,6 +5485,7 @@ validate_common_config() {
validate_optional_positive_integer_config_var HEIGHT || return
validate_positive_integer_config_var THUMBHEIGHT || return
validate_positive_integer_config_var MAXPREVIEWS || return
+ validate_percentage_config_var THUMB_SUBDIVIDE_PERCENT || return
validate_positive_integer_config_var IMAGE_JOBS || return
validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT || return
validate_positive_integer_config_var TAR_TIMEOUT || return
diff --git a/docs/configuration.md b/docs/configuration.md
index d6471b4..5d61a27 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -13,6 +13,7 @@ values for the current run.
| `HEIGHT` | `1200` | Scaled photo height in pixels. Leave unset to keep original size. Optional positive integer. |
| `THUMBHEIGHT` | `300` | Thumbnail height in pixels. Positive integer. |
| `MAXPREVIEWS` | `40` | Maximum previews per page. Positive integer. |
+| `THUMB_SUBDIVIDE_PERCENT` | `30` | Percent chance (0-100) that a preview tile is subdivided into smaller thumbnails (2x2 quad, two stacked wide strips, or two squares plus one wide strip on top/bottom). Each sub-thumbnail is its own clickable photo. `0` disables it. |
| `IMAGE_JOBS` | `3` | Parallel jobs for image processing and HTML template rendering. Positive integer. |
| `IMAGEMAGICK_TIMEOUT` | `60` | Per-ImageMagick-command timeout in seconds. Positive integer. |
| `TAR_TIMEOUT` | `120` | Tarball creation timeout in seconds. Positive integer. |
@@ -53,6 +54,7 @@ The checks (details in `src/lib/config.validate.source.sh`):
`IMAGE_JOBS`, `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`.
* **Positive integers**: `THUMBHEIGHT`, `MAXPREVIEWS`, `IMAGE_JOBS`,
`IMAGEMAGICK_TIMEOUT`, `TAR_TIMEOUT`; `HEIGHT` is an optional positive integer.
+* **Percentage (0-100 integer)**: `THUMB_SUBDIVIDE_PERCENT`.
* **`yes`/`no` settings**: `SHUFFLE`, `SPLASH_PAGE`, `STATS_PAGE`,
`TARBALL_INCLUDE`, `SYNC_DELETE` (where applicable).
* **Readable input**: `INCOMING_DIR` must be a readable directory; `TEMPLATE_DIR`
@@ -74,7 +76,8 @@ Generation stops before writing album output when validation fails.
`--print-config` writes stable shell-style assignments to stdout in this order:
`CONFIG_SOURCE`, `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `FAVICON`,
-`SOURCE_URL`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`, `IMAGE_JOBS`,
+`SOURCE_URL`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`,
+`THUMB_SUBDIVIDE_PERCENT`, `IMAGE_JOBS`,
`IMAGEMAGICK_TIMEOUT`, `RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, `STATS_PAGE`,
`TARBALL_INCLUDE`, `TARBALL_SUFFIX`, `TAR_TIMEOUT`, `TAR_OPTS`, `SYNC_DELETE`,
`SYNC_DESTINATIONS`, `ORIGINAL_BASEPATH`.
diff --git a/docs/generation.md b/docs/generation.md
index c377adf..b89cafc 100644
--- a/docs/generation.md
+++ b/docs/generation.md
@@ -96,9 +96,9 @@ metadata records:
* the source `INCOMING_DIR` and source image count;
* generated photo, thumbnail, and HTML file counts;
* tarball status (included + file);
-* effective settings (title, height, thumbheight, maxpreviews, image jobs,
- random seed, shuffle, splash page, stats page, original basepath) useful for
- debugging a published album.
+* effective settings (title, height, thumbheight, maxpreviews, subdivide
+ percent, image jobs, random seed, shuffle, splash page, stats page, original
+ basepath) useful for debugging a published album.
## Favicon
diff --git a/docs/usage.md b/docs/usage.md
index b11514b..f4c8d27 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -68,6 +68,7 @@ config variable documented in [configuration.md](configuration.md).
| `--height VALUE` | `HEIGHT` |
| `--thumbheight VALUE` | `THUMBHEIGHT` |
| `--maxpreviews N` | `MAXPREVIEWS` |
+| `--subdivide PERCENT` | `THUMB_SUBDIVIDE_PERCENT` |
| `--image-jobs N` | `IMAGE_JOBS` |
| `--random-seed VALUE` | `RANDOM_SEED` |
| `--shuffle` | `SHUFFLE=yes` |
diff --git a/share/templates/default/header.tmpl b/share/templates/default/header.tmpl
index 4ba159e..50a33a7 100644
--- a/share/templates/default/header.tmpl
+++ b/share/templates/default/header.tmpl
@@ -186,6 +186,52 @@ cat <<END
height: ${render_thumbheight_html}px;
width: ${render_thumbheight_html}px;
object-fit: cover;
+ vertical-align: top;
+ }
+
+ /* A subdivided thumbnail tile: same outer footprint as one square thumb
+ (including the 5px image padding), but a 2x2 grid packing several
+ smaller thumbnails. Anchor order + the "wide" class (a full-width
+ strip spanning both columns) produce the quad / two-wide / squares+
+ wide layouts via grid auto-placement. */
+ div.tile {
+ display: inline-grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ gap: 4px;
+ width: ${render_thumbheight_html}px;
+ height: ${render_thumbheight_html}px;
+ padding: 5px;
+ box-sizing: content-box;
+ vertical-align: top;
+ }
+
+ div.tile a {
+ display: block;
+ min-width: 0;
+ min-height: 0;
+ }
+
+ div.tile a.wide {
+ grid-column: span 2;
+ }
+
+ /* Raise the hovered sub-thumbnail above its neighbours so its
+ scaled/rotated "pop" effect (the shared img.thumb/img.subthumb:hover
+ rule below) is painted on top of the adjacent grid cells, not under
+ them. The sub-images fit their cells exactly, so there is no overflow
+ to clip until a hover scales one up. */
+ div.tile a:hover {
+ position: relative;
+ z-index: 2;
+ }
+
+ img.subthumb {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ padding: 0;
+ display: block;
}
/* Camera filter page grid: separate from the back link and let the
@@ -194,7 +240,12 @@ cat <<END
margin-top: 12px;
}
- img.thumb:hover {
+ /* Sub-thumbnails get the same dramatic hover as the full square thumbs
+ (mirror flip, scale-up, rotate, contrast/saturate boost, and a switch
+ to object-fit:contain) so a subdivided tile feels as lively as a
+ normal one. */
+ img.thumb:hover,
+ img.subthumb:hover {
outline: 4px solid #ffffff;
-webkit-transform: scaleX(-1);
transform: scaleX(-1) scale(1.08) rotate(2deg);
diff --git a/src/lib/album-metadata.source.sh b/src/lib/album-metadata.source.sh
index 01bf826..3d38cbf 100644
--- a/src/lib/album-metadata.source.sh
+++ b/src/lib/album-metadata.source.sh
@@ -209,6 +209,7 @@ _collect_generation_metadata() {
_GENERATION_METADATA["settings_height"]="$HEIGHT"
_GENERATION_METADATA["settings_thumbheight"]="$THUMBHEIGHT"
_GENERATION_METADATA["settings_maxpreviews"]="$MAXPREVIEWS"
+ _GENERATION_METADATA["settings_subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
_GENERATION_METADATA["settings_image_jobs"]="$IMAGE_JOBS"
_GENERATION_METADATA["settings_random_seed"]="$RANDOM_SEED"
_GENERATION_METADATA["settings_shuffle"]="$SHUFFLE"
@@ -264,6 +265,8 @@ _generation_metadata_json() {
"$(_json_string "${_GENERATION_METADATA["settings_thumbheight"]}")"
printf ' "maxpreviews": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_maxpreviews"]}")"
+ printf ' "subdivide_percent": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_subdivide_percent"]}")"
printf ' "image_jobs": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_image_jobs"]}")"
printf ' "random_seed": %s,\n' \
@@ -345,6 +348,7 @@ collect_dry_run_plan() {
plan_ref["height"]="$HEIGHT"
plan_ref["thumbheight"]="$THUMBHEIGHT"
plan_ref["maxpreviews"]="$MAXPREVIEWS"
+ plan_ref["subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
plan_ref["image_jobs"]="$IMAGE_JOBS"
plan_ref["random_seed"]="$RANDOM_SEED"
plan_ref["shuffle"]="$SHUFFLE"
@@ -375,6 +379,7 @@ print_dry_run_plan() {
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 'Subdivide percent: %s\n' "${plan_ref["subdivide_percent"]}"
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"]}"
diff --git a/src/lib/album-render.source.sh b/src/lib/album-render.source.sh
index b077dd5..63c0c57 100644
--- a/src/lib/album-render.source.sh
+++ b/src/lib/album-render.source.sh
@@ -100,8 +100,6 @@ render_full_preview_page() {
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" \
@@ -113,13 +111,12 @@ render_full_preview_page() {
# Batch all of this page's thumbnails into ONE template call. Building the
# markup in bash and emitting it via the raw "preview_thumbs" field collapses
# what used to be N "template preview" renders (one env -i bash per
- # thumbnail) into a single previewpage render per page.
+ # thumbnail) into a single previewpage render per page. append_preview_grid
+ # also groups the page's photos into tiles (some subdivided into smaller
+ # thumbnails), so the per-page ordering and preview numbering stay here.
local preview_thumbs=''
- for photo in "$@"; do
- (( ++preview_num ))
- append_preview_thumbnail preview_thumbs \
- "$thumbs_dir" "$backhref" "$page_num" "$preview_num" "$photo"
- done
+ append_preview_grid preview_thumbs \
+ "$thumbs_dir" "$backhref" "$page_num" "$@"
template previewpage "$page_name.html" \
html_dir "$html_dir" \
preview_thumbs "$preview_thumbs"
@@ -186,38 +183,151 @@ queue_preview_page_render_job() {
render_job_labels_ref["$!"]="template render job for preview $page_name"
}
-# Append one thumbnail's markup to a page's accumulating thumbnail-grid buffer.
-# Produces the per-thumbnail preview.tmpl block (the
-# <a id=... href=...><img class='thumb <anim>' alt=... src=...></a> block), so
-# batching all thumbnails into one previewpage render stays consistent. Every
-# interpolated value is HTML-escaped like the template's context_html fields; the
-# seeded "slow" animation class is preserved exactly. Blocks are separated by a
-# newline; the previewpage template adds the single trailing newline, matching
-# the old N sequential renders.
-append_preview_thumbnail() {
+# Build a page's whole thumbnail-grid buffer by walking its photos and grouping
+# them into tiles. Most tiles are a single square thumbnail, but (controlled by
+# THUMB_SUBDIVIDE_PERCENT) some are subdivided into several smaller thumbnails
+# packed into the same square footprint. Each photo keeps its 1-based page
+# position as its preview number (subdivision only groups CONSECUTIVE photos
+# visually, never reorders them), so the view-page links stay correct. Tile
+# blocks are separated by a single newline; the previewpage template adds the
+# trailing newline, matching the old per-thumbnail rendering.
+append_preview_grid() {
local -n buffer_ref="$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
+ local -a photos=("$@")
+ local -i i=0
+ local -i count
+ local layout
local block
- animation_class=$(random_animation_css_class slow "$photo_file")
- block=$(build_preview_thumbnail \
- "$thumbs_dir" "$backhref" "$page_num" "$preview_num" "$photo_file" \
- "$animation_class")
- if [ -z "$buffer_ref" ]; then
- buffer_ref="$block"
- else
- buffer_ref+=$'\n'"$block"
+ while (( i < ${#photos[@]} )); do
+ # Decide this tile's layout from the photos still available; the first
+ # photo's name is the seeded-random context so the choice is stable.
+ read -r layout count < <(
+ tile_layout_for "$(( ${#photos[@]} - i ))" "${photos[i]}"
+ )
+ block=$(build_tile_block \
+ "$thumbs_dir" "$backhref" "$page_num" "$layout" "$(( i + 1 ))" \
+ "${photos[@]:i:count}")
+ if [ -z "$buffer_ref" ]; then
+ buffer_ref="$block"
+ else
+ buffer_ref+=$'\n'"$block"
+ fi
+ (( i += count ))
+ done
+}
+
+# Decide the layout for the next tile, printing "<layout> <photo-count>". A tile
+# is subdivided with THUMB_SUBDIVIDE_PERCENT probability, but only into a layout
+# that fits the photos still remaining on the page; otherwise it is a single
+# square thumbnail. Randomness reuses the seeded random_index, so builds are
+# reproducible when RANDOM_SEED is set (and varied otherwise).
+tile_layout_for() {
+ local -ri remaining="$1"; shift
+ local -r context="$1"; shift
+ local -a names=(two_wide)
+ local -a counts=(2)
+ local -i roll choice
+
+ # A single tile when subdivision is disabled, too few photos remain to fill
+ # even the smallest subdivided layout (two_wide needs 2), or the roll misses.
+ if (( remaining < 2 || THUMB_SUBDIVIDE_PERCENT == 0 )); then
+ printf 'single 1\n'
+ return
fi
+ roll=$(random_index "subdivide:$context" 100)
+ if (( roll >= THUMB_SUBDIVIDE_PERCENT )); then
+ printf 'single 1\n'
+ return
+ fi
+
+ # Offer only the subdivision layouts that fit the remaining photo count.
+ if (( remaining >= 3 )); then
+ names+=(squares_wide_top squares_wide_bottom)
+ counts+=(3 3)
+ fi
+ if (( remaining >= 4 )); then
+ names+=(quad)
+ counts+=(4)
+ fi
+
+ choice=$(random_index "sublayout:$context" "${#names[@]}")
+ printf '%s %s\n' "${names[choice]}" "${counts[choice]}"
+}
+
+# Render one tile's markup (no trailing newline). A "single" tile is the plain
+# square thumbnail, byte-identical to the previous per-thumbnail output; any
+# other layout is a subdivided tile. The tile's photos are the trailing args and
+# their preview numbers run from start_preview upward.
+build_tile_block() {
+ local -r thumbs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r page_num="$1"; shift
+ local -r layout="$1"; shift
+ local -ri start_preview="$1"; shift
+ local -a photos=("$@")
+ local animation_class
+
+ if [ "$layout" = single ]; then
+ animation_class=$(random_animation_css_class slow "${photos[0]}")
+ build_preview_thumbnail \
+ "$thumbs_dir" "$backhref" "$page_num" "$start_preview" \
+ "${photos[0]}" "$animation_class"
+ return
+ fi
+ build_subdivided_tile \
+ "$thumbs_dir" "$backhref" "$page_num" "$layout" "$start_preview" \
+ "${photos[@]}"
+}
+
+# Emit a subdivided tile: a <div class='tile'> wrapping the smaller
+# sub-thumbnails (class 'subthumb'). Each sub-thumbnail is still its own
+# clickable photo/view page; only its size and (for the full-width strip) an
+# extra 'wide' anchor class differ from a normal square thumbnail. The anchor
+# ORDER encodes the layout for the CSS grid's auto-placement:
+# two_wide -> two full-width strips (both 'wide'), stacked
+# squares_wide_top -> strip first (top row), then two squares (bottom row)
+# squares_wide_bottom -> two squares first (top row), then strip (bottom row)
+# quad -> four squares (2x2)
+build_subdivided_tile() {
+ local -r thumbs_dir="$1"; shift
+ local -r backhref="$1"; shift
+ local -r page_num="$1"; shift
+ local -r layout="$1"; shift
+ local -ri start_preview="$1"; shift
+ local -a photos=("$@")
+ local -a anchor_classes
+ local -i k
+ local animation_class
+
+ case "$layout" in
+ two_wide) anchor_classes=(wide wide) ;;
+ squares_wide_top) anchor_classes=(wide '' '') ;;
+ squares_wide_bottom) anchor_classes=('' '' wide) ;;
+ quad) anchor_classes=('' '' '' '') ;;
+ esac
+
+ printf "<div class='tile'>\n"
+ for (( k = 0; k < ${#photos[@]}; k++ )); do
+ animation_class=$(random_animation_css_class slow "${photos[k]}")
+ build_preview_thumbnail \
+ "$thumbs_dir" "$backhref" "$page_num" "$(( start_preview + k ))" \
+ "${photos[k]}" "$animation_class" subthumb "${anchor_classes[k]:-}"
+ printf '\n'
+ done
+ printf '</div>'
}
# Render the HTML for a single preview thumbnail (HTML-escaping every value the
# way preview.tmpl's context_html fields did). Returned without a trailing
-# newline so callers control separators.
+# newline so callers control separators. img_class defaults to 'thumb' (the full
+# square); subdivided tiles pass 'subthumb'. anchor_class is an optional extra
+# class on the <a> ('wide' marks the full-width strip inside a subdivided tile);
+# when empty the <a> has no class attribute, keeping the single-tile output
+# byte-identical to before.
build_preview_thumbnail() {
local -r thumbs_dir="$1"; shift
local -r backhref="$1"; shift
@@ -225,19 +335,30 @@ build_preview_thumbnail() {
local -r preview_num="$1"; shift
local -r photo_file="$1"; shift
local -r animation_class="$1"; shift
+ local -r img_class="${1:-thumb}"
+ local -r anchor_class="${2:-}"
local photo_html
local anim_html
local backhref_html
local thumbs_dir_html
+ local anchor_class_html
photo_html=$(_html_escape "$photo_file")
anim_html=$(_html_escape "$animation_class")
backhref_html=$(_html_escape "$backhref")
thumbs_dir_html=$(_html_escape "$thumbs_dir")
- printf '<a id=%s href=%s>\n' \
- "'$photo_html'" "'$page_num-$preview_num.html'"
- printf " <img class='thumb %s' alt='%s' src='%s/%s/%s'>\n</a>" \
- "$anim_html" "$photo_html" "$backhref_html" "$thumbs_dir_html" "$photo_html"
+ if [ -n "$anchor_class" ]; then
+ anchor_class_html=$(_html_escape "$anchor_class")
+ printf '<a id=%