1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
|
# Shared EXIF identify cache primitive. Promoted out of album-metadata.source.sh
# (task pn0) because the cached `identify -verbose` reader is a low-level
# metadata primitive consumed by BOTH the album (tooltips, details tables) and
# the stats aggregator (leaderboard tallies). Keeping it inside the album module
# forced stats to reach across a module boundary into album internals; moving it
# here gives both consumers a shared, stable dependency that is sourced before
# either of them (see LIB_SOURCES in the Justfile, ordered right after
# metadata-label.source.sh, the sibling shared metadata helper). All library
# modules are sourced before any code runs, so source order documents the
# dependency, it does not affect availability. Behaviour and signatures are
# unchanged by the move.
# The volatile EXIF cache directory: ./cache/exif parallel to ./dist. Computed
# once here (task pr0) from working_dir() (the parent of DIST_DIR) so the path is
# defined in exactly ONE place. It was previously recomputed inline -- byte for
# byte identically -- in both cached_photo_identify_output (read/write) and
# clear_exif_cache (remove for --force/--clean); a single source for it keeps
# those two in lockstep so the reader and the cleaner can never drift apart and
# point at different directories. The path is deliberately a sibling of DIST_DIR
# (working_dir()/cache/exif): the staging dir is a sibling of the final dist, so
# this resolves to the working dir in both staging and direct contexts, lives
# outside dist (surviving a fresh/cleared dist and never deployed), and lets an
# unchanged photo skip the slow `identify -verbose` on every regenerate.
# printf (no echo) keeps it safe under `set -euo pipefail`.
exif_cache_dir() {
printf '%s\n' "$(working_dir)/cache/exif"
}
# Build the cache signature line ("<photo>:<size>:<mtime>") used to decide
# whether a cache entry is still valid for the source file. Kept private to this
# module alongside its only consumers, plus the stats test that pre-seeds caches.
# Uses $STAT (compat.source.sh): -c FORMAT is GNU-only (BSD/macOS stat uses -f
# and rejects -c), so this must run against the resolved GNU stat.
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 a cache file's payload (everything after the leading signature line).
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"
}
# Return cached ImageMagick `identify -verbose` output for a photo, rebuilding
# the cache when missing or stale. Public shared primitive: album-metadata and
# stats-aggregate both call this rather than running identify themselves.
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
# Resolve the volatile EXIF cache dir via the shared exif_cache_dir() helper
# (see its definition above for why it sits parallel to ./dist and survives a
# cleared dist) so the reader/writer here and clear_exif_cache below always
# agree on the same directory.
cache_dir="$(exif_cache_dir)"
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
if ! _rebuild_photo_identify_cache "$photo" "$photo_path" \
"$cache_dir" "$cache_file" "$current_signature"; then
# Rebuild reported a failed identify: it already warned and removed the
# cache file, so there is nothing to print for this photo. Return 0 so
# one unreadable photo does not abort the backgrounded render job under
# `set -euo pipefail` (see the rebuild helper for the data-loss rationale).
return 0
fi
print_cached_photo_identify_output "$cache_file"
}
# Rebuild the identify cache file for one photo: write the signature line, then
# append `identify -verbose` output. Returns 0 on success (cache_file now holds a
# usable entry) and 1 on a failed identify, having warned and removed the
# half-written cache file. Removing it is essential: a file holding only the
# signature line is a valid-looking cache hit, so the next run would silently
# reuse the empty result forever -- never retrying identify and never warning
# again (the original data-loss bug). Deleting it makes the next run retry+warn.
_rebuild_photo_identify_cache() {
local -r photo="$1"; shift
local -r photo_path="$1"; shift
local -r cache_dir="$1"; shift
local -r cache_file="$1"; shift
local -r current_signature="$1"; shift
local identify_status
mkdir -p "$cache_dir"
printf '%s\n' "$current_signature" > "$cache_file"
# Capture the identify exit status instead of swallowing it with `|| true`.
# Errors are still hidden from stdout (so a corrupt photo does not pollute
# the EXIF output), but a non-zero status now drives a warning + no-cache
# rather than silently leaving a signature-only cache entry behind.
identify_status=0
imagemagick_identify -verbose "$photo_path" >> "$cache_file" 2>/dev/null \
|| identify_status=$?
if [ "$identify_status" -ne 0 ]; then
# Failed identify (corrupt photo, timeout, missing binary, ...): warn
# naming the photo and remove the cache file (see this function's header
# for why removal matters). The caller turns our non-zero return into a
# graceful "skip this photo" so the whole generation is not killed.
rm -f "$cache_file"
log_warning \
"could not read EXIF for $photo (ImageMagick identify failed);" \
"tooltip/stats will be missing"
return 1
fi
return 0
}
# Canonical `identify -verbose` stream parser. Reads an identify stream from
# stdin and fills the caller-provided associative array (by nameref) with one
# entry per recognised field. Promoted here (task 8r0) from three near-identical
# copies that had already drifted -- album-metadata had two exif:-only loops and
# stats-aggregate added a native Geometry path. This single definition is a
# strict superset of all three: it lives next to cached_photo_identify_output so
# both the album (tooltips, details tables) and the stats aggregator share one
# regex and one set of key conventions, isolating any future identify-format
# drift to one place.
#
# Keys produced:
# - exif:* lines -> stored under the bare tag name (e.g. "Make", "FNumber"),
# i.e. WITHOUT the leading "exif:" -- callers that want the prefixed label
# (the details table) re-add it. This matches the historical album and stats
# array keys exactly.
# - the native Geometry line ("WxH+x+y") -> stored under the synthetic key
# "__geometry"; stats reads this for dimension tallies, album ignores it.
#
# Reads from stdin (not from a photo path) so it composes with the cache layer:
# stats pipes a fixture or cache stream straight in, while album wraps it with
# `photo_exif_values_to ref < <(cached_photo_identify_output ...)`.
photo_exif_values_to() {
local -n output_ref="$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]}"
elif [[ "$line" =~ ^[[:space:]]*Geometry:[[:space:]]*(.*)$ ]]; then
# __geometry is a literal array key, not a variable; --check-sourced
# nameref aliasing across modules misreads it (SC2154). SC2034 covers
# the caller-owned array write.
# shellcheck disable=SC2034,SC2154
output_ref[__geometry]="${BASH_REMATCH[1]}"
fi
done
}
# 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).
# Moved here from album-metadata.source.sh (task 6r0): clearing the cache is the
# lifecycle counterpart of cached_photo_identify_output above, so the cache's
# creation and destruction now live in the same module.
clear_exif_cache() {
local -r cache_dir="$(exif_cache_dir)"
log_verbose "Force generation; clearing EXIF cache $cache_dir"
rm -rf "$cache_dir"
}
|