diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-22 09:52:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-22 09:52:57 +0300 |
| commit | 6945a0ae9afb18313636c09f34959ee5b225f78d (patch) | |
| tree | e74aa7b861543f5407de863f9667b4ccc994bbbd | |
| parent | 79d78a296d2919155fe4d4bb2fac73d2fa7adeec (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.md | 19 | ||||
| -rwxr-xr-x | bin/shuriken | 213 | ||||
| -rw-r--r-- | docs/configuration.md | 5 | ||||
| -rw-r--r-- | docs/generation.md | 6 | ||||
| -rw-r--r-- | docs/usage.md | 1 | ||||
| -rw-r--r-- | share/templates/default/header.tmpl | 53 | ||||
| -rw-r--r-- | src/lib/album-metadata.source.sh | 5 | ||||
| -rw-r--r-- | src/lib/album-render.source.sh | 187 | ||||
| -rw-r--r-- | src/lib/bootstrap.source.sh | 1 | ||||
| -rw-r--r-- | src/lib/config.print.source.sh | 1 | ||||
| -rw-r--r-- | src/lib/config.source.sh | 3 | ||||
| -rw-r--r-- | src/lib/config.validate.source.sh | 14 | ||||
| -rw-r--r-- | src/shuriken.default.conf | 5 | ||||
| -rwxr-xr-x | src/shuriken.sh | 2 | ||||
| -rwxr-xr-x | tests/cli.sh | 54 |
15 files changed, 498 insertions, 71 deletions
@@ -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=%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 |
