diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index f80e24bc..404d0d75 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -32,4 +32,7 @@ jobs: run: nix develop . --command rustup toolchain install --profile minimal stable - name: Check - run: nix develop . --command cargo run --locked --manifest-path tools/site/Cargo.toml -- check + run: nix develop . --command cargo run --locked --no-default-features --manifest-path tools/site/Cargo.toml -- check + + - name: Check photo feature + run: nix develop . --command cargo check --locked --tests --manifest-path tools/site/Cargo.toml --no-default-features --features photos diff --git a/.github/workflows/generate.yaml b/.github/workflows/generate.yaml index 1217596b..7465e8e6 100644 --- a/.github/workflows/generate.yaml +++ b/.github/workflows/generate.yaml @@ -33,7 +33,7 @@ jobs: run: rustup toolchain install --profile minimal stable - name: Generate files - run: cargo run --locked --manifest-path tools/site/Cargo.toml -- generate + run: cargo run --locked --no-default-features --manifest-path tools/site/Cargo.toml -- generate - name: Commit generated files - run: cargo run --locked --manifest-path tools/site/Cargo.toml -- commit generate + run: cargo run --locked --no-default-features --manifest-path tools/site/Cargo.toml -- commit generate diff --git a/.github/workflows/github-pages.yaml b/.github/workflows/github-pages.yaml index 8bbdb165..f5b56ffd 100644 --- a/.github/workflows/github-pages.yaml +++ b/.github/workflows/github-pages.yaml @@ -36,7 +36,7 @@ jobs: run: nix develop . --command rustup toolchain install --profile minimal stable --target wasm32-unknown-unknown - name: Build site - run: nix develop . --command cargo run --locked --manifest-path tools/site/Cargo.toml -- build --prod + run: nix develop . --command cargo run --locked --no-default-features --manifest-path tools/site/Cargo.toml -- build --prod - name: Upload artifact uses: actions/upload-pages-artifact@v5.0.0 diff --git a/.github/workflows/update_and_lint.yaml b/.github/workflows/update_and_lint.yaml index eee3390c..029293e9 100644 --- a/.github/workflows/update_and_lint.yaml +++ b/.github/workflows/update_and_lint.yaml @@ -33,7 +33,7 @@ jobs: run: nix develop . --command rustup toolchain install --profile minimal stable --component rustfmt --component clippy --target wasm32-unknown-unknown - name: Update dependencies and lint - run: nix develop . --command cargo run --locked --manifest-path tools/site/Cargo.toml -- update + run: nix develop . --command cargo run --locked --no-default-features --manifest-path tools/site/Cargo.toml -- update - name: Commit maintenance changes - run: nix develop . --command cargo run --locked --manifest-path tools/site/Cargo.toml -- commit update_and_lint + run: nix develop . --command cargo run --locked --no-default-features --manifest-path tools/site/Cargo.toml -- commit update_and_lint diff --git a/.gitignore b/.gitignore index 5f6449d6..5db9ff05 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ tsconfig.tsbuildinfo quartz/cli/tui/dist/ private/ private/photography/originals/ +private/tools/nightshade/ .replit replit.nix diff --git a/content/blog/recursive-just-intonation.md b/content/blog/recursive-just-intonation.md index 73f601a3..00ab0bfa 100644 --- a/content/blog/recursive-just-intonation.md +++ b/content/blog/recursive-just-intonation.md @@ -1,7 +1,7 @@ --- title: Why you should buy 12 Pianos | Recursive Just Intonation date: 2022-11-29 -updated: 2026-05-05 +updated: 2026-05-08 tags: - music - programming @@ -9,18 +9,30 @@ tags: ## What is this about? -Recursive Just Intonation is a novel tuning system I came up with during my highschool physics classes, while trying to find a solution -to the dissonance of 12TET. Before i explain what it is I'll give a little background on tuning systems in general. +Before getting to recursive just intonation, there are two tuning ideas worth +separating: + +- 12-tone equal temperament, or 12-TET, divides the octave into twelve equal + steps. It is why a piano can play in every key without retuning, but its + thirds and fifths are approximations of simpler acoustic ratios. +- Just intonation tunes notes as small whole-number frequency ratios, like + `2/1`, `3/2`, and `5/4`. Those intervals can sound more settled because the + waves line up more often, but the "right" version of a note depends on the + harmonic context. + +The rest of the post builds from that tradeoff: one global keyboard versus +cleaner chord-local relationships. ## Equal Temperament vs Just Intonation -Equal temperament gives us one keyboard. Every C# is the same C#, every G is -the same G, and every semitone is the same distance from the last one. That is -very convenient, at the cost of shaving almost every interval a little. The -intervals are close enough to simple ratios that they work, but most of them -are not exact. +Equal temperament gives us one frequency table. Every C# is the same C#, every G is the +same G, and every semitone is the same distance from the last one. That is very +convenient, at the cost of shaving almost every interval a little. The intervals +are close enough to simple ratios that they work, but most of them are not +exact. -Just intonation goes the other way. It starts from simple frequency ratios: +Just intonation goes the other way. It treats notes as relationships to a root, +then builds those relationships from simple frequency ratios: - octave: `2/1` - perfect fifth: `3/2` @@ -63,21 +75,7 @@ third above C is E. A `5/4` major third above E is G#/Ab. Those two facts cannot both fit into one fixed 12-note keyboard unless we allow the same pitch name to mean different frequencies in different harmonic contexts. -The experiment here is: - -> Keep the roots on a C-based just-intonation keyboard, but give every chord -> root its own just-intonation keyboard. - -I think of it as 12 pianos: one just piano rooted on C, one on C#/Db, one on D, -and so on. The root of each piano is taken from the original C just-intonation -scale. Once a chord chooses a root, all of its notes come from the piano rooted -on that note. - -This is "recursive" in the simple algorithmic sense: use a just-ratio table to -choose the chord root, then use the same ratio table again inside that root. - -For a C-based just-intonation scale the 12 pitch -classes are: +For a C-based just-intonation scale, the 12 pitch classes are: | pitch | ratio from C | | ----- | -----------: | @@ -151,8 +149,18 @@ closer to `500:630:749`, or `1:1.260:1.498`. ## 12 Just Pianos | Recursive Just Intonation -Recursive just intonation changes only one rule: after choosing a root, restart -the ratio table at that root. +Here is what I call recursive just intonation: + +> Keep the roots on a C-based just-intonation keyboard, but give every chord +> root its own just-intonated keyboard. + +I think of it as 12 pianos: one just piano rooted on C, one on C#/Db, one on D, +and so on. The root of each piano is taken from the original C just-intonation +scale. Once a chord chooses a root, all of its notes come from the piano rooted +on that note. + +This is "recursive" in the simple algorithmic sense: use a just-ratio table to +choose the chord root, then use the same ratio table again inside that root. For an E major chord: @@ -387,28 +395,6 @@ Recursive just intonation is mostly a listening tool for me. It makes the root-relationship audible again, instead of flattening every chord tone onto one global keyboard. -### Implementation Notes - -The shared Rust renderer lives in `crates/recursive_ji_core`. It does not use -MIDI, because standard MIDI note numbers assume fixed pitch classes unless you -add extra tuning messages. Instead it writes 16-bit mono WAV files directly and -feeds the browser notation through the WASM bundle. - -Generate the files with: - -```sh -cargo run --manifest-path tools/site/Cargo.toml -- recursive-ji-music -``` - -The same command also writes a CSV frequency report to -`content/misc/plaintext/recursive-ji-frequencies.csv`. - -The renderer supports three tunings: - -- `12-TET`: `C4 * 2^(n/12)` -- `Fixed C just intonation`: `C4 * J[pitch_class] * octave` -- `Recursive just intonation`: `C4 * J[root] * J[chord_degree] * octave` - ## My Other Music Work - [Play around with different tuning systems and your computer keyboard](/tools/tuningplayground.md) diff --git a/content/photography.css b/content/photography.css index a8205b36..c220c587 100644 --- a/content/photography.css +++ b/content/photography.css @@ -191,10 +191,22 @@ } .photo-lightbox figcaption { + display: grid; + gap: 0.18rem; color: var(--photo-muted); + line-height: 1.35; text-align: center; } +.photo-lightbox figcaption strong { + color: var(--dark); +} + +.photo-lightbox figcaption span { + max-width: 52rem; + justify-self: center; +} + .photo-lightbox button { border: 1px solid var(--photo-border); border-radius: 6px; diff --git a/content/photography/gallery.json b/content/photography/gallery.json index acf0e1b5..2bb75409 100644 --- a/content/photography/gallery.json +++ b/content/photography/gallery.json @@ -1,498 +1,955 @@ [ { "src": "/photography/gallery/70873501585.jpg", - "title": "70873501585", + "title": "Red cat in motion", + "description": "A blurred, warm-toned image of a cat mid-movement.", "meta": "70873501585.jpg", + "tags": [], "width": 5984, "height": 3989 }, { "src": "/photography/gallery/p1073908.jpg", - "title": "P1073908", + "title": "Silhouetted Boats at Dusk", + "description": "Darkened masts reflect [K the moon on a calm water surface", "meta": "P1073908.jpg", + "tags": [], "width": 5761, "height": 3841 }, { "src": "/photography/gallery/p1073909.jpg", - "title": "P1073909", + "title": "Nighttime Sailboats", + "description": "Dark silhouettes of sailboats reflected on the calm water un [2D [K under a hazy moon.", "meta": "P1073909.jpg", + "tags": [], "width": 5870, "height": 3913 }, { "src": "/photography/gallery/p1073910.jpg", - "title": "P1073910", + "title": "Shadow on White", + "description": "A dark silhouette interacts with a bright white screen di [2D [K displaying a simplified circle.", "meta": "P1073910.jpg", + "tags": [ + "shadow", + "white", + "circle", + "screen", + "minimalism", + "geometric", + "geometric", + "light" + ], "width": 5870, "height": 3913 }, { "src": "/photography/gallery/p1073936.jpg", - "title": "P1073936", + "title": "Row houses in the sun", + "description": "A view of white row houses [K with red tile roofs under a bright blue sky with scattered clouds", "meta": "P1073936.jpg", + "tags": [ + "urban", + "architecture", + "rowhouse", + "residential", + "sweden", + "europe", + "sky", + "houses" + ], "width": 6000, "height": 4000 }, { "src": "/photography/gallery/p1084939.jpg", - "title": "P1084939", + "title": "Dark Figure Passage", + "description": "A solitary figure walks into t [1D [K the darkness", "meta": "P1084939.jpg", + "tags": [ + "silhouette", + "night", + "movement", + "urban", + "mystery", + "shdarkness", + "tags", + "silhouette" + ], "width": 3999, "height": 5326 }, { "src": "/photography/gallery/p1084942.jpg", - "title": "P1084942", + "title": "Urban Skyscape", + "description": "A cloudy sky views a distant citys [5D [K cityscape in muted tones", "meta": "P1084942.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1084946.jpg", - "title": "P1084946", + "title": "Distant cityscape through foliage", + "description": "A muted cityscape is visible through a leafy foreground, fra [3D [K framed by a cloudy sky.", "meta": "P1084946.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1084950.jpg", - "title": "P1084950", + "title": "Solitary Contemplation", + "description": "A man gazes upwards against a dark, expansive sky.", "meta": "P1084950.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1084952.jpg", - "title": "P1084952", + "title": "Night cityscape vista", + "description": "Blurred city lights shimmer over the distant water and mount [5D [K mountains.", "meta": "P1084952.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1084958.jpg", - "title": "P1084958", + "title": "Darkened Passage", + "description": "A stark, dimly lit corridor lined [5D [K lined with numerous vertical columns of books creates an overwhelming sense [5D [K sense of depth and mystery", "meta": "P1084958.jpg", + "tags": [ + "darkness", + "architecture", + "books", + "colu 56d k mystery", + "tags", + "darkness", + "architecture", + "books" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1084977.jpg", - "title": "P1084977", + "title": "Golden Hour Reflection", + "description": "A serene view of a distant [7D [K distant mountain range bathed in warm sunlight at sunset.", "meta": "P1084977.jpg", + "tags": [], "width": 4000, "height": 5328 }, { "src": "/photography/gallery/p1084998.jpg", - "title": "P1084998", + "title": "Ethereal Blue Drift", + "description": "A luminous, indistinct blue swirl evokes a sense of weigh [5D [K weightless movement and atmospheric depth.", "meta": "P1084998.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1084999.jpg", - "title": "P1084999", + "title": "Ethereal blue cascade", + "description": "A vibrant blue waterfall pl [2D [K plunges through a dense, dark forest canopy", "meta": "P1084999.jpg", + "tags": [ + "blue", + "waterfall", + "fo 38d k canopy", + "tags", + "blue", + "waterfall", + "forest", + "abstract" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085002.jpg", - "title": "P1085002", + "title": "Black Tower Against Azure", + "description": "A dark wooden pagoda si [2D [K silhouette rises dramatically against a vivid blue sky, hinting at ancient [K Japanese architecture and serene landscapes", "meta": "P1085002.jpg", + "tags": [ + "pagoda", + "japan", + "silh 42d k landscapes", + "tags", + "pagoda", + "japan", + "silhouette", + "architecture" + ], "width": 4000, "height": 5328 }, { "src": "/photography/gallery/p1085008.jpg", - "title": "P1085008", + "title": "Torii Gate in Forest", + "description": "A traditional Japanese torii [5D [K torii gate stands prominently within a dense, green forest under a bright s [1D [K sky", "meta": "P1085008.jpg", + "tags": [ + "torii", + "gate", + "japan", + "forest", + "shrine", + "zen", + "nature", + "tradisy" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085009.jpg", - "title": "P1085009", + "title": "Torii Gate under a Blue Sky", + "description": "A traditional Japanese torii gate stands silhouetted against [7D [K against a clear blue sky, framed by lush greenery.", "meta": "P1085009.jpg", + "tags": [ + "japan", + "torii", + "gate", + "temple", + "shrine", + "zen", + "asia", + "landscape" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085010.jpg", - "title": "P1085010", + "title": "Torii Gate at Dusk", + "description": "A traditional Japanese torii gate stands prominently amid [4D [K amidst a crowd of visitors on a path surrounded by lush greenery.", "meta": "P1085010.jpg", + "tags": [ + "torii", + "japan", + "shrine", + "gates", + "travel", + "zen", + "nature", + "architecture" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085015.jpg", - "title": "P1085015", + "title": "Stacks of Maneki-neko", + "description": "A charming collection of white, lucky Maneki-neko figurines [K are artfully arranged.", "meta": "P1085015.jpg", + "tags": [ + "maneki-neko", + "lucky cat", + "japan", + "tokyo", + "statues", + "collection", + "c 46d k cat", + "japan" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085016.jpg", - "title": "P1085016", + "title": "Solitary Silhouette in Blue", + "description": "A dark figure retreats against a hazy blue sky and distant f [1D [K foliage.", "meta": "P1085016.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085025.jpg", - "title": "P1085025", + "title": "Miniature Penguin on Sand", + "description": "A small gray penguin figure stands on a sandy surface with a [1D [K a blurred background.", "meta": "P1085025.jpg", + "tags": [ + "penguin", + "toy", + "beach", + "sand", + "miniature", + "gray", + "figure", + "still l 1d k life" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085029.jpg", - "title": "P1085029", + "title": "Night cityscape reflection", + "description": "Blurred city lights shi [3D [K shimmer on the dark river surface at night.", "meta": "P1085029.jpg", + "tags": [ + "cityscape", + "reflectio 38d k night", + "tags", + "cityscape", + "reflection", + "night", + "water", + "urban" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085030.jpg", - "title": "P1085030", + "title": "Neon Ferris Wheel at Night", + "description": "A brightly lit Ferris wheel dominates the cityscape skyl [4D [K skyline reflecting in the dark water.", "meta": "P1085030.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085033.jpg", - "title": "P1085033", + "title": "Mahjong Tiles", + "description": "A close-up view of empty mahjong tiles arranged on a blue ta [2D [K table.", "meta": "P1085033.jpg", + "tags": [ + "mahjong", + "tiles", + "game", + "table", + "close-up", + "minimalist", + "abstract", + "tags" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085034.jpg", - "title": "P1085034", + "title": "Mahjong Hands", + "description": "Two hands reach across a blue mahjong table, contemplatin [12D [K contemplating the tiles.", "meta": "P1085034.jpg", + "tags": [ + "mahjong", + "game", + "tiles", + "hands", + "blue", + "table", + "strategy", + "strategy" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085040.jpg", - "title": "P1085040", + "title": "Floating Chrysanthemums in Stone", + "description": "A grey stone basin filled with vibrant, assorted chrysanth [9D [K chrysanthemums and a bamboo stick.", "meta": "P1085040.jpg", + "tags": [], "width": 4000, "height": 5328 }, { "src": "/photography/gallery/p1085053.jpg", - "title": "P1085053", + "title": "Man with Red Hair", + "description": "A portrait of a man with flowing red hair, standing near a [1D [K a bridge.", "meta": "P1085053.jpg", + "tags": [ + "portrait", + "red hair", + "bridge", + "urban", + "reflection", + "moody", + "casu 49d k hair", + "bridge" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085056.jpg", - "title": "P1085056", + "title": "Mount Fuji in the Distance", + "description": "A hazy view of Mount Fuji framed by a blue sky and distant c [1D [K coastline.", "meta": "P1085056.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085059.jpg", - "title": "P1085059", + "title": "Intimate moment, candid portrait", + "description": "A photographer f [1D [K focuses on a scene with a child in the background", "meta": "P1085059.jpg", + "tags": [ + "portrait", + "cand 36d k background", + "tags", + "portrait", + "candid", + "photography", + "child", + "moment" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085061.jpg", - "title": "P1085061", + "title": "Mount Fuji Horizon", + "description": "A hazy blue panorama reveals M [1D [K Mount Fuji shrouded in distant clouds above a calm sea and sprawling coastl [6D [K coastline", "meta": "P1085061.jpg", + "tags": [ + "japan", + "mountfuji", + "horizon", + "sea", + "coastline", + "landscacoastlne", + "tags", + "japan" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085065.jpg", - "title": "P1085065", + "title": "Serene Portrait, Coastal Dusk", + "description": "A contemplative portrait of a man amidst a muted, coastal sc [2D [K scene.", "meta": "P1085065.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085066.jpg", - "title": "P1085066", + "title": "Contemplative Observer", + "description": "A man peers into the distance with binoculars, lost in thoug [5D [K thought.", "meta": "P1085066.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085067.jpg", - "title": "P1085067", + "title": "Coastal Dusk Reflection", + "description": "A solitary boat reflects on a calm, moonlit sea under a grey [4D [K grey sky.", "meta": "P1085067.jpg", + "tags": [], "width": 4000, "height": 5328 }, { "src": "/photography/gallery/p1085070.jpg", - "title": "P1085070", + "title": "Solitary Figure on Dusk Shores", + "description": "A lone figure stan [4D [K stands on a desolate beach as the sun dips below the horizon", "meta": "P1085070.jpg", + "tags": [], "width": 4000, "height": 5328 }, { "src": "/photography/gallery/p1085072.jpg", - "title": "P1085072", + "title": "Bird in flight", + "description": "A lone bird soars against a vast, [K open sky", "meta": "P1085072.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085076.jpg", - "title": "P1085076", + "title": "Rocky Coastline at Dusk", + "description": "A serene coastal scene wi [2D [K with dark rocks and calm waters under a muted sky", "meta": "P1085076.jpg", + "tags": [ + "coastline", + "roc 29d k sky", + "tags", + "coastline", + "rocks", + "ocean", + "dusk", + "landscape" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085077.jpg", - "title": "P1085077", + "title": "Three birds against a vast sky", + "description": "Dark birds fly across a deep blue sky, creating a sense of f [1D [K freedom and movement.", "meta": "P1085077.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085078.jpg", - "title": "P1085078", + "title": "Coastal cliffs and seabirds", + "description": "Two birds soar across [6D [K across a dramatic, cloudy sky above the sea and a distant cliff edge", "meta": "P1085078.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085082.jpg", - "title": "P1085082", + "title": "Mount Fuji Silhouette", + "description": "A hazy view of Mount Fuji framed by wires and trees.", "meta": "P1085082.jpg", + "tags": [ + "mount_fuji", + "silhouette", + "japan", + "wires", + "trees", + "horizon", + "sky", + "tags" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085083.jpg", - "title": "P1085083", + "title": "Framed Mount Fuji", + "description": "A blurred view of Mount Fuji seen through a residential stre [4D [K street.", "meta": "P1085083.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085084.jpg", - "title": "P1085084", + "title": "Subdued mountain view", + "description": "Power lines obscure a dista [5D [K distant mountain landscape under a hazy sky", "meta": "P1085084.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085087.jpg", - "title": "P1085087", + "title": "Blurring transit", + "description": "A blurry figure stands amidst a c [1D [K crowd in a train station", "meta": "P1085087.jpg", + "tags": [ + "transit", + "blur", + "people", + "station", + "urba 58d k station", + "tags", + "transit", + "blur" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085088.jpg", - "title": "P1085088", + "title": "Fragmented Gaze, Urban Decay", + "description": "A blurred, unsettling portrait merges with the anonymous [K backdrop of a subway station.", "meta": "P1085088.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085096.jpg", - "title": "P1085096", + "title": "Stormy Glass Pavilion", + "description": "A darkened glass pavilion stands silhouetted against a drama [5D [K dramatic, overcast sky.", "meta": "P1085096.jpg", + "tags": [ + "architecture", + "silhouette", + "storm", + "sky", + "glass", + "pavilion", + "trees", + "dark" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085097.jpg", - "title": "P1085097", + "title": "Stormy Pavilion Silhouette", + "description": "A dark, imposing pavilion is framed by dramatic, overcast sk [2D [K skies.", "meta": "P1085097.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085099.jpg", - "title": "P1085099", + "title": "Blurred Gold", + "description": "A dense, almost abstract, composition of warm gold tones cre [3D [K creates a shimmering, indistinct surface.", "meta": "P1085099.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085130.jpg", - "title": "P1085130", + "title": "Riverbank Serenity and Urban Edge", + "description": "A concrete riverbank scene with a low bridge and a distant h [1D [K high-rise building.", "meta": "P1085130.jpg", + "tags": [ + "river", + "bridge", + "urban", + "reflection", + "water", + "architecture", + "cityscape", + "serenity" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085135.jpg", - "title": "P1085135", + "title": "Railway through the forest", + "description": "A weathered railway lin [3D [K line winds through a densely wooded hillside", "meta": "P1085135.jpg", + "tags": [ + "railway", + "forest", + "l 39d k hillside", + "tags", + "railway", + "forest", + "landscape", + "rural" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085146.jpg", - "title": "P1085146", + "title": "Remote Cabin in the Woods", + "description": "A small cabin is nestled [7D [K nestled deep within a dense, dark forest, creating a sense of isolation and [3D [K and quiet.", "meta": "P1085146.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085147.jpg", - "title": "P1085147", + "title": "Bamboo Forest River View", + "description": "A dramatic aerial view captures a river winding through a [K dense bamboo forest with traditional Japanese houses nestled on the banks.", "meta": "P1085147.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085148.jpg", - "title": "P1085148", + "title": "Stormy Mountain Vista", + "description": "Dark clouds obscure a distant mountain range under a grey [K sky.", "meta": "P1085148.jpg", + "tags": [], "width": 4000, "height": 5328 }, { "src": "/photography/gallery/p1085151.jpg", - "title": "P1085151", + "title": "Dense foliage and rolling hills", + "description": "A verdant landsca [7D [K landscape of trees fades into distant hills under a cloudy sky", "meta": "P1085151.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085185.jpg", - "title": "P1085185", + "title": "Giant Redwood Tree", + "description": "A towering redwood dominates the frame, partially obscure [7D [K obscured by lush green foliage and a weathered wooden stairway.", "meta": "P1085185.jpg", + "tags": [ + "redwood", + "tree", + "forest", + "stairway", + "nature", + "california", + "california", + "tall" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085186.jpg", - "title": "P1085186", + "title": "Japanese Torii Gate in Lush Foliage", + "description": "A weathered torii gate stands partially obscured by vibrant [K green vegetation, suggesting a serene, secluded shrine.", "meta": "P1085186.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085195.jpg", - "title": "P1085195", + "title": "Cloudy mountain vista", + "description": "A hazy, overcast landscape f [1D [K featuring distant mountains and trees", "meta": "P1085195.jpg", + "tags": [ + "mountains", + "clouds", + "sky", + "43d k trees", + "tags", + "mountains", + "clouds", + "sky" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085198.jpg", - "title": "P1085198", + "title": "Overlooking a misty hillside", + "description": "A serene view of a f [1D [K forest and mountains under a cloudy sky", "meta": "P1085198.jpg", + "tags": [ + "landscape", + "forest", + "tree 39d k sky", + "tags", + "landscape", + "forest", + "trees", + "mountains" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085202.jpg", - "title": "P1085202", + "title": "Night Reflection on the River", + "description": "A dark bridge reflects light across a calm river under a [K starry night.", "meta": "P1085202.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1085209.jpg", - "title": "P1085209", + "title": "Rainy Street Scene", + "description": "A solitary figure walks down a wet, neon-lit street in a [K bustling Japanese city.", "meta": "P1085209.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105672.jpg", - "title": "P1105672", + "title": "Prague's Iconic Clock Tower", + "description": "A towering Gothic clock tower dominates the night sky.", "meta": "P1105672.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105680.jpg", - "title": "P1105680", + "title": "Tree-lined driveway and graveyard", + "description": "A weathered gra [3D [K gravel driveway cuts through a shadowy graveyard of ancient trees", "meta": "P1105680.jpg", + "tags": [ + "driveway", + "graveyard", + "trees", + "autumn", + "rural", + "path", + "shadows", + "stone" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105691.jpg", - "title": "P1105691", + "title": "Dark silhouette, obscured face", + "description": "A person's head is [2D [K is partially visible, blending into the shadows of a room", "meta": "P1105691.jpg", + "tags": [ + "shadows 22d k room", + "tags", + "shadows", + "head", + "darkness", + "portrait", + "minimalism", + "texture" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105692.jpg", - "title": "P1105692", + "title": "Forgotten stoneware in the woods", + "description": "A weathered clay [K pot rests amongst the trees, hinting at a quiet stillness", "meta": "P1105692.jpg", + "tags": [ + "still l 1d k life", + "texture", + "woodland", + "decay", + "nature", + "minimalism", + "earth", + "shadows" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105695.jpg", - "title": "P1105695", + "title": "bare winter trees", + "description": "a stark, desolate scene of leaf [4D [K leafless trees against a grey sky", "meta": "P1105695.jpg", + "tags": [ + "winter", + "trees", + "bare", + "silhoue 45d k sky", + "tags", + "winter", + "trees", + "bare" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105698.jpg", - "title": "P1105698", + "title": "Electric Guitarist in Concert", + "description": "A musician passionately plays electric guitar on stage durin [5D [K during a live performance.", "meta": "P1105698.jpg", + "tags": [ + "guitar", + "concert", + "stage", + "music", + "performance", + "rock", + "amplifier", + "tags" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105703.jpg", - "title": "P1105703", + "title": "bare trees against gray sky", + "description": "silhouetted trees cre [3D [K create a stark contrast against a hazy, overcast sky", "meta": "P1105703.jpg", + "tags": [ + "trees", + "silh 26d k sky", + "tags", + "trees", + "silhouettes", + "winter", + "sky", + "gray" + ], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105706.jpg", - "title": "P1105706", + "title": "Solitary Figure by the Water", + "description": "A dark silhouette sits pensively by the calm, reflective w [1D [K waters of a lake.", "meta": "P1105706.jpg", + "tags": [], "width": 5328, "height": 4000 }, { "src": "/photography/gallery/p1105713.jpg", - "title": "P1105713", + "title": "Red streaks on dark water", + "description": "Dynamic streaks of red [K light dance across the surface of still water reflecting city lights", "meta": "P1105713.jpg", + "tags": [ + "light trails", + "night photography", + "cityscape", + "water", + "ref 37d k photography", + "cityscape", + "water", + "reflection" + ], "width": 5106, "height": 3404 }, { "src": "/photography/gallery/p1105714.jpg", - "title": "P1105714", + "title": "Abstract Light Trails", + "description": "Swirling streaks of light illuminate a dark rural road at [K night.", "meta": "P1105714.jpg", + "tags": [ + "nightscape", + "light trails", + "road", + "rural", + "abstract", + "motion", + "d 45d k trails", + "road" + ], "width": 5106, "height": 3404 }, { "src": "/photography/gallery/p1105715.jpg", - "title": "P1105715", + "title": "Silhouetted Church at Dusk", + "description": "Dark church spires rise against a vibrant, hazy sunset sky [3D [K sky.", "meta": "P1105715.jpg", + "tags": [], "width": 5106, "height": 3404 }, { "src": "/photography/gallery/p1105716.jpg", - "title": "P1105716", + "title": "Fireworks Trails at Night", + "description": "Vibrant trails of firew [5D [K fireworks illuminate the dark night sky with streaks of color and light", "meta": "P1105716.jpg", + "tags": [ + "fireworks", + "night", + "trails", + "light", + "color", + "celebration", + "light", + "tgs" + ], "width": 5106, "height": 3404 }, { "src": "/photography/gallery/p1105724.jpg", - "title": "P1105724", + "title": "Distant Silhouette", + "description": "A muted view of a person’s back disappearing into a blurred [K interior space.", "meta": "P1105724.jpg", + "tags": [], "width": 5106, "height": 3404 } diff --git a/content/photography/gallery/70873501585.jpg b/content/photography/gallery/70873501585.jpg index fbe0c186..2ee6657c 100644 Binary files a/content/photography/gallery/70873501585.jpg and b/content/photography/gallery/70873501585.jpg differ diff --git a/content/photography/gallery/p1073908.jpg b/content/photography/gallery/p1073908.jpg index 92f0cfab..0fa59ebd 100644 Binary files a/content/photography/gallery/p1073908.jpg and b/content/photography/gallery/p1073908.jpg differ diff --git a/content/photography/gallery/p1073909.jpg b/content/photography/gallery/p1073909.jpg index b4b37a96..ae27a601 100644 Binary files a/content/photography/gallery/p1073909.jpg and b/content/photography/gallery/p1073909.jpg differ diff --git a/content/photography/gallery/p1073910.jpg b/content/photography/gallery/p1073910.jpg index 4900f420..5497d176 100644 Binary files a/content/photography/gallery/p1073910.jpg and b/content/photography/gallery/p1073910.jpg differ diff --git a/content/photography/gallery/p1073936.jpg b/content/photography/gallery/p1073936.jpg index 1ad63476..a00a6a34 100644 Binary files a/content/photography/gallery/p1073936.jpg and b/content/photography/gallery/p1073936.jpg differ diff --git a/content/photography/gallery/p1084939.jpg b/content/photography/gallery/p1084939.jpg index 36ac1689..a4fb0560 100644 Binary files a/content/photography/gallery/p1084939.jpg and b/content/photography/gallery/p1084939.jpg differ diff --git a/content/photography/gallery/p1084942.jpg b/content/photography/gallery/p1084942.jpg index b6ecdf01..1248c695 100644 Binary files a/content/photography/gallery/p1084942.jpg and b/content/photography/gallery/p1084942.jpg differ diff --git a/content/photography/gallery/p1084946.jpg b/content/photography/gallery/p1084946.jpg index 5e3de1da..0def73b5 100644 Binary files a/content/photography/gallery/p1084946.jpg and b/content/photography/gallery/p1084946.jpg differ diff --git a/content/photography/gallery/p1084950.jpg b/content/photography/gallery/p1084950.jpg index 4c774f34..435dc4e2 100644 Binary files a/content/photography/gallery/p1084950.jpg and b/content/photography/gallery/p1084950.jpg differ diff --git a/content/photography/gallery/p1084952.jpg b/content/photography/gallery/p1084952.jpg index 1d724a2d..98f4d3d3 100644 Binary files a/content/photography/gallery/p1084952.jpg and b/content/photography/gallery/p1084952.jpg differ diff --git a/content/photography/gallery/p1084958.jpg b/content/photography/gallery/p1084958.jpg index d83be853..54e5bdc0 100644 Binary files a/content/photography/gallery/p1084958.jpg and b/content/photography/gallery/p1084958.jpg differ diff --git a/content/photography/gallery/p1084977.jpg b/content/photography/gallery/p1084977.jpg index 60901cf2..3e71a4ed 100644 Binary files a/content/photography/gallery/p1084977.jpg and b/content/photography/gallery/p1084977.jpg differ diff --git a/content/photography/gallery/p1084998.jpg b/content/photography/gallery/p1084998.jpg index 4dcdc9ab..5110f876 100644 Binary files a/content/photography/gallery/p1084998.jpg and b/content/photography/gallery/p1084998.jpg differ diff --git a/content/photography/gallery/p1084999.jpg b/content/photography/gallery/p1084999.jpg index 49870970..e730ce00 100644 Binary files a/content/photography/gallery/p1084999.jpg and b/content/photography/gallery/p1084999.jpg differ diff --git a/content/photography/gallery/p1085002.jpg b/content/photography/gallery/p1085002.jpg index 40066cae..60b4a0ae 100644 Binary files a/content/photography/gallery/p1085002.jpg and b/content/photography/gallery/p1085002.jpg differ diff --git a/content/photography/gallery/p1085008.jpg b/content/photography/gallery/p1085008.jpg index 089d89ca..eeb6b4c2 100644 Binary files a/content/photography/gallery/p1085008.jpg and b/content/photography/gallery/p1085008.jpg differ diff --git a/content/photography/gallery/p1085009.jpg b/content/photography/gallery/p1085009.jpg index 166531b8..13b5424b 100644 Binary files a/content/photography/gallery/p1085009.jpg and b/content/photography/gallery/p1085009.jpg differ diff --git a/content/photography/gallery/p1085010.jpg b/content/photography/gallery/p1085010.jpg index ab6d295f..213bef67 100644 Binary files a/content/photography/gallery/p1085010.jpg and b/content/photography/gallery/p1085010.jpg differ diff --git a/content/photography/gallery/p1085015.jpg b/content/photography/gallery/p1085015.jpg index 8df0519e..67934176 100644 Binary files a/content/photography/gallery/p1085015.jpg and b/content/photography/gallery/p1085015.jpg differ diff --git a/content/photography/gallery/p1085016.jpg b/content/photography/gallery/p1085016.jpg index 2a0578db..23a94579 100644 Binary files a/content/photography/gallery/p1085016.jpg and b/content/photography/gallery/p1085016.jpg differ diff --git a/content/photography/gallery/p1085025.jpg b/content/photography/gallery/p1085025.jpg index d6baea2c..fe5cab91 100644 Binary files a/content/photography/gallery/p1085025.jpg and b/content/photography/gallery/p1085025.jpg differ diff --git a/content/photography/gallery/p1085029.jpg b/content/photography/gallery/p1085029.jpg index fa6a82e1..f8997cfc 100644 Binary files a/content/photography/gallery/p1085029.jpg and b/content/photography/gallery/p1085029.jpg differ diff --git a/content/photography/gallery/p1085030.jpg b/content/photography/gallery/p1085030.jpg index 9dc716b8..83c52627 100644 Binary files a/content/photography/gallery/p1085030.jpg and b/content/photography/gallery/p1085030.jpg differ diff --git a/content/photography/gallery/p1085033.jpg b/content/photography/gallery/p1085033.jpg index 77efc41c..f988ed94 100644 Binary files a/content/photography/gallery/p1085033.jpg and b/content/photography/gallery/p1085033.jpg differ diff --git a/content/photography/gallery/p1085034.jpg b/content/photography/gallery/p1085034.jpg index 06b50b12..8b09dd39 100644 Binary files a/content/photography/gallery/p1085034.jpg and b/content/photography/gallery/p1085034.jpg differ diff --git a/content/photography/gallery/p1085040.jpg b/content/photography/gallery/p1085040.jpg index 659c518c..c72ca21b 100644 Binary files a/content/photography/gallery/p1085040.jpg and b/content/photography/gallery/p1085040.jpg differ diff --git a/content/photography/gallery/p1085053.jpg b/content/photography/gallery/p1085053.jpg index 3dc52ba6..cfb58177 100644 Binary files a/content/photography/gallery/p1085053.jpg and b/content/photography/gallery/p1085053.jpg differ diff --git a/content/photography/gallery/p1085056.jpg b/content/photography/gallery/p1085056.jpg index e4ff32bf..9fab27ae 100644 Binary files a/content/photography/gallery/p1085056.jpg and b/content/photography/gallery/p1085056.jpg differ diff --git a/content/photography/gallery/p1085059.jpg b/content/photography/gallery/p1085059.jpg index f176a374..84305a5b 100644 Binary files a/content/photography/gallery/p1085059.jpg and b/content/photography/gallery/p1085059.jpg differ diff --git a/content/photography/gallery/p1085061.jpg b/content/photography/gallery/p1085061.jpg index 0c2e8a0a..d911d1f0 100644 Binary files a/content/photography/gallery/p1085061.jpg and b/content/photography/gallery/p1085061.jpg differ diff --git a/content/photography/gallery/p1085065.jpg b/content/photography/gallery/p1085065.jpg index 619f628f..f9aca95e 100644 Binary files a/content/photography/gallery/p1085065.jpg and b/content/photography/gallery/p1085065.jpg differ diff --git a/content/photography/gallery/p1085066.jpg b/content/photography/gallery/p1085066.jpg index 80b47952..53d3af0a 100644 Binary files a/content/photography/gallery/p1085066.jpg and b/content/photography/gallery/p1085066.jpg differ diff --git a/content/photography/gallery/p1085067.jpg b/content/photography/gallery/p1085067.jpg index e3b1ee29..681e38a3 100644 Binary files a/content/photography/gallery/p1085067.jpg and b/content/photography/gallery/p1085067.jpg differ diff --git a/content/photography/gallery/p1085070.jpg b/content/photography/gallery/p1085070.jpg index 55578b7c..1995c8d7 100644 Binary files a/content/photography/gallery/p1085070.jpg and b/content/photography/gallery/p1085070.jpg differ diff --git a/content/photography/gallery/p1085072.jpg b/content/photography/gallery/p1085072.jpg index bd0f3997..9136837f 100644 Binary files a/content/photography/gallery/p1085072.jpg and b/content/photography/gallery/p1085072.jpg differ diff --git a/content/photography/gallery/p1085076.jpg b/content/photography/gallery/p1085076.jpg index 4a094c38..3d04b79c 100644 Binary files a/content/photography/gallery/p1085076.jpg and b/content/photography/gallery/p1085076.jpg differ diff --git a/content/photography/gallery/p1085077.jpg b/content/photography/gallery/p1085077.jpg index 541d4d78..c095e06b 100644 Binary files a/content/photography/gallery/p1085077.jpg and b/content/photography/gallery/p1085077.jpg differ diff --git a/content/photography/gallery/p1085078.jpg b/content/photography/gallery/p1085078.jpg index 041d786b..e7a23b9d 100644 Binary files a/content/photography/gallery/p1085078.jpg and b/content/photography/gallery/p1085078.jpg differ diff --git a/content/photography/gallery/p1085082.jpg b/content/photography/gallery/p1085082.jpg index 9e5cf4ab..b434123f 100644 Binary files a/content/photography/gallery/p1085082.jpg and b/content/photography/gallery/p1085082.jpg differ diff --git a/content/photography/gallery/p1085083.jpg b/content/photography/gallery/p1085083.jpg index a78b56b4..b4315740 100644 Binary files a/content/photography/gallery/p1085083.jpg and b/content/photography/gallery/p1085083.jpg differ diff --git a/content/photography/gallery/p1085084.jpg b/content/photography/gallery/p1085084.jpg index a1a0f64c..7eb31e0f 100644 Binary files a/content/photography/gallery/p1085084.jpg and b/content/photography/gallery/p1085084.jpg differ diff --git a/content/photography/gallery/p1085087.jpg b/content/photography/gallery/p1085087.jpg index 2ac5da51..b7107f4a 100644 Binary files a/content/photography/gallery/p1085087.jpg and b/content/photography/gallery/p1085087.jpg differ diff --git a/content/photography/gallery/p1085088.jpg b/content/photography/gallery/p1085088.jpg index 28f7fe37..07ce8c83 100644 Binary files a/content/photography/gallery/p1085088.jpg and b/content/photography/gallery/p1085088.jpg differ diff --git a/content/photography/gallery/p1085096.jpg b/content/photography/gallery/p1085096.jpg index 78a973f3..2c538e28 100644 Binary files a/content/photography/gallery/p1085096.jpg and b/content/photography/gallery/p1085096.jpg differ diff --git a/content/photography/gallery/p1085097.jpg b/content/photography/gallery/p1085097.jpg index 4cb91e51..171a23c7 100644 Binary files a/content/photography/gallery/p1085097.jpg and b/content/photography/gallery/p1085097.jpg differ diff --git a/content/photography/gallery/p1085099.jpg b/content/photography/gallery/p1085099.jpg index e7225bb6..2f3abc80 100644 Binary files a/content/photography/gallery/p1085099.jpg and b/content/photography/gallery/p1085099.jpg differ diff --git a/content/photography/gallery/p1085130.jpg b/content/photography/gallery/p1085130.jpg index 1f6c4606..cca91f09 100644 Binary files a/content/photography/gallery/p1085130.jpg and b/content/photography/gallery/p1085130.jpg differ diff --git a/content/photography/gallery/p1085135.jpg b/content/photography/gallery/p1085135.jpg index 46e6c2c3..ab7c886a 100644 Binary files a/content/photography/gallery/p1085135.jpg and b/content/photography/gallery/p1085135.jpg differ diff --git a/content/photography/gallery/p1085146.jpg b/content/photography/gallery/p1085146.jpg index 747d7af1..5112b1c6 100644 Binary files a/content/photography/gallery/p1085146.jpg and b/content/photography/gallery/p1085146.jpg differ diff --git a/content/photography/gallery/p1085147.jpg b/content/photography/gallery/p1085147.jpg index 97092158..29579170 100644 Binary files a/content/photography/gallery/p1085147.jpg and b/content/photography/gallery/p1085147.jpg differ diff --git a/content/photography/gallery/p1085148.jpg b/content/photography/gallery/p1085148.jpg index cfb963e1..2a0c11bd 100644 Binary files a/content/photography/gallery/p1085148.jpg and b/content/photography/gallery/p1085148.jpg differ diff --git a/content/photography/gallery/p1085151.jpg b/content/photography/gallery/p1085151.jpg index d1d09886..3ba447bc 100644 Binary files a/content/photography/gallery/p1085151.jpg and b/content/photography/gallery/p1085151.jpg differ diff --git a/content/photography/gallery/p1085185.jpg b/content/photography/gallery/p1085185.jpg index 54643657..01a23e18 100644 Binary files a/content/photography/gallery/p1085185.jpg and b/content/photography/gallery/p1085185.jpg differ diff --git a/content/photography/gallery/p1085186.jpg b/content/photography/gallery/p1085186.jpg index d23502c9..c5048c19 100644 Binary files a/content/photography/gallery/p1085186.jpg and b/content/photography/gallery/p1085186.jpg differ diff --git a/content/photography/gallery/p1085195.jpg b/content/photography/gallery/p1085195.jpg index 4fdea753..8e2574ef 100644 Binary files a/content/photography/gallery/p1085195.jpg and b/content/photography/gallery/p1085195.jpg differ diff --git a/content/photography/gallery/p1085198.jpg b/content/photography/gallery/p1085198.jpg index a2116668..50db3455 100644 Binary files a/content/photography/gallery/p1085198.jpg and b/content/photography/gallery/p1085198.jpg differ diff --git a/content/photography/gallery/p1085202.jpg b/content/photography/gallery/p1085202.jpg index 6e94d189..12505df8 100644 Binary files a/content/photography/gallery/p1085202.jpg and b/content/photography/gallery/p1085202.jpg differ diff --git a/content/photography/gallery/p1085209.jpg b/content/photography/gallery/p1085209.jpg index 051132f0..eba0e040 100644 Binary files a/content/photography/gallery/p1085209.jpg and b/content/photography/gallery/p1085209.jpg differ diff --git a/content/photography/gallery/p1105672.jpg b/content/photography/gallery/p1105672.jpg index 6d06549a..52961e26 100644 Binary files a/content/photography/gallery/p1105672.jpg and b/content/photography/gallery/p1105672.jpg differ diff --git a/content/photography/gallery/p1105680.jpg b/content/photography/gallery/p1105680.jpg index 13939f61..bb18645f 100644 Binary files a/content/photography/gallery/p1105680.jpg and b/content/photography/gallery/p1105680.jpg differ diff --git a/content/photography/gallery/p1105691.jpg b/content/photography/gallery/p1105691.jpg index 10fab755..f10565cc 100644 Binary files a/content/photography/gallery/p1105691.jpg and b/content/photography/gallery/p1105691.jpg differ diff --git a/content/photography/gallery/p1105692.jpg b/content/photography/gallery/p1105692.jpg index 43076897..05f7c5b1 100644 Binary files a/content/photography/gallery/p1105692.jpg and b/content/photography/gallery/p1105692.jpg differ diff --git a/content/photography/gallery/p1105695.jpg b/content/photography/gallery/p1105695.jpg index ec5c4e56..28885c35 100644 Binary files a/content/photography/gallery/p1105695.jpg and b/content/photography/gallery/p1105695.jpg differ diff --git a/content/photography/gallery/p1105698.jpg b/content/photography/gallery/p1105698.jpg index cf6c73c4..365f2b00 100644 Binary files a/content/photography/gallery/p1105698.jpg and b/content/photography/gallery/p1105698.jpg differ diff --git a/content/photography/gallery/p1105703.jpg b/content/photography/gallery/p1105703.jpg index 5a2be56f..46132b10 100644 Binary files a/content/photography/gallery/p1105703.jpg and b/content/photography/gallery/p1105703.jpg differ diff --git a/content/photography/gallery/p1105706.jpg b/content/photography/gallery/p1105706.jpg index 668d3878..37ec68c3 100644 Binary files a/content/photography/gallery/p1105706.jpg and b/content/photography/gallery/p1105706.jpg differ diff --git a/content/photography/gallery/p1105713.jpg b/content/photography/gallery/p1105713.jpg index 1ca1781e..f38d6c0d 100644 Binary files a/content/photography/gallery/p1105713.jpg and b/content/photography/gallery/p1105713.jpg differ diff --git a/content/photography/gallery/p1105714.jpg b/content/photography/gallery/p1105714.jpg index 3250b70f..276b4603 100644 Binary files a/content/photography/gallery/p1105714.jpg and b/content/photography/gallery/p1105714.jpg differ diff --git a/content/photography/gallery/p1105715.jpg b/content/photography/gallery/p1105715.jpg index 85d55cc1..150dfa6c 100644 Binary files a/content/photography/gallery/p1105715.jpg and b/content/photography/gallery/p1105715.jpg differ diff --git a/content/photography/gallery/p1105716.jpg b/content/photography/gallery/p1105716.jpg index 3e2b9862..2631d02d 100644 Binary files a/content/photography/gallery/p1105716.jpg and b/content/photography/gallery/p1105716.jpg differ diff --git a/content/photography/gallery/p1105724.jpg b/content/photography/gallery/p1105724.jpg index 7ab77586..2845f99e 100644 Binary files a/content/photography/gallery/p1105724.jpg and b/content/photography/gallery/p1105724.jpg differ diff --git a/flake.nix b/flake.nix index 2cf40272..b0f8d47d 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ overrides = builtins.fromTOML (builtins.readFile (self + "/rust-toolchain.toml")); updateScript = pkgs.writeShellScriptBin "update" '' set -e - nix develop . --command cargo run --locked --manifest-path tools/site/Cargo.toml -- update + nix develop . --command cargo run --locked --no-default-features --manifest-path tools/site/Cargo.toml -- update ''; in { devShells.default = pkgs.mkShell rec { @@ -33,6 +33,7 @@ gnugrep llvmPackages.bintools bun + openssl python313 rustup typescript diff --git a/tools/site/Cargo.lock b/tools/site/Cargo.lock index a741db5e..4d2a212d 100644 --- a/tools/site/Cargo.lock +++ b/tools/site/Cargo.lock @@ -126,6 +126,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "lazy_static" version = "1.5.0" @@ -363,6 +369,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -376,6 +395,8 @@ dependencies = [ "image", "music21-rs", "recursive-ji-core", + "serde", + "serde_json", ] [[package]] @@ -395,6 +416,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zune-core" version = "0.5.1" diff --git a/tools/site/Cargo.toml b/tools/site/Cargo.toml index bdf3474a..c76fc843 100644 --- a/tools/site/Cargo.toml +++ b/tools/site/Cargo.toml @@ -4,10 +4,17 @@ version = "0.1.0" edition = "2021" publish = false +[features] +default = ["photos"] +photos = ["dep:image", "dep:serde", "dep:serde_json"] + [dependencies] -image = { version = "0.25.8", default-features = false, features = ["jpeg", "png", "webp"] } +image = { version = "0.25.8", default-features = false, features = ["jpeg", "png", "webp"], optional = true } music21-rs = { git = "https://github.com/float3/music21-rs.git" } recursive-ji-core = { path = "../../crates/recursive_ji_core" } +serde = { version = "1.0.228", features = ["derive"], optional = true } +serde_json = { version = "1.0.145", optional = true } +# zip = { version = "2.4.2", default-features = false, features = ["deflate"] } [[bin]] name = "site" diff --git a/tools/site/src/build.rs b/tools/site/src/build.rs new file mode 100644 index 00000000..03dee20b --- /dev/null +++ b/tools/site/src/build.rs @@ -0,0 +1,200 @@ +use crate::report; +use crate::{ + os_args, remove_dir_if_exists, remove_file_if_exists, remove_license_files, ChildGuard, + InstallMode, Mode, Result, Site, +}; +use std::fs; +use std::time::Instant; + +impl Site { + pub(crate) fn build(&self, mode: Mode) -> Result<()> { + let started = Instant::now(); + + if mode == Mode::Dev { + self.warn("building in development mode"); + } + + remove_dir_if_exists(&self.root.join("content/js"))?; + self.wasm(mode)?; + remove_license_files(&self.root.join("content/js"))?; + + self.bun_install(&self.root, InstallMode::Locked)?; + + let mut args = os_args(&["quartz/bootstrap-cli.mts", "build"]); + if mode == Mode::Dev { + args.push("--serve".into()); + } + let _typescript_watchers = if mode == Mode::Dev { + self.start_typescript_watchers()? + } else { + Vec::new() + }; + + self.run_bun(&self.root, &args)?; + + let public = self.root.join("public"); + report::write(self, &public, started.elapsed().as_secs()) + } + + pub(crate) fn wasm(&self, mode: Mode) -> Result<()> { + let wasm_dir = self.root.join("wasm/wasm"); + let mut wasm_args = os_args(&["build", "--target", "bundler"]); + + match mode { + Mode::Prod => { + wasm_args.push("--release".into()); + } + Mode::Dev => { + wasm_args.push("--dev".into()); + self.warn("building wasm in development mode"); + } + } + + wasm_args.extend(os_args(&["--features", "console_error_panic_hook"])); + + self.run_with_env( + &wasm_dir, + "wasm-pack", + &wasm_args, + &[("RUSTFLAGS", r#"--cfg getrandom_backend="wasm_js""#)], + )?; + remove_file_if_exists(&wasm_dir.join("pkg/.gitignore"))?; + + let ts_dir = self.root.join("ts"); + self.bun_install(&ts_dir, InstallMode::Locked)?; + self.sync_wasm_package_dependency()?; + self.run_bun(&ts_dir, &os_args(&["run", "tsc"]))?; + self.run_bun( + &ts_dir, + &os_args(&[ + "run", + "webpack", + "--config", + "webpack.config.mjs", + "--mode", + mode.webpack(), + ]), + ) + } + + fn start_typescript_watchers(&self) -> Result> { + let ts_dir = self.root.join("ts"); + self.warn("watching TypeScript and webpack output for Quartz reloads"); + Ok(vec![ + self.spawn_bun( + &ts_dir, + &os_args(&["run", "tsc", "--watch", "--preserveWatchOutput"]), + )?, + self.spawn_bun( + &ts_dir, + &os_args(&[ + "run", + "webpack", + "--config", + "webpack.config.mjs", + "--mode", + "development", + "--watch", + ]), + )?, + ]) + } + + fn sync_wasm_package_dependency(&self) -> Result<()> { + let source = self.root.join("wasm/wasm/pkg"); + let target = self.root.join("ts/node_modules/wasm"); + + if !target.exists() { + fs::create_dir_all(&target)?; + } + + if fs::canonicalize(&source)? == fs::canonicalize(&target)? { + return Ok(()); + } + + for entry in fs::read_dir(&source)? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_file() { + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if target_path.exists() { + fs::remove_file(&target_path)?; + } + fs::copy(source_path, target_path)?; + } + } + + Ok(()) + } + pub(crate) fn check(&self) -> Result<()> { + let mut site_check_args = os_args(&[ + "check", + "--locked", + "--manifest-path", + "tools/site/Cargo.toml", + ]); + let mut site_test_args = os_args(&[ + "test", + "--locked", + "--manifest-path", + "tools/site/Cargo.toml", + ]); + + if self.ci { + site_check_args.push("--no-default-features".into()); + site_test_args.push("--no-default-features".into()); + } + + if self.ci { + self.warn("checking site tool without default features"); + } + + self.run(&self.root, "cargo", &site_check_args)?; + self.run(&self.root, "cargo", &site_test_args)?; + self.wasm(Mode::Dev)?; + self.check_typescript()?; + + for (manifest, target_name) in [ + ("wasm/glsl2hlsl/Cargo.toml", "glsl2hlsl"), + ("wasm/textprocessing/Cargo.toml", "textprocessing"), + ("wasm/tuningplayground/Cargo.toml", "tuningplayground"), + ] { + self.cargo_check_manifest(manifest, target_name)?; + } + + Ok(()) + } + + fn check_typescript(&self) -> Result<()> { + let dir = self.root.join("ts"); + let local_tsc = dir.join("node_modules/typescript/bin/tsc"); + let local_eslint = dir.join("node_modules/eslint/bin/eslint.js"); + + if !local_tsc.is_file() || !local_eslint.is_file() { + self.bun_install(&dir, InstallMode::Locked)?; + } + + self.run_bun( + &dir, + &os_args(&["run", "tsc", "--noEmit", "--incremental", "false"]), + )?; + self.run_bun(&dir, &os_args(&["run", "eslint", "src"])) + } + + fn cargo_check_manifest(&self, manifest: &str, target_name: &str) -> Result<()> { + let target_dir = format!("target/check/{target_name}"); + self.run( + &self.root, + "cargo", + &os_args(&[ + "check", + "--locked", + "--manifest-path", + manifest, + "--target-dir", + target_dir.as_str(), + ]), + ) + } +} diff --git a/tools/site/src/content.rs b/tools/site/src/content.rs new file mode 100644 index 00000000..1c76d944 --- /dev/null +++ b/tools/site/src/content.rs @@ -0,0 +1,292 @@ +use crate::{os_args, Result, Site}; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; + +impl Site { + pub(crate) fn generate(&self) -> Result<()> { + self.links()?; + self.indices()?; + self.generate_chords()?; + self.dates("content") + } + + pub(crate) fn links(&self) -> Result<()> { + let output_dir = self.root.join("content/misc/plaintext"); + fs::create_dir_all(&output_dir)?; + + self.collect_links( + "content/notes/talks.md", + "content/misc/plaintext/talks.txt", + false, + )?; + self.collect_links( + "content/notes/blogs.md", + "content/misc/plaintext/blogs.txt", + false, + )?; + self.collect_links( + "content/notes/graphics-resources.md", + "content/misc/plaintext/graphics-resources.txt", + true, + ) + } + + fn collect_links(&self, input: &str, output: &str, unique: bool) -> Result<()> { + let source = fs::read_to_string(self.root.join(input))?; + let mut links = extract_urls(&source); + + if unique { + links.sort(); + links.dedup(); + } + + let mut body = String::new(); + for link in links { + body.push_str(&link); + body.push('\n'); + } + + fs::write(self.root.join(output), body)?; + Ok(()) + } + + pub(crate) fn indices(&self) -> Result<()> { + self.generate_index("media", "media")?; + self.generate_index("blobs", "blobs")?; + self.generate_index("plaintext", "plaintext")?; + let trolley_count = self.generate_index("trolley", "trolley")?; + self.update_trolley_count(trolley_count.saturating_sub(1)) + } + + fn generate_index(&self, dir: &str, title: &str) -> Result { + let base = self.root.join("content/misc").join(dir); + let mut entries = Vec::new(); + + for entry in fs::read_dir(&base)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name != "index.md" { + entries.push(name); + } + } + + entries.sort(); + + let existing_dates = existing_date_metadata(&base.join("index.md"))?; + let mut body = format!("---\ntitle: {title}\n"); + for line in existing_dates { + body.push_str(&line); + body.push('\n'); + } + body.push_str("tags:\n - list\n---\n\n"); + for (index, entry) in entries.iter().enumerate() { + let continuation = if index + 1 == entries.len() { + "" + } else { + " \\" + }; + body.push_str(&format!("[{entry}](/misc/{dir}/{entry}){continuation}\n")); + } + + fs::write(base.join("index.md"), body)?; + Ok(entries.len()) + } + + fn update_trolley_count(&self, count: usize) -> Result<()> { + let path = self.root.join("ts/src/trolley.ts"); + let source = fs::read_to_string(&path)?; + let replacement = format!("const NUM = {count}"); + let mut replaced = false; + let mut body = String::new(); + + for line in source.lines() { + if !replaced && line.starts_with("const NUM =") { + body.push_str(&replacement); + replaced = true; + } else { + body.push_str(line); + } + body.push('\n'); + } + + if !replaced { + body = format!("{replacement}\n{body}"); + } + + fs::write(path, body)?; + Ok(()) + } + + fn generate_chords(&self) -> Result<()> { + let dir = self.root.join("wasm/tuningplayground"); + self.run(&dir, "cargo", &os_args(&["run", "-p", "chord_generator"])) + } + pub(crate) fn dates(&self, target: &str) -> Result<()> { + if self.ci { + let shallow = self.output_optional( + &self.root, + "git", + &os_args(&["rev-parse", "--is-shallow-repository"]), + )?; + if shallow.as_deref() == Some("true") { + self.run(&self.root, "git", &os_args(&["fetch", "--unshallow"]))?; + } + } + + let mut files = Vec::new(); + collect_markdown_files(&self.root.join(target), &mut files)?; + files.sort(); + + for file in files { + self.update_dates_for_file(&file)?; + } + + Ok(()) + } + + fn update_dates_for_file(&self, file: &Path) -> Result<()> { + let relative = self.relative_git_path(file)?; + let created = self.git_iso_date(&os_args(&[ + "log", + "--diff-filter=A", + "--follow", + "--format=%aI", + "-1", + "--", + relative.as_str(), + ]))?; + + let Some(created) = created else { + self.warn(&format!("skipping untracked markdown file: {relative}")); + return Ok(()); + }; + + let updated = self + .git_iso_date(&os_args(&[ + "log", + "--invert-grep", + "--grep=generate", + "-1", + "--format=%aI", + "--", + relative.as_str(), + ]))? + .unwrap_or_else(|| created.clone()); + + let source = fs::read_to_string(file)?; + let mut body = String::new(); + let mut inserted = false; + + for line in source.lines() { + if line.starts_with("date:") || line.starts_with("updated:") { + continue; + } + + body.push_str(line); + body.push('\n'); + + if !inserted && line.starts_with("title:") { + body.push_str(&format!("date: {created}\nupdated: {updated}\n")); + inserted = true; + } + } + + if !inserted { + body = format!("date: {created}\nupdated: {updated}\n\n{body}"); + } + + fs::write(file, body)?; + Ok(()) + } +} + +fn existing_date_metadata(path: &Path) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + + let source = fs::read_to_string(path)?; + Ok(source + .lines() + .filter(|line| line.starts_with("date:") || line.starts_with("updated:")) + .map(str::to_string) + .collect()) +} + +fn collect_markdown_files(dir: &Path, files: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_markdown_files(&path, files)?; + } else if path + .extension() + .is_some_and(|extension| extension == OsStr::new("md")) + { + files.push(path); + } + } + + Ok(()) +} + +fn extract_urls(source: &str) -> Vec { + let mut links = Vec::new(); + let mut offset = 0; + + while let Some(start) = find_next_url(&source[offset..]) { + let absolute_start = offset + start; + let mut absolute_end = absolute_start; + + for (index, ch) in source[absolute_start..].char_indices() { + if is_url_delimiter(ch) { + break; + } + absolute_end = absolute_start + index + ch.len_utf8(); + } + + if absolute_end > absolute_start { + let link = source[absolute_start..absolute_end] + .trim_end_matches(['.', ',', ';']) + .to_string(); + links.push(link); + } + + offset = absolute_end.max(absolute_start + 1); + } + + links +} + +fn find_next_url(source: &str) -> Option { + match (source.find("http://"), source.find("https://")) { + (Some(http), Some(https)) => Some(http.min(https)), + (Some(http), None) => Some(http), + (None, Some(https)) => Some(https), + (None, None) => None, + } +} + +fn is_url_delimiter(ch: char) -> bool { + ch.is_whitespace() || matches!(ch, '<' | '>' | '"' | '\'' | '`' | '[' | ']' | '(' | ')') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_markdown_urls_without_trailing_punctuation() { + let links = + extract_urls(r#"see [one](https://example.com/a?b=1), "http://example.org/x"."#); + + assert_eq!( + links, + vec![ + "https://example.com/a?b=1".to_string(), + "http://example.org/x".to_string() + ] + ); + } +} diff --git a/tools/site/src/fsutil.rs b/tools/site/src/fsutil.rs new file mode 100644 index 00000000..d6eeb841 --- /dev/null +++ b/tools/site/src/fsutil.rs @@ -0,0 +1,99 @@ +use crate::{Result, Site, SiteError}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +impl Site { + #[cfg(feature = "photos")] + pub(crate) fn resolve_path(&self, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + self.root.join(path) + } + } + + #[cfg(feature = "photos")] + pub(crate) fn ensure_photo_input_is_private(&self, path: &Path) -> Result<()> { + let normalized_root = normalize_path(&self.root)?; + let normalized_path = normalize_path(path)?; + let content_dir = normalized_root.join("content"); + + if normalized_path.starts_with(&content_dir) { + return Err(Box::new(SiteError::new(format!( + "photo originals must not live under {}; use private/photography/originals or another untracked directory", + content_dir.display() + )))); + } + + Ok(()) + } +} + +pub(crate) fn find_repo_root() -> Result { + let mut current = env::current_dir()?; + + loop { + if current.join(".git").exists() + && (current.join("quartz.config.ts").exists() + || current.join("quartz.config.yaml").exists() + || current.join("quartz.ts").exists()) + { + return Ok(current); + } + + if !current.pop() { + break; + } + } + + Err(Box::new(SiteError( + "could not find repository root from current directory".to_string(), + ))) +} + +pub(crate) fn remove_dir_if_exists(path: &Path) -> Result<()> { + if path.exists() { + fs::remove_dir_all(path)?; + } + Ok(()) +} + +pub(crate) fn remove_file_if_exists(path: &Path) -> Result<()> { + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +pub(crate) fn remove_license_files(path: &Path) -> Result<()> { + if !path.is_dir() { + return Ok(()); + } + + for entry in fs::read_dir(path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.ends_with("LICENSE.txt") { + fs::remove_file(entry.path())?; + } + } + + Ok(()) +} + +#[cfg(feature = "photos")] +fn normalize_path(path: &Path) -> Result { + if path.exists() { + Ok(path.canonicalize()?) + } else if let Some(parent) = path.parent() { + let parent = normalize_path(parent)?; + Ok(parent.join( + path.file_name() + .ok_or_else(|| SiteError::new(format!("invalid path: {}", path.display())))?, + )) + } else { + Ok(path.to_path_buf()) + } +} diff --git a/tools/site/src/main.rs b/tools/site/src/main.rs index 6a826e51..7e05c2fc 100644 --- a/tools/site/src/main.rs +++ b/tools/site/src/main.rs @@ -1,21 +1,40 @@ mod aoc; +mod build; +mod content; +mod fsutil; +mod maintenance; +#[cfg(feature = "photos")] +mod photos; +#[cfg(not(feature = "photos"))] +mod photos { + use crate::{Result, Site, SiteError}; + + pub(crate) fn process(_: &Site, _: &[String]) -> Result<()> { + Err(Box::new(SiteError::new( + "process-photos requires the `photos` feature; rebuild without --no-default-features", + ))) + } + + // pub(crate) fn setup_nightshade(_: &Site, _: &[String]) -> Result<()> { + // Err(Box::new(SiteError::new( + // "setup-nightshade requires the `photos` feature; rebuild without --no-default-features", + // ))) + // } +} +mod process; mod recursive_ji; mod report; mod tables; -use image::codecs::jpeg::JpegEncoder; -use image::{ImageReader, Rgb, RgbImage}; -use std::collections::HashMap; use std::env; use std::error::Error; -use std::ffi::{OsStr, OsString}; use std::fmt; -use std::fs; -use std::fs::File; -use std::io::BufWriter; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::time::Instant; +use std::path::PathBuf; + +pub(crate) use fsutil::{ + find_repo_root, remove_dir_if_exists, remove_file_if_exists, remove_license_files, +}; +pub(crate) use process::{os_args, ChildGuard, InstallMode}; pub(crate) type Result = std::result::Result>; @@ -37,30 +56,13 @@ impl fmt::Display for SiteError { impl Error for SiteError {} #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Mode { +pub(crate) enum Mode { Dev, Prod, } -struct PhotoPoisonOptions { - input: PathBuf, - output: PathBuf, - manifest: PathBuf, - strength: u8, - quality: u8, - dry_run: bool, -} - -struct GalleryEntry { - src: String, - title: String, - meta: String, - width: u32, - height: u32, -} - impl Mode { - fn webpack(self) -> &'static str { + pub(crate) fn webpack(self) -> &'static str { match self { Mode::Dev => "development", Mode::Prod => "production", @@ -78,29 +80,7 @@ impl Mode { pub(crate) struct Site { pub(crate) root: PathBuf, - ci: bool, -} - -struct ChildGuard { - child: Child, - label: String, -} - -impl Drop for ChildGuard { - fn drop(&mut self) { - if matches!(self.child.try_wait(), Ok(Some(_))) { - return; - } - - if let Err(error) = self.child.kill() { - eprintln!("warning: failed to stop {}: {error}", self.label); - return; - } - - if let Err(error) = self.child.wait() { - eprintln!("warning: failed to wait for {}: {error}", self.label); - } - } + pub(crate) ci: bool, } impl Site { @@ -110,941 +90,6 @@ impl Site { ci: env::var("GITHUB_ACTIONS").is_ok_and(|value| value == "true"), }) } - - fn build(&self, mode: Mode) -> Result<()> { - let started = Instant::now(); - - if mode == Mode::Dev { - self.warn("building in development mode"); - } - - remove_dir_if_exists(&self.root.join("content/js"))?; - self.wasm(mode)?; - remove_license_files(&self.root.join("content/js"))?; - - self.bun_install(&self.root, InstallMode::Locked)?; - - let mut args = os_args(&["quartz/bootstrap-cli.mts", "build"]); - if mode == Mode::Dev { - args.push("--serve".into()); - } - let _typescript_watchers = if mode == Mode::Dev { - self.start_typescript_watchers()? - } else { - Vec::new() - }; - - self.run_bun(&self.root, &args)?; - - let public = self.root.join("public"); - report::write(self, &public, started.elapsed().as_secs()) - } - - fn wasm(&self, mode: Mode) -> Result<()> { - let wasm_dir = self.root.join("wasm/wasm"); - let mut wasm_args = os_args(&["build", "--target", "bundler"]); - - match mode { - Mode::Prod => { - wasm_args.push("--release".into()); - } - Mode::Dev => { - wasm_args.push("--dev".into()); - self.warn("building wasm in development mode"); - } - } - - wasm_args.extend(os_args(&["--features", "console_error_panic_hook"])); - - self.run_with_env( - &wasm_dir, - "wasm-pack", - &wasm_args, - &[("RUSTFLAGS", r#"--cfg getrandom_backend="wasm_js""#)], - )?; - remove_file_if_exists(&wasm_dir.join("pkg/.gitignore"))?; - - let ts_dir = self.root.join("ts"); - self.bun_install(&ts_dir, InstallMode::Locked)?; - self.sync_wasm_package_dependency()?; - self.run_bun(&ts_dir, &os_args(&["run", "tsc"]))?; - self.run_bun( - &ts_dir, - &os_args(&[ - "run", - "webpack", - "--config", - "webpack.config.mjs", - "--mode", - mode.webpack(), - ]), - ) - } - - fn start_typescript_watchers(&self) -> Result> { - let ts_dir = self.root.join("ts"); - self.warn("watching TypeScript and webpack output for Quartz reloads"); - Ok(vec![ - self.spawn_bun( - &ts_dir, - &os_args(&["run", "tsc", "--watch", "--preserveWatchOutput"]), - )?, - self.spawn_bun( - &ts_dir, - &os_args(&[ - "run", - "webpack", - "--config", - "webpack.config.mjs", - "--mode", - "development", - "--watch", - ]), - )?, - ]) - } - - fn sync_wasm_package_dependency(&self) -> Result<()> { - let source = self.root.join("wasm/wasm/pkg"); - let target = self.root.join("ts/node_modules/wasm"); - - if !target.exists() { - fs::create_dir_all(&target)?; - } - - if fs::canonicalize(&source)? == fs::canonicalize(&target)? { - return Ok(()); - } - - for entry in fs::read_dir(&source)? { - let entry = entry?; - let file_type = entry.file_type()?; - if file_type.is_file() { - let source_path = entry.path(); - let target_path = target.join(entry.file_name()); - if target_path.exists() { - fs::remove_file(&target_path)?; - } - fs::copy(source_path, target_path)?; - } - } - - Ok(()) - } - - fn generate(&self) -> Result<()> { - self.links()?; - self.indices()?; - self.generate_chords()?; - self.dates("content") - } - - fn links(&self) -> Result<()> { - let output_dir = self.root.join("content/misc/plaintext"); - fs::create_dir_all(&output_dir)?; - - self.collect_links( - "content/notes/talks.md", - "content/misc/plaintext/talks.txt", - false, - )?; - self.collect_links( - "content/notes/blogs.md", - "content/misc/plaintext/blogs.txt", - false, - )?; - self.collect_links( - "content/notes/graphics-resources.md", - "content/misc/plaintext/graphics-resources.txt", - true, - ) - } - - fn collect_links(&self, input: &str, output: &str, unique: bool) -> Result<()> { - let source = fs::read_to_string(self.root.join(input))?; - let mut links = extract_urls(&source); - - if unique { - links.sort(); - links.dedup(); - } - - let mut body = String::new(); - for link in links { - body.push_str(&link); - body.push('\n'); - } - - fs::write(self.root.join(output), body)?; - Ok(()) - } - - fn indices(&self) -> Result<()> { - self.generate_index("media", "media")?; - self.generate_index("blobs", "blobs")?; - self.generate_index("plaintext", "plaintext")?; - let trolley_count = self.generate_index("trolley", "trolley")?; - self.update_trolley_count(trolley_count.saturating_sub(1)) - } - - fn generate_index(&self, dir: &str, title: &str) -> Result { - let base = self.root.join("content/misc").join(dir); - let mut entries = Vec::new(); - - for entry in fs::read_dir(&base)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().into_owned(); - if name != "index.md" { - entries.push(name); - } - } - - entries.sort(); - - let existing_dates = existing_date_metadata(&base.join("index.md"))?; - let mut body = format!("---\ntitle: {title}\n"); - for line in existing_dates { - body.push_str(&line); - body.push('\n'); - } - body.push_str("tags:\n - list\n---\n\n"); - for (index, entry) in entries.iter().enumerate() { - let continuation = if index + 1 == entries.len() { - "" - } else { - " \\" - }; - body.push_str(&format!("[{entry}](/misc/{dir}/{entry}){continuation}\n")); - } - - fs::write(base.join("index.md"), body)?; - Ok(entries.len()) - } - - fn update_trolley_count(&self, count: usize) -> Result<()> { - let path = self.root.join("ts/src/trolley.ts"); - let source = fs::read_to_string(&path)?; - let replacement = format!("const NUM = {count}"); - let mut replaced = false; - let mut body = String::new(); - - for line in source.lines() { - if !replaced && line.starts_with("const NUM =") { - body.push_str(&replacement); - replaced = true; - } else { - body.push_str(line); - } - body.push('\n'); - } - - if !replaced { - body = format!("{replacement}\n{body}"); - } - - fs::write(path, body)?; - Ok(()) - } - - fn generate_chords(&self) -> Result<()> { - let dir = self.root.join("wasm/tuningplayground"); - self.run(&dir, "cargo", &os_args(&["run", "-p", "chord_generator"])) - } - - fn check(&self) -> Result<()> { - self.run( - &self.root, - "cargo", - &os_args(&[ - "check", - "--locked", - "--manifest-path", - "tools/site/Cargo.toml", - ]), - )?; - self.run( - &self.root, - "cargo", - &os_args(&[ - "test", - "--locked", - "--manifest-path", - "tools/site/Cargo.toml", - ]), - )?; - self.wasm(Mode::Dev)?; - self.check_typescript()?; - - for (manifest, target_name) in [ - ("wasm/glsl2hlsl/Cargo.toml", "glsl2hlsl"), - ("wasm/textprocessing/Cargo.toml", "textprocessing"), - ("wasm/tuningplayground/Cargo.toml", "tuningplayground"), - ] { - self.cargo_check_manifest(manifest, target_name)?; - } - - Ok(()) - } - - fn check_typescript(&self) -> Result<()> { - let dir = self.root.join("ts"); - let local_tsc = dir.join("node_modules/typescript/bin/tsc"); - let local_eslint = dir.join("node_modules/eslint/bin/eslint.js"); - - if !local_tsc.is_file() || !local_eslint.is_file() { - self.bun_install(&dir, InstallMode::Locked)?; - } - - self.run_bun( - &dir, - &os_args(&["run", "tsc", "--noEmit", "--incremental", "false"]), - )?; - self.run_bun(&dir, &os_args(&["run", "eslint", "src"])) - } - - fn cargo_check_manifest(&self, manifest: &str, target_name: &str) -> Result<()> { - let target_dir = format!("target/check/{target_name}"); - self.run( - &self.root, - "cargo", - &os_args(&[ - "check", - "--locked", - "--manifest-path", - manifest, - "--target-dir", - target_dir.as_str(), - ]), - ) - } - - fn poison_photos(&self, args: &[String]) -> Result<()> { - if args.iter().any(|arg| arg == "--help" || arg == "-h") { - print_photo_poison_help(); - return Ok(()); - } - - let options = self.parse_photo_poison_options(args)?; - self.ensure_photo_input_is_private(&options.input)?; - fs::create_dir_all(&options.input)?; - fs::create_dir_all(&options.output)?; - - let mut photos = Vec::new(); - collect_photo_files(&options.input, &mut photos)?; - photos.sort(); - - if photos.is_empty() { - self.warn(&format!( - "no source photos found in {}; drop originals there and rerun poison-photos", - options.input.display() - )); - return Ok(()); - } - - let mut entries = Vec::new(); - let mut names = HashMap::::new(); - - for photo in photos { - let relative = photo.strip_prefix(&options.input).unwrap_or(&photo); - let stem = photo - .file_stem() - .and_then(OsStr::to_str) - .map_or("photo", |value| value); - let base = sanitize_file_stem(stem); - let count = names.entry(base.clone()).or_insert(0); - *count += 1; - let file_name = if *count == 1 { - format!("{base}.jpg") - } else { - format!("{base}-{count}.jpg") - }; - let target = options.output.join(&file_name); - let title = title_from_stem(stem); - - println!( - "poison {} -> {}", - relative.display(), - target.strip_prefix(&self.root).unwrap_or(&target).display() - ); - - let (width, height) = if options.dry_run { - (0, 0) - } else { - poison_photo_file( - &photo, - &target, - hash64(relative.to_string_lossy().as_bytes()), - options.strength, - options.quality, - )? - }; - - entries.push(GalleryEntry { - src: format!("/photography/gallery/{file_name}"), - title, - meta: relative.to_string_lossy().replace('\\', "/"), - width, - height, - }); - } - - if !options.dry_run { - write_gallery_manifest(&options.manifest, &entries)?; - } - - Ok(()) - } - - fn parse_photo_poison_options(&self, args: &[String]) -> Result { - let mut positionals = Vec::new(); - let mut strength = 4_u8; - let mut quality = 92_u8; - let mut manifest = self.root.join("content/photography/gallery.json"); - let mut dry_run = false; - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--strength" => { - index += 1; - let value = args.get(index).ok_or_else(|| { - Box::new(SiteError::new("--strength requires a value")) as Box - })?; - strength = value.parse::().map_err(|source| { - SiteError::new(format!("invalid strength {value:?}: {source}")) - })?; - } - "--quality" => { - index += 1; - let value = args.get(index).ok_or_else(|| { - Box::new(SiteError::new("--quality requires a value")) as Box - })?; - quality = value.parse::().map_err(|source| { - SiteError::new(format!("invalid quality {value:?}: {source}")) - })?; - } - "--manifest" => { - index += 1; - let value = args.get(index).ok_or_else(|| { - Box::new(SiteError::new("--manifest requires a path")) as Box - })?; - manifest = self.resolve_path(value); - } - "--dry-run" => dry_run = true, - "--help" | "-h" => { - print_photo_poison_help(); - return Err(Box::new(SiteError::new("help requested"))); - } - value if value.starts_with('-') => { - return Err(Box::new(SiteError::new(format!( - "unknown poison-photos option: {value}" - )))); - } - value => positionals.push(value.to_string()), - } - index += 1; - } - - if !(1..=32).contains(&strength) { - return Err(Box::new(SiteError::new( - "strength must be between 1 and 32", - ))); - } - if !(60..=100).contains(&quality) { - return Err(Box::new(SiteError::new( - "quality must be between 60 and 100", - ))); - } - if positionals.len() > 2 { - return Err(Box::new(SiteError::new( - "poison-photos accepts at most INPUT and OUTPUT paths", - ))); - } - - Ok(PhotoPoisonOptions { - input: positionals.first().map_or_else( - || self.root.join("private/photography/originals"), - |path| self.resolve_path(path), - ), - output: positionals.get(1).map_or_else( - || self.root.join("content/photography/gallery"), - |path| self.resolve_path(path), - ), - manifest, - strength, - quality, - dry_run, - }) - } - - fn resolve_path(&self, path: &str) -> PathBuf { - let path = PathBuf::from(path); - if path.is_absolute() { - path - } else { - self.root.join(path) - } - } - - fn ensure_photo_input_is_private(&self, path: &Path) -> Result<()> { - let normalized_root = normalize_path(&self.root)?; - let normalized_path = normalize_path(path)?; - let content_dir = normalized_root.join("content"); - - if normalized_path.starts_with(&content_dir) { - return Err(Box::new(SiteError::new(format!( - "photo originals must not live under {}; use private/photography/originals or another untracked directory", - content_dir.display() - )))); - } - - Ok(()) - } - - fn dates(&self, target: &str) -> Result<()> { - if self.ci { - let shallow = self.output_optional( - &self.root, - "git", - &os_args(&["rev-parse", "--is-shallow-repository"]), - )?; - if shallow.as_deref() == Some("true") { - self.run(&self.root, "git", &os_args(&["fetch", "--unshallow"]))?; - } - } - - let mut files = Vec::new(); - collect_markdown_files(&self.root.join(target), &mut files)?; - files.sort(); - - for file in files { - self.update_dates_for_file(&file)?; - } - - Ok(()) - } - - fn update_dates_for_file(&self, file: &Path) -> Result<()> { - let relative = self.relative_git_path(file)?; - let created = self.git_iso_date(&os_args(&[ - "log", - "--diff-filter=A", - "--follow", - "--format=%aI", - "-1", - "--", - relative.as_str(), - ]))?; - - let Some(created) = created else { - self.warn(&format!("skipping untracked markdown file: {relative}")); - return Ok(()); - }; - - let updated = self - .git_iso_date(&os_args(&[ - "log", - "--invert-grep", - "--grep=generate", - "-1", - "--format=%aI", - "--", - relative.as_str(), - ]))? - .unwrap_or_else(|| created.clone()); - - let source = fs::read_to_string(file)?; - let mut body = String::new(); - let mut inserted = false; - - for line in source.lines() { - if line.starts_with("date:") || line.starts_with("updated:") { - continue; - } - - body.push_str(line); - body.push('\n'); - - if !inserted && line.starts_with("title:") { - body.push_str(&format!("date: {created}\nupdated: {updated}\n")); - inserted = true; - } - } - - if !inserted { - body = format!("date: {created}\nupdated: {updated}\n\n{body}"); - } - - fs::write(file, body)?; - Ok(()) - } - - fn update(&self) -> Result<()> { - self.run( - &self.root, - "git", - &os_args(&[ - "submodule", - "update", - "--remote", - "--recursive", - "--", - "wasm/tuningplayground/music21", - ]), - )?; - - self.run_with_env( - &self.root.join("wasm/wasm"), - "wasm-pack", - &os_args(&[ - "build", - "--target", - "bundler", - if self.ci { "--release" } else { "--dev" }, - ]), - &[("RUSTFLAGS", r#"--cfg getrandom_backend="wasm_js""#)], - )?; - - self.run_bun(&self.root, &os_args(&["update"]))?; - self.run_bun(&self.root, &os_args(&["audit"]))?; - self.bun_install(&self.root, InstallMode::Unlocked)?; - - self.node_update(&self.root.join("ts"), "src")?; - - for crate_dir in [ - "wasm/tuningplayground", - "wasm/tuningplayground/tuning_systems", - "wasm/tuningplayground/keymapping", - "wasm/textprocessing", - "wasm/textprocessing/hangeul_conversion", - "wasm/glsl2hlsl", - "wasm/adventofcode", - "wasm/wasm", - ] { - self.cargo_update(&self.root.join(crate_dir))?; - } - - Ok(()) - } - - fn node_update(&self, dir: &Path, lint_target: &str) -> Result<()> { - self.run_bun(dir, &os_args(&["update"]))?; - self.run_bun(dir, &os_args(&["audit"]))?; - self.bun_install(dir, InstallMode::Unlocked)?; - self.run_bun(dir, &os_args(&["run", "prettier", lint_target, "--write"]))?; - self.run_bun(dir, &os_args(&["run", "eslint", lint_target, "--fix"])) - } - - fn cargo_update(&self, dir: &Path) -> Result<()> { - self.run(dir, "cargo", &os_args(&["upgrade"]))?; - self.run(dir, "cargo", &os_args(&["update", "--workspace"]))?; - self.run( - dir, - "cargo", - &os_args(&[ - "hack", - "clippy", - "--feature-powerset", - "--fix", - "--allow-dirty", - "--allow-staged", - "--all-targets", - "--workspace", - "--", - "-D", - "warnings", - ]), - )?; - self.run( - dir, - "cargo", - &os_args(&[ - "hack", - "fix", - "--feature-powerset", - "--allow-dirty", - "--allow-staged", - "--all-targets", - "--workspace", - ]), - )?; - self.run( - dir, - "cargo", - &os_args(&[ - "hack", - "check", - "--feature-powerset", - "--all-targets", - "--workspace", - ]), - )?; - self.run( - dir, - "cargo", - &os_args(&[ - "hack", - "test", - "--feature-powerset", - "--release", - "--verbose", - "--all-targets", - "--workspace", - "--no-fail-fast", - "--lib", - "--bins", - "--examples", - "--tests", - "--benches", - ]), - )?; - self.run(dir, "cargo", &os_args(&["fmt", "--all"])) - } - - fn commit(&self, message: Option) -> Result<()> { - if !self.ci { - return Err(Box::new(SiteError::new( - "commit is CI-only; review and commit local changes with git".to_string(), - ))); - } - - let message = message - .or_else(|| env::var("GITHUB_JOB").ok()) - .unwrap_or_else(|| "update generated files".to_string()); - - self.run( - &self.root, - "git", - &os_args(&[ - "config", - "user.email", - "github-actions[bot]@users.noreply.github.com", - ]), - )?; - self.run( - &self.root, - "git", - &os_args(&["config", "user.name", "github-actions[bot]"]), - )?; - self.run(&self.root, "git", &os_args(&["add", "-A"]))?; - - let has_changes = !self.status_success( - &self.root, - "git", - &os_args(&["diff", "--cached", "--quiet"]), - )?; - - if !has_changes { - println!("No changes to commit"); - return Ok(()); - } - - self.run( - &self.root, - "git", - &os_args(&["commit", "-m", message.as_str()]), - )?; - self.run(&self.root, "git", &os_args(&["push"])) - } - - fn bun_install(&self, dir: &Path, mode: InstallMode) -> Result<()> { - let args = match mode { - InstallMode::Locked if self.ci => os_args(&["ci"]), - InstallMode::Locked => os_args(&["install"]), - InstallMode::Unlocked => os_args(&["install"]), - }; - - if bun_program().is_none() && dir.join("node_modules").is_dir() { - self.warn(&format!( - "bun was not found; reusing existing dependencies in {}", - dir.display() - )); - return Ok(()); - } - - self.run_bun(dir, &args) - } - - fn run_bun(&self, cwd: &Path, args: &[OsString]) -> Result<()> { - if let Some(program) = bun_program() { - return self.run(cwd, program, args); - } - - Err(Box::new(SiteError::new( - "could not find bun; install Bun or run bun install before building", - ))) - } - - fn spawn_bun(&self, cwd: &Path, args: &[OsString]) -> Result { - if let Some(program) = bun_program() { - return self.spawn(cwd, program, args); - } - - Err(Box::new(SiteError::new( - "could not find bun; install Bun or run bun install before building", - ))) - } - - fn git_iso_date(&self, args: &[OsString]) -> Result> { - let Some(output) = self.output_optional(&self.root, "git", args)? else { - return Ok(None); - }; - - Ok(output - .split('T') - .next() - .filter(|value| !value.is_empty()) - .map(str::to_string)) - } - - fn relative_git_path(&self, path: &Path) -> Result { - Ok(path - .strip_prefix(&self.root)? - .to_string_lossy() - .replace('\\', "/")) - } - - fn warn(&self, message: &str) { - if self.ci { - println!("::warning::{message}"); - } else { - eprintln!("warning: {message}"); - } - } - - fn run

(&self, cwd: &Path, program: P, args: &[OsString]) -> Result<()> - where - P: AsRef, - { - self.run_with_env(cwd, program, args, &[]) - } - - fn spawn

(&self, cwd: &Path, program: P, args: &[OsString]) -> Result - where - P: AsRef, - { - let program = program.as_ref(); - self.print_command(cwd, program, args); - - let child = Command::new(program) - .args(args) - .current_dir(cwd) - .spawn() - .map_err(|source| { - SiteError(format!( - "failed to run {}: {source}", - format_command(program, args) - )) - })?; - - Ok(ChildGuard { - child, - label: format_command(program, args), - }) - } - - fn run_with_env

( - &self, - cwd: &Path, - program: P, - args: &[OsString], - envs: &[(&str, &str)], - ) -> Result<()> - where - P: AsRef, - { - let program = program.as_ref(); - self.print_command(cwd, program, args); - - let mut command = Command::new(program); - command.args(args).current_dir(cwd); - for (key, value) in envs { - command.env(key, value); - } - - let status = command.status().map_err(|source| { - SiteError(format!( - "failed to run {}: {source}", - format_command(program, args) - )) - })?; - - if status.success() { - Ok(()) - } else { - Err(Box::new(SiteError(format!( - "command failed with {status}: {}", - format_command(program, args) - )))) - } - } - - pub(crate) fn output_optional

( - &self, - cwd: &Path, - program: P, - args: &[OsString], - ) -> Result> - where - P: AsRef, - { - let program = program.as_ref(); - let output = Command::new(program) - .args(args) - .current_dir(cwd) - .stderr(Stdio::inherit()) - .output() - .map_err(|source| { - SiteError(format!( - "failed to run {}: {source}", - format_command(program, args) - )) - })?; - - if !output.status.success() { - return Ok(None); - } - - let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok((!value.is_empty()).then_some(value)) - } - - fn status_success

(&self, cwd: &Path, program: P, args: &[OsString]) -> Result - where - P: AsRef, - { - let program = program.as_ref(); - Ok(Command::new(program) - .args(args) - .current_dir(cwd) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map_err(|source| { - SiteError(format!( - "failed to run {}: {source}", - format_command(program, args) - )) - })? - .success()) - } - - fn print_command(&self, cwd: &Path, program: &OsStr, args: &[OsString]) { - let relative = cwd.strip_prefix(&self.root).unwrap_or(cwd); - let label = if relative.as_os_str().is_empty() { - ".".to_string() - } else { - relative.display().to_string() - }; - - println!("$ (cd {label}) {}", format_command(program, args)); - } -} - -#[derive(Clone, Copy)] -enum InstallMode { - Locked, - Unlocked, } fn main() { @@ -1081,7 +126,8 @@ fn run_main() -> Result<()> { "recursive-ji-music" | "rji-music" => recursive_ji::generate(&site, &args[1..]), "aoc-problems" | "download-aoc-problems" => aoc::download_problem_text(&site, &args[1..]), "aoc-inputs" | "download-aoc-inputs" => aoc::download_inputs(&site, &args[1..]), - "poison-photos" | "photo-poison" => site.poison_photos(&args[1..]), + "process-photos" => photos::process(&site, &args[1..]), + // "setup-nightshade" => photos::setup_nightshade(&site, &args[1..]), "check" => site.check(), "dates" => { let target = args.get(1).map_or("content", String::as_str); @@ -1145,8 +191,8 @@ Commands: render recursive just-intonation examples aoc-problems [options] download scaffolded AoC problem statements aoc-inputs [options] download scaffolded AoC puzzle inputs - poison-photos [INPUT] [OUTPUT] - protect source photos and update gallery manifest + process-photos [INPUT] [OUTPUT] + classify and publish source photos check run Rust, TypeScript, and lint checks dates [path] refresh markdown date metadata update run dependency updates and linters @@ -1155,452 +201,10 @@ Commands: ); } -fn print_photo_poison_help() { - println!( - "\ -poison-photos - -Usage: - cargo run --manifest-path tools/site/Cargo.toml -- poison-photos [INPUT] [OUTPUT] [options] - -Defaults: - INPUT private/photography/originals - OUTPUT content/photography/gallery - -Options: - --strength N perturbation strength, 1-32, default 4 - --quality N JPEG quality, 60-100, default 92 - --manifest PATH gallery JSON path, default content/photography/gallery.json - --dry-run print planned work without writing images -" - ); -} - -fn find_repo_root() -> Result { - let mut current = env::current_dir()?; - - loop { - if current.join(".git").exists() - && (current.join("quartz.config.ts").exists() - || current.join("quartz.config.yaml").exists() - || current.join("quartz.ts").exists()) - { - return Ok(current); - } - - if !current.pop() { - break; - } - } - - Err(Box::new(SiteError( - "could not find repository root from current directory".to_string(), - ))) -} - -fn remove_dir_if_exists(path: &Path) -> Result<()> { - if path.exists() { - fs::remove_dir_all(path)?; - } - Ok(()) -} - -fn remove_file_if_exists(path: &Path) -> Result<()> { - if path.exists() { - fs::remove_file(path)?; - } - Ok(()) -} - -fn remove_license_files(path: &Path) -> Result<()> { - if !path.is_dir() { - return Ok(()); - } - - for entry in fs::read_dir(path)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().into_owned(); - if name.ends_with("LICENSE.txt") { - fs::remove_file(entry.path())?; - } - } - - Ok(()) -} - -fn normalize_path(path: &Path) -> Result { - if path.exists() { - Ok(path.canonicalize()?) - } else if let Some(parent) = path.parent() { - let parent = normalize_path(parent)?; - Ok(parent.join( - path.file_name() - .ok_or_else(|| SiteError::new(format!("invalid path: {}", path.display())))?, - )) - } else { - Ok(path.to_path_buf()) - } -} - -fn collect_photo_files(dir: &Path, files: &mut Vec) -> Result<()> { - if !dir.exists() { - return Ok(()); - } - - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - collect_photo_files(&path, files)?; - } else if is_photo_file(&path) { - files.push(path); - } - } - - Ok(()) -} - -fn is_photo_file(path: &Path) -> bool { - let Some(extension) = path.extension().and_then(OsStr::to_str) else { - return false; - }; - - matches!( - extension.to_ascii_lowercase().as_str(), - "jpg" | "jpeg" | "png" | "webp" - ) -} - -fn poison_photo_file( - input: &Path, - output: &Path, - seed: u64, - strength: u8, - quality: u8, -) -> Result<(u32, u32)> { - let image = ImageReader::open(input)?.with_guessed_format()?.decode()?; - let source = image.to_rgba8(); - let (width, height) = source.dimensions(); - let poisoned = poison_pixels(&source, seed, strength); - - let file = File::create(output)?; - let writer = BufWriter::new(file); - let mut encoder = JpegEncoder::new_with_quality(writer, quality); - encoder.encode_image(&poisoned)?; - - Ok((width, height)) -} - -fn poison_pixels(source: &image::RgbaImage, seed: u64, strength: u8) -> RgbImage { - let (width, height) = source.dimensions(); - let mut rgb = Vec::with_capacity((width * height) as usize); - let mut luma = Vec::with_capacity((width * height) as usize); - - for pixel in source.pixels() { - let alpha = f32::from(pixel[3]) / 255.0; - let color = [ - f32::from(pixel[0]) * alpha + 255.0 * (1.0 - alpha), - f32::from(pixel[1]) * alpha + 255.0 * (1.0 - alpha), - f32::from(pixel[2]) * alpha + 255.0 * (1.0 - alpha), - ]; - luma.push(color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722); - rgb.push(color); - } - - let mut output = RgbImage::new(width, height); - for y in 0..height { - for x in 0..width { - let index = (y * width + x) as usize; - let edge = local_edge_strength(&luma, width, height, x, y); - let amplitude = f32::from(strength) * (0.45 + edge * 0.75); - let mut color = [0_u8; 3]; - - for channel in 0..3 { - let channel_seed = seed - ^ u64::from(x).wrapping_mul(0x9E37_79B1_85EB_CA87) - ^ u64::from(y).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) - ^ (channel as u64).wrapping_mul(0x1656_67B1_9E37_79F9); - let noise = signed_noise(channel_seed); - let checker = if ((x ^ y ^ channel as u32) & 1) == 0 { - 1.0 - } else { - -1.0 - }; - let wave = ((x as f32 * 0.73 - + y as f32 * 1.37 - + channel as f32 * 2.11 - + (seed & 1023) as f32) - .sin()) - * 0.35; - let delta = amplitude * (noise * 0.65 + checker * 0.18 + wave); - color[channel] = (rgb[index][channel] + delta).clamp(0.0, 255.0).round() as u8; - } - - output.put_pixel(x, y, Rgb(color)); - } - } - - output -} - -fn local_edge_strength(luma: &[f32], width: u32, height: u32, x: u32, y: u32) -> f32 { - let center = luma[(y * width + x) as usize]; - let mut total = 0.0; - let mut count = 0.0; - let neighbors = [ - (x.checked_sub(1), Some(y)), - (x.checked_add(1).filter(|value| *value < width), Some(y)), - (Some(x), y.checked_sub(1)), - (Some(x), y.checked_add(1).filter(|value| *value < height)), - ]; - - for (nx, ny) in neighbors { - if let (Some(nx), Some(ny)) = (nx, ny) { - total += (center - luma[(ny * width + nx) as usize]).abs(); - count += 1.0; - } - } - - if count == 0.0 { - 0.0 - } else { - (total / count / 255.0).clamp(0.0, 1.0) - } -} - -fn signed_noise(seed: u64) -> f32 { - let value = splitmix64(seed); - let unit = (value >> 11) as f64 / ((1_u64 << 53) as f64); - (unit as f32) * 2.0 - 1.0 -} - -fn splitmix64(mut value: u64) -> u64 { - value = value.wrapping_add(0x9E37_79B9_7F4A_7C15); - value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - value ^ (value >> 31) -} - -fn hash64(bytes: &[u8]) -> u64 { - let mut hash = 0xCBF2_9CE4_8422_2325_u64; - for byte in bytes { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01B3); - } - hash -} - -fn sanitize_file_stem(stem: &str) -> String { - let mut result = String::new(); - for ch in stem.chars() { - if ch.is_ascii_alphanumeric() { - result.push(ch.to_ascii_lowercase()); - } else if !result.ends_with('-') { - result.push('-'); - } - } - - let result = result.trim_matches('-'); - if result.is_empty() { - "photo".to_string() - } else { - result.to_string() - } -} - -fn title_from_stem(stem: &str) -> String { - let title = stem - .replace(['_', '-'], " ") - .split_whitespace() - .collect::>() - .join(" "); - - if title.is_empty() { - "untitled photo".to_string() - } else { - title - } -} - -fn write_gallery_manifest(path: &Path, entries: &[GalleryEntry]) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let mut body = String::from("[\n"); - for (index, entry) in entries.iter().enumerate() { - let comma = if index + 1 == entries.len() { "" } else { "," }; - body.push_str(" {\n"); - body.push_str(&format!(" \"src\": {},\n", json_string(&entry.src))); - body.push_str(&format!(" \"title\": {},\n", json_string(&entry.title))); - body.push_str(&format!(" \"meta\": {},\n", json_string(&entry.meta))); - body.push_str(&format!(" \"width\": {},\n", entry.width)); - body.push_str(&format!(" \"height\": {}\n", entry.height)); - body.push_str(&format!(" }}{comma}\n")); - } - body.push_str("]\n"); - - fs::write(path, body)?; - Ok(()) -} - -fn json_string(value: &str) -> String { - let mut result = String::from("\""); - for ch in value.chars() { - match ch { - '"' => result.push_str("\\\""), - '\\' => result.push_str("\\\\"), - '\n' => result.push_str("\\n"), - '\r' => result.push_str("\\r"), - '\t' => result.push_str("\\t"), - ch if ch.is_control() => result.push_str(&format!("\\u{:04x}", ch as u32)), - ch => result.push(ch), - } - } - result.push('"'); - result -} - -fn existing_date_metadata(path: &Path) -> Result> { - if !path.exists() { - return Ok(Vec::new()); - } - - let source = fs::read_to_string(path)?; - Ok(source - .lines() - .filter(|line| line.starts_with("date:") || line.starts_with("updated:")) - .map(str::to_string) - .collect()) -} - -fn collect_markdown_files(dir: &Path, files: &mut Vec) -> Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - collect_markdown_files(&path, files)?; - } else if path - .extension() - .is_some_and(|extension| extension == OsStr::new("md")) - { - files.push(path); - } - } - - Ok(()) -} - -fn extract_urls(source: &str) -> Vec { - let mut links = Vec::new(); - let mut offset = 0; - - while let Some(start) = find_next_url(&source[offset..]) { - let absolute_start = offset + start; - let mut absolute_end = absolute_start; - - for (index, ch) in source[absolute_start..].char_indices() { - if is_url_delimiter(ch) { - break; - } - absolute_end = absolute_start + index + ch.len_utf8(); - } - - if absolute_end > absolute_start { - let link = source[absolute_start..absolute_end] - .trim_end_matches(['.', ',', ';']) - .to_string(); - links.push(link); - } - - offset = absolute_end.max(absolute_start + 1); - } - - links -} - -fn find_next_url(source: &str) -> Option { - match (source.find("http://"), source.find("https://")) { - (Some(http), Some(https)) => Some(http.min(https)), - (Some(http), None) => Some(http), - (None, Some(https)) => Some(https), - (None, None) => None, - } -} - -fn is_url_delimiter(ch: char) -> bool { - ch.is_whitespace() || matches!(ch, '<' | '>' | '"' | '\'' | '`' | '[' | ']' | '(' | ')') -} - -fn os_args(args: &[&str]) -> Vec { - args.iter().map(OsString::from).collect() -} - -fn command_succeeds

(program: P, args: &[&str]) -> bool -where - P: AsRef, -{ - Command::new(program) - .args(args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) -} - -fn bun_program() -> Option { - if command_succeeds("bun", &["--version"]) { - return Some("bun".into()); - } - - if cfg!(windows) { - let path = env::var_os("USERPROFILE") - .map(PathBuf::from) - .map(|home| home.join(".bun/bin/bun.exe")); - - if let Some(path) = path { - if path.is_file() && command_succeeds(path.as_os_str(), &["--version"]) { - return Some(path.into_os_string()); - } - } - } - - None -} - -fn format_command(program: &OsStr, args: &[OsString]) -> String { - let mut parts = vec![format_os(program)]; - parts.extend(args.iter().map(|arg| format_os(arg.as_os_str()))); - parts.join(" ") -} - -fn format_os(value: &OsStr) -> String { - let value = value.to_string_lossy(); - if value.chars().any(char::is_whitespace) { - format!("{value:?}") - } else { - value.into_owned() - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn extracts_markdown_urls_without_trailing_punctuation() { - let links = - extract_urls(r#"see [one](https://example.com/a?b=1), "http://example.org/x"."#); - - assert_eq!( - links, - vec![ - "https://example.com/a?b=1".to_string(), - "http://example.org/x".to_string() - ] - ); - } - #[test] fn parses_build_modes() { assert_eq!( diff --git a/tools/site/src/maintenance.rs b/tools/site/src/maintenance.rs new file mode 100644 index 00000000..4f33bb56 --- /dev/null +++ b/tools/site/src/maintenance.rs @@ -0,0 +1,173 @@ +use crate::{os_args, InstallMode, Result, Site, SiteError}; +use std::env; +use std::path::Path; + +impl Site { + pub(crate) fn update(&self) -> Result<()> { + self.run( + &self.root, + "git", + &os_args(&[ + "submodule", + "update", + "--remote", + "--recursive", + "--", + "wasm/tuningplayground/music21", + ]), + )?; + + self.run_with_env( + &self.root.join("wasm/wasm"), + "wasm-pack", + &os_args(&[ + "build", + "--target", + "bundler", + if self.ci { "--release" } else { "--dev" }, + ]), + &[("RUSTFLAGS", r#"--cfg getrandom_backend="wasm_js""#)], + )?; + + self.run_bun(&self.root, &os_args(&["update"]))?; + self.run_bun(&self.root, &os_args(&["audit"]))?; + self.bun_install(&self.root, InstallMode::Unlocked)?; + + self.node_update(&self.root.join("ts"), "src")?; + + for crate_dir in [ + "wasm/tuningplayground", + "wasm/tuningplayground/tuning_systems", + "wasm/tuningplayground/keymapping", + "wasm/textprocessing", + "wasm/textprocessing/hangeul_conversion", + "wasm/glsl2hlsl", + "wasm/adventofcode", + "wasm/wasm", + ] { + self.cargo_update(&self.root.join(crate_dir))?; + } + + Ok(()) + } + + fn node_update(&self, dir: &Path, lint_target: &str) -> Result<()> { + self.run_bun(dir, &os_args(&["update"]))?; + self.run_bun(dir, &os_args(&["audit"]))?; + self.bun_install(dir, InstallMode::Unlocked)?; + self.run_bun(dir, &os_args(&["run", "prettier", lint_target, "--write"]))?; + self.run_bun(dir, &os_args(&["run", "eslint", lint_target, "--fix"])) + } + + fn cargo_update(&self, dir: &Path) -> Result<()> { + self.run(dir, "cargo", &os_args(&["upgrade"]))?; + self.run(dir, "cargo", &os_args(&["update", "--workspace"]))?; + self.run( + dir, + "cargo", + &os_args(&[ + "hack", + "clippy", + "--feature-powerset", + "--fix", + "--allow-dirty", + "--allow-staged", + "--all-targets", + "--workspace", + "--", + "-D", + "warnings", + ]), + )?; + self.run( + dir, + "cargo", + &os_args(&[ + "hack", + "fix", + "--feature-powerset", + "--allow-dirty", + "--allow-staged", + "--all-targets", + "--workspace", + ]), + )?; + self.run( + dir, + "cargo", + &os_args(&[ + "hack", + "check", + "--feature-powerset", + "--all-targets", + "--workspace", + ]), + )?; + self.run( + dir, + "cargo", + &os_args(&[ + "hack", + "test", + "--feature-powerset", + "--release", + "--verbose", + "--all-targets", + "--workspace", + "--no-fail-fast", + "--lib", + "--bins", + "--examples", + "--tests", + "--benches", + ]), + )?; + self.run(dir, "cargo", &os_args(&["fmt", "--all"])) + } + + pub(crate) fn commit(&self, message: Option) -> Result<()> { + if !self.ci { + return Err(Box::new(SiteError::new( + "commit is CI-only; review and commit local changes with git".to_string(), + ))); + } + + let message = message + .or_else(|| env::var("GITHUB_JOB").ok()) + .unwrap_or_else(|| "update generated files".to_string()); + + self.run( + &self.root, + "git", + &os_args(&[ + "config", + "user.email", + "github-actions[bot]@users.noreply.github.com", + ]), + )?; + self.run( + &self.root, + "git", + &os_args(&["config", "user.name", "github-actions[bot]"]), + )?; + self.run(&self.root, "git", &os_args(&["add", "-A"]))?; + + let has_changes = !self.status_success( + &self.root, + "git", + &os_args(&["diff", "--cached", "--quiet"]), + )?; + + if !has_changes { + println!("No changes to commit"); + return Ok(()); + } + + self.run( + &self.root, + "git", + &os_args(&["commit", "-m", message.as_str()]), + )?; + self.run(&self.root, "git", &os_args(&["push"])) + } +} diff --git a/tools/site/src/photos.rs b/tools/site/src/photos.rs new file mode 100644 index 00000000..488053fa --- /dev/null +++ b/tools/site/src/photos.rs @@ -0,0 +1,1294 @@ +use crate::{Result, Site, SiteError}; +use image::codecs::jpeg::JpegEncoder; +use image::{ImageReader, Rgb, RgbImage, RgbaImage}; +use serde::Serialize; +use std::collections::HashMap; +use std::error::Error; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::fs::File; +use std::io::BufWriter; +use std::path::{Path, PathBuf}; + +struct ProcessPhotosOptions { + input: PathBuf, + output: PathBuf, + manifest: PathBuf, + describer: PhotoDescriberOptions, + // nightshade_input: PathBuf, + // nightshade_output: PathBuf, + quality: u8, + dry_run: bool, +} + +// struct SetupNightshadeOptions { +// install_dir: PathBuf, +// download_dir: PathBuf, +// archive: Option, +// url: Option, +// force: bool, +// print_url: bool, +// dry_run: bool, +// } + +#[derive(Clone, Debug)] +struct PhotoDescriberOptions { + ollama_model: String, + prompt: String, +} + +#[derive(Serialize)] +struct GalleryEntry { + src: String, + title: String, + #[serde(skip_serializing_if = "String::is_empty")] + description: String, + meta: String, + tags: Vec, + width: u32, + height: u32, +} + +struct PhotoClassification { + title: String, + description: String, + tags: Vec, +} + +// #[derive(Serialize)] +// struct NightshadeStagingEntry { +// source: String, +// file: String, +// tag: String, +// title: String, +// } + +// #[derive(Clone, Copy, Debug, PartialEq, Eq)] +// enum NightshadeArchiveKind { +// Zip, +// Dmg, +// } + +// struct NightshadePackage { +// version: &'static str, +// urls: &'static [&'static str], +// file_name: &'static str, +// archive_kind: NightshadeArchiveKind, +// } + +struct OllamaPhotoDescriber { + model: String, + prompt: String, +} + +const MAX_GENERATED_PHOTO_TAGS: usize = 8; +const DEFAULT_OLLAMA_PHOTO_MODEL: &str = "gemma3"; +const DEFAULT_PHOTO_DESCRIPTION_PROMPT: &str = "\ +Describe this image for a personal photography gallery. +Return only these three labeled fields: +Title: 3 to 7 words +Description: one concise sentence about the visible scene +Tags: 3 to 8 lowercase tags, separated by commas +Do not include markdown, filenames, camera settings, or guesses about private identity."; +// const NIGHTSHADE_VERSION: &str = "1.1"; +// const NIGHTSHADE_DOWNLOADS_PAGE: &str = "https://nightshade.cs.uchicago.edu/downloads.html"; +// const NIGHTSHADE_WINDOWS_URLS: &[&str] = &[ +// "https://webvault.cs.uchicago.edu/sandlab/fawkes/files/nightshade/Nightshade-1.1-Windows.zip", +// "https://mirror.cs.uchicago.edu/fawkes/files/nightshade/Nightshade-1.1-Windows.zip", +// ]; +// const NIGHTSHADE_MACOS_APPLE_SILICON_URLS: &[&str] = &[ +// "https://webvault.cs.uchicago.edu/sandlab/fawkes/files/nightshade/Nightshade-1.1-m1.dmg", +// "https://mirror.cs.uchicago.edu/fawkes/files/nightshade/Nightshade-1.1-m1.dmg", +// ]; + +pub(crate) fn process(site: &Site, args: &[String]) -> Result<()> { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + print_process_help(); + return Ok(()); + } + + let options = parse_process_options(site, args)?; + site.ensure_photo_input_is_private(&options.input)?; + fs::create_dir_all(&options.input)?; + fs::create_dir_all(&options.output)?; + + let mut photos = Vec::new(); + collect_photo_files(&options.input, &mut photos)?; + photos.sort(); + + if photos.is_empty() { + site.warn(&format!( + "no source photos found in {}; drop originals there and rerun process-photos", + options.input.display() + )); + return Ok(()); + } + + let describer = if options.dry_run { + None + } else { + Some(OllamaPhotoDescriber::new( + site, + &options.describer.ollama_model, + &options.describer.prompt, + )?) + }; + let mut entries = Vec::new(); + let mut names = HashMap::::new(); + // let mut staging_entries = Vec::new(); + // let mut missing_nightshade_outputs = Vec::new(); + + // if !options.dry_run { + // fs::create_dir_all(&options.nightshade_input)?; + // fs::create_dir_all(&options.nightshade_output)?; + // } + + for photo in photos { + let relative = photo.strip_prefix(&options.input).unwrap_or(&photo); + let stem = photo + .file_stem() + .and_then(OsStr::to_str) + .map_or("photo", |value| value); + let base = sanitize_file_stem(stem); + let count = names.entry(base.clone()).or_insert(0); + *count += 1; + let file_name = if *count == 1 { + format!("{base}.jpg") + } else { + format!("{base}-{count}.jpg") + }; + let target = options.output.join(&file_name); + // let staged_file_name = staged_photo_file_name(&base, *count, &photo); + // let staged_photo = options.nightshade_input.join(&staged_file_name); + // let nightshade_photo = + // find_nightshade_output(&options.nightshade_output, &staged_file_name); + + println!( + "process {} -> {}", + relative.display(), + target.strip_prefix(&site.root).unwrap_or(&target).display() + ); + + let (width, height, classification) = if options.dry_run { + (0, 0, PhotoClassification::unclassified()) + } else { + process_photo_file( + site, + &photo, + &target, + options.quality, + describer + .as_ref() + .ok_or_else(|| SiteError::new("photo describer was not initialized"))?, + )? + }; + // let nightshade_tag = nightshade_tag(&classification.tags); + + // if !options.dry_run { + // stage_nightshade_input(&photo, &staged_photo)?; + // staging_entries.push(NightshadeStagingEntry { + // source: relative.to_string_lossy().replace('\\', "/"), + // file: staged_file_name.clone(), + // tag: nightshade_tag.clone(), + // title: classification.title.clone(), + // }); + + // if nightshade_photo.is_none() { + // missing_nightshade_outputs.push(staged_file_name); + // } + // } + + println!( + " title: {}; tags: {}", + classification.title, + if classification.tags.is_empty() { + "unclassified".to_string() + } else { + classification.tags.join(", ") + } + ); + + entries.push(GalleryEntry { + src: format!("/photography/gallery/{file_name}"), + title: classification.title, + description: classification.description, + meta: relative.to_string_lossy().replace('\\', "/"), + tags: classification.tags, + width, + height, + }); + } + + // if !options.dry_run { + // write_nightshade_staging_manifest(&options.nightshade_input, &staging_entries)?; + // } + + // if !missing_nightshade_outputs.is_empty() { + // return Err(Box::new(SiteError::new(format!( + // "staged {} photo(s) for Nightshade in {}; run Nightshade on that folder with the tags in nightshade-tags.csv, write results to {}, then rerun process-photos", + // missing_nightshade_outputs.len(), + // options.nightshade_input.display(), + // options.nightshade_output.display() + // )))); + // } + + if !options.dry_run { + write_gallery_manifest(&options.manifest, &entries)?; + } + + Ok(()) +} + +// pub(crate) fn setup_nightshade(site: &Site, args: &[String]) -> Result<()> { +// if args.iter().any(|arg| arg == "--help" || arg == "-h") { +// print_setup_help(); +// return Ok(()); +// } + +// let options = parse_setup_options(site, args)?; +// let package = nightshade_package()?; +// let download_urls = setup_download_urls(&options, &package); + +// if options.print_url { +// for url in &download_urls { +// println!("{url}"); +// } +// return Ok(()); +// } + +// let archive = options +// .archive +// .clone() +// .unwrap_or_else(|| options.download_dir.join(package.file_name)); +// println!("Nightshade {}", package.version); +// if options.archive.is_some() { +// println!("archive source: local file"); +// } else { +// for (index, url) in download_urls.iter().enumerate() { +// if index == 0 { +// println!("download: {url}"); +// } else { +// println!("fallback download: {url}"); +// } +// } +// } +// println!("archive: {}", archive.display()); + +// if options.dry_run { +// if options.archive.is_some() { +// println!("dry run: would unpack Nightshade into private/tools/nightshade"); +// } else { +// println!( +// "dry run: would download Nightshade and unpack it into private/tools/nightshade" +// ); +// } +// return Ok(()); +// } + +// fs::create_dir_all(&options.download_dir)?; +// fs::create_dir_all(&options.install_dir)?; + +// if options.archive.is_some() { +// if !archive.is_file() { +// return Err(Box::new(SiteError::new(format!( +// "Nightshade archive does not exist: {}", +// archive.display() +// )))); +// } +// println!("using existing {}", archive.display()); +// } else if archive.is_file() && !options.force { +// println!("reusing existing {}", archive.display()); +// } else { +// download_nightshade_archive(site, &download_urls, &archive)?; +// } + +// match package.archive_kind { +// NightshadeArchiveKind::Zip => { +// let app_dir = options +// .install_dir +// .join(format!("Nightshade-{}", package.version)); +// unpack_zip_archive(&archive, &app_dir)?; +// if let Some(executable) = find_nightshade_executable(&app_dir)? { +// println!("Nightshade executable: {}", executable.display()); +// } else { +// println!("Nightshade unpacked to {}", app_dir.display()); +// } +// } +// NightshadeArchiveKind::Dmg => { +// println!( +// "Nightshade DMG downloaded to {}; mount it and drag the app into {}", +// archive.display(), +// options.install_dir.display() +// ); +// } +// } + +// Ok(()) +// } + +fn parse_process_options(site: &Site, args: &[String]) -> Result { + let mut positionals = Vec::new(); + let mut quality = 92_u8; + let mut manifest = site.root.join("content/photography/gallery.json"); + let mut ollama_model = DEFAULT_OLLAMA_PHOTO_MODEL.to_string(); + let mut description_prompt = DEFAULT_PHOTO_DESCRIPTION_PROMPT.to_string(); + // let mut nightshade_input = site.root.join("private/photography/nightshade/input"); + // let mut nightshade_output = site.root.join("private/photography/nightshade/output"); + let mut dry_run = false; + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--quality" => { + index += 1; + let value = args.get(index).ok_or_else(|| { + Box::new(SiteError::new("--quality requires a value")) as Box + })?; + quality = value.parse::().map_err(|source| { + SiteError::new(format!("invalid quality {value:?}: {source}")) + })?; + } + "--manifest" => { + index += 1; + let value = args.get(index).ok_or_else(|| { + Box::new(SiteError::new("--manifest requires a path")) as Box + })?; + manifest = site.resolve_path(value); + } + "--describer" => { + index += 1; + let value = args.get(index).ok_or_else(|| { + Box::new(SiteError::new("--describer requires a value")) as Box + })?; + validate_photo_describer(value)?; + } + "--ollama-model" => { + index += 1; + let value = args.get(index).ok_or_else(|| { + Box::new(SiteError::new("--ollama-model requires a model name")) + as Box + })?; + ollama_model = value.to_string(); + } + "--description-prompt" => { + index += 1; + let value = args.get(index).ok_or_else(|| { + Box::new(SiteError::new("--description-prompt requires text")) as Box + })?; + description_prompt = value.to_string(); + } + // "--nightshade-input" => { + // index += 1; + // let value = args.get(index).ok_or_else(|| { + // Box::new(SiteError::new("--nightshade-input requires a path")) as Box + // })?; + // nightshade_input = site.resolve_path(value); + // } + // "--nightshade-output" => { + // index += 1; + // let value = args.get(index).ok_or_else(|| { + // Box::new(SiteError::new("--nightshade-output requires a path")) + // as Box + // })?; + // nightshade_output = site.resolve_path(value); + // } + "--dry-run" => dry_run = true, + "--help" | "-h" => { + print_process_help(); + return Err(Box::new(SiteError::new("help requested"))); + } + value if value.starts_with('-') => { + return Err(Box::new(SiteError::new(format!( + "unknown process-photos option: {value}" + )))); + } + value => positionals.push(value.to_string()), + } + index += 1; + } + + if !(60..=100).contains(&quality) { + return Err(Box::new(SiteError::new( + "quality must be between 60 and 100", + ))); + } + if positionals.len() > 2 { + return Err(Box::new(SiteError::new( + "process-photos accepts at most INPUT and OUTPUT paths", + ))); + } + + Ok(ProcessPhotosOptions { + input: positionals.first().map_or_else( + || site.root.join("private/photography/originals"), + |path| site.resolve_path(path), + ), + output: positionals.get(1).map_or_else( + || site.root.join("content/photography/gallery"), + |path| site.resolve_path(path), + ), + manifest, + describer: PhotoDescriberOptions { + ollama_model, + prompt: description_prompt, + }, + // nightshade_input, + // nightshade_output, + quality, + dry_run, + }) +} + +// fn parse_setup_options(site: &Site, args: &[String]) -> Result { +// let mut install_dir = site.root.join("private/tools/nightshade"); +// let mut download_dir = None; +// let mut archive = None; +// let mut url = None; +// let mut force = false; +// let mut print_url = false; +// let mut dry_run = false; +// let mut index = 0; + +// while index < args.len() { +// match args[index].as_str() { +// "--install-dir" => { +// index += 1; +// let value = args.get(index).ok_or_else(|| { +// Box::new(SiteError::new("--install-dir requires a path")) as Box +// })?; +// install_dir = site.resolve_path(value); +// } +// "--download-dir" => { +// index += 1; +// let value = args.get(index).ok_or_else(|| { +// Box::new(SiteError::new("--download-dir requires a path")) as Box +// })?; +// download_dir = Some(site.resolve_path(value)); +// } +// "--archive" => { +// index += 1; +// let value = args.get(index).ok_or_else(|| { +// Box::new(SiteError::new("--archive requires a path")) as Box +// })?; +// archive = Some(site.resolve_path(value)); +// } +// "--url" => { +// index += 1; +// let value = args.get(index).ok_or_else(|| { +// Box::new(SiteError::new("--url requires a URL")) as Box +// })?; +// url = Some(value.to_string()); +// } +// "--force" => force = true, +// "--print-url" => print_url = true, +// "--dry-run" => dry_run = true, +// "--help" | "-h" => { +// print_setup_help(); +// return Err(Box::new(SiteError::new("help requested"))); +// } +// value => { +// return Err(Box::new(SiteError::new(format!( +// "unknown setup-nightshade option: {value}" +// )))); +// } +// } +// index += 1; +// } + +// let download_dir = download_dir.unwrap_or_else(|| install_dir.join("downloads")); + +// Ok(SetupNightshadeOptions { +// install_dir, +// download_dir, +// archive, +// url, +// force, +// print_url, +// dry_run, +// }) +// } + +// fn setup_download_urls( +// options: &SetupNightshadeOptions, +// package: &NightshadePackage, +// ) -> Vec { +// if let Some(url) = &options.url { +// return vec![url.to_string()]; +// } + +// package.urls.iter().map(|url| url.to_string()).collect() +// } + +// fn download_nightshade_archive(site: &Site, urls: &[String], output: &Path) -> Result<()> { +// let mut errors = Vec::new(); + +// for url in urls { +// match download_file(site, url, output) { +// Ok(()) => return Ok(()), +// Err(error) => { +// errors.push(format!("{url}: {error}")); +// site.warn(&format!("Nightshade download failed from {url}")); +// } +// } +// } + +// Err(Box::new(SiteError::new(format!( +// "failed to download Nightshade archive from pinned URL(s): {}\nDownload it manually from {NIGHTSHADE_DOWNLOADS_PAGE}, then rerun setup-nightshade with --archive PATH.", +// errors.join("; ") +// )))) +// } + +// fn unpack_zip_archive(archive: &Path, destination: &Path) -> Result<()> { +// fs::create_dir_all(destination)?; + +// println!( +// "extracting {} to {}", +// archive.display(), +// destination.display() +// ); +// let archive_file = File::open(archive)?; +// let mut archive = zip::ZipArchive::new(archive_file)?; +// archive.extract(destination)?; + +// Ok(()) +// } + +// fn print_setup_help() { +// println!( +// "\ +// setup-nightshade + +// Usage: +// cargo run --manifest-path tools/site/Cargo.toml -- setup-nightshade [options] + +// Options: +// --install-dir PATH default private/tools/nightshade +// --download-dir PATH default INSTALL_DIR/downloads +// --archive PATH unpack an already downloaded Nightshade archive +// --url URL override the pinned download URL +// --force redownload the archive if it already exists +// --print-url print the pinned official download URL(s) and exit +// --dry-run print planned work without downloading + +// Nightshade is not part of the normal build and is ignored by git. +// " +// ); +// } + +fn print_process_help() { + println!( + "\ +process-photos + +Usage: + cargo run --manifest-path tools/site/Cargo.toml -- process-photos [INPUT] [OUTPUT] [options] + +Defaults: + INPUT private/photography/originals + OUTPUT content/photography/gallery + +Options: + --quality N JPEG quality, 60-100, default 92 + --manifest PATH gallery JSON path, default content/photography/gallery.json + --describer NAME ollama, default ollama + --ollama-model M local Ollama vision model, default gemma3 + --description-prompt TEXT + prompt for Ollama labeled descriptions + --dry-run print planned work without writing images + +Model: + Install Ollama, pull a local vision model, then run process-photos. + The --describer option is kept for explicitness and currently accepts ollama. +" + ); +} + +fn collect_photo_files(dir: &Path, files: &mut Vec) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_photo_files(&path, files)?; + } else if is_photo_file(&path) { + files.push(path); + } + } + + Ok(()) +} + +fn is_photo_file(path: &Path) -> bool { + let Some(extension) = path.extension().and_then(OsStr::to_str) else { + return false; + }; + + matches!( + extension.to_ascii_lowercase().as_str(), + "jpg" | "jpeg" | "png" | "webp" + ) +} + +// fn nightshade_package() -> Result { +// if cfg!(target_os = "windows") { +// return Ok(NightshadePackage { +// version: NIGHTSHADE_VERSION, +// urls: NIGHTSHADE_WINDOWS_URLS, +// file_name: "Nightshade-1.1-Windows.zip", +// archive_kind: NightshadeArchiveKind::Zip, +// }); +// } + +// if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") { +// return Ok(NightshadePackage { +// version: NIGHTSHADE_VERSION, +// urls: NIGHTSHADE_MACOS_APPLE_SILICON_URLS, +// file_name: "Nightshade-1.1-m1.dmg", +// archive_kind: NightshadeArchiveKind::Dmg, +// }); +// } + +// Err(Box::new(SiteError::new( +// "Nightshade setup is only pinned for Windows and Apple Silicon macOS", +// ))) +// } + +// fn find_nightshade_executable(dir: &Path) -> Result> { +// if !dir.is_dir() { +// return Ok(None); +// } + +// let mut pending = vec![dir.to_path_buf()]; +// while let Some(current) = pending.pop() { +// for entry in fs::read_dir(current)? { +// let path = entry?.path(); +// if is_nightshade_executable(&path) { +// return Ok(Some(path)); +// } +// if path.is_dir() { +// pending.push(path); +// } +// } +// } + +// Ok(None) +// } + +// fn is_nightshade_executable(path: &Path) -> bool { +// let Some(stem) = path.file_stem().and_then(OsStr::to_str) else { +// return false; +// }; +// if !stem.to_ascii_lowercase().contains("nightshade") { +// return false; +// } + +// if cfg!(target_os = "windows") { +// path.extension() +// .and_then(OsStr::to_str) +// .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) +// } else if cfg!(target_os = "macos") { +// path.extension() +// .and_then(OsStr::to_str) +// .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) +// } else { +// false +// } +// } + +impl PhotoClassification { + fn unclassified() -> Self { + Self { + title: "Unclassified photo".to_string(), + description: String::new(), + tags: Vec::new(), + } + } +} + +impl OllamaPhotoDescriber { + fn new(site: &Site, model: &str, prompt: &str) -> Result { + let model = model.trim(); + if model.is_empty() { + return Err(Box::new(SiteError::new( + "--ollama-model must not be empty when --describer ollama is used", + ))); + } + + let show_args = vec![OsString::from("show"), OsString::from(model)]; + if !site.status_success(&site.root, "ollama", &show_args)? { + return Err(Box::new(SiteError::new(format!( + "Ollama model {model:?} is not available locally; run `ollama pull {model}` first" + )))); + } + + println!("photo describer: ollama ({model})"); + + Ok(Self { + model: model.to_string(), + prompt: prompt.to_string(), + }) + } + + fn describe(&self, site: &Site, image: &Path) -> Result { + let args = vec![ + OsString::from("run"), + OsString::from(&self.model), + image.as_os_str().to_os_string(), + OsString::from(&self.prompt), + ]; + let output = site + .output_optional(&site.root, "ollama", &args)? + .ok_or_else(|| { + SiteError::new(format!( + "Ollama failed to describe {}; check that {} is a local vision model", + image.display(), + self.model + )) + })?; + + parse_generated_photo_description(&output) + } +} + +fn validate_photo_describer(value: &str) -> Result<()> { + match value.to_ascii_lowercase().as_str() { + "ollama" => Ok(()), + _ => Err(Box::new(SiteError::new(format!( + "unknown photo describer {value:?}; only ollama is supported" + )))), + } +} + +fn process_photo_file( + site: &Site, + input: &Path, + output: &Path, + quality: u8, + describer: &OllamaPhotoDescriber, +) -> Result<(u32, u32, PhotoClassification)> { + let image = ImageReader::open(input)?.with_guessed_format()?.decode()?; + let source = image.to_rgba8(); + let classification = describer.describe(site, input)?; + let (width, height) = publish_photo(&source, output, quality)?; + + Ok((width, height, classification)) +} + +fn publish_photo(source: &RgbaImage, output: &Path, quality: u8) -> Result<(u32, u32)> { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + + let (width, height) = source.dimensions(); + let rgb = rgba_to_rgb_on_white(source); + let file = File::create(output)?; + let writer = BufWriter::new(file); + let mut encoder = JpegEncoder::new_with_quality(writer, quality); + encoder.encode_image(&rgb)?; + + Ok((width, height)) +} + +fn rgba_to_rgb_on_white(source: &RgbaImage) -> RgbImage { + let (width, height) = source.dimensions(); + let mut output = RgbImage::new(width, height); + + for (x, y, pixel) in source.enumerate_pixels() { + let alpha = f32::from(pixel[3]) / 255.0; + let red = f32::from(pixel[0]) * alpha + 255.0 * (1.0 - alpha); + let green = f32::from(pixel[1]) * alpha + 255.0 * (1.0 - alpha); + let blue = f32::from(pixel[2]) * alpha + 255.0 * (1.0 - alpha); + output.put_pixel( + x, + y, + Rgb([ + red.clamp(0.0, 255.0).round() as u8, + green.clamp(0.0, 255.0).round() as u8, + blue.clamp(0.0, 255.0).round() as u8, + ]), + ); + } + + output +} + +fn sanitize_file_stem(stem: &str) -> String { + let mut result = String::new(); + for ch in stem.chars() { + if ch.is_ascii_alphanumeric() { + result.push(ch.to_ascii_lowercase()); + } else if !result.ends_with('-') { + result.push('-'); + } + } + + let result = result.trim_matches('-'); + if result.is_empty() { + "photo".to_string() + } else { + result.to_string() + } +} + +// fn staged_photo_file_name(base: &str, count: usize, source: &Path) -> String { +// let extension = source +// .extension() +// .and_then(OsStr::to_str) +// .map(|extension| extension.to_ascii_lowercase()) +// .filter(|extension| is_safe_extension(extension)) +// .unwrap_or_else(|| "jpg".to_string()); + +// if count == 1 { +// format!("{base}.{extension}") +// } else { +// format!("{base}-{count}.{extension}") +// } +// } + +// fn is_safe_extension(extension: &str) -> bool { +// extension +// .chars() +// .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') +// } + +// fn find_nightshade_output(output_dir: &Path, staged_file_name: &str) -> Option { +// let direct = output_dir.join(staged_file_name); +// if direct.is_file() { +// return Some(direct); +// } + +// let stem = Path::new(staged_file_name).file_stem()?.to_string_lossy(); +// for extension in ["jpg", "jpeg", "png", "webp"] { +// let candidate = output_dir.join(format!("{stem}.{extension}")); +// if candidate.is_file() { +// return Some(candidate); +// } +// } + +// None +// } + +// fn stage_nightshade_input(source: &Path, target: &Path) -> Result<()> { +// if let Some(parent) = target.parent() { +// fs::create_dir_all(parent)?; +// } +// fs::copy(source, target)?; +// Ok(()) +// } + +// fn nightshade_tag(tags: &[String]) -> String { +// tags.first() +// .filter(|tag| !tag.trim().is_empty()) +// .cloned() +// .unwrap_or_else(|| "photo".to_string()) +// } + +// fn write_nightshade_staging_manifest( +// input_dir: &Path, +// entries: &[NightshadeStagingEntry], +// ) -> Result<()> { +// fs::create_dir_all(input_dir)?; + +// let mut json = serde_json::to_string_pretty(entries)?; +// json.push('\n'); +// fs::write(input_dir.join("nightshade-tags.json"), json)?; + +// let mut csv = String::from("file,tag,title,source\n"); +// for entry in entries { +// csv.push_str(&csv_field(&entry.file)); +// csv.push(','); +// csv.push_str(&csv_field(&entry.tag)); +// csv.push(','); +// csv.push_str(&csv_field(&entry.title)); +// csv.push(','); +// csv.push_str(&csv_field(&entry.source)); +// csv.push('\n'); +// } +// fs::write(input_dir.join("nightshade-tags.csv"), csv)?; + +// Ok(()) +// } + +// fn csv_field(value: &str) -> String { +// if value +// .chars() +// .any(|ch| matches!(ch, '"' | ',' | '\n' | '\r')) +// { +// format!("\"{}\"", value.replace('"', "\"\"")) +// } else { +// value.to_string() +// } +// } + +fn parse_generated_photo_description(output: &str) -> Result { + let (raw_title, raw_description, raw_tags) = parse_labeled_photo_description(output) + .ok_or_else(|| { + SiteError::new(format!( + "photo describer did not return Title, Description, and Tags fields: {}", + output.trim() + )) + })?; + let description = clean_generated_text(&raw_description).unwrap_or_default(); + let title = clean_generated_text(&raw_title) + .or_else(|| title_from_description(&description)) + .unwrap_or_else(|| "Untitled photo".to_string()); + let tags = raw_tags + .split(',') + .filter_map(|tag| clean_generated_tag(tag)) + .take(MAX_GENERATED_PHOTO_TAGS) + .collect::>(); + + Ok(PhotoClassification { + title, + description, + tags, + }) +} + +fn parse_labeled_photo_description(output: &str) -> Option<(String, String, String)> { + let mut title = String::new(); + let mut description = String::new(); + let mut tags = String::new(); + let mut current = None; + + for line in strip_code_fence(output).lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some((field, value)) = parse_labeled_line(line) { + current = Some(field); + append_generated_field( + match field { + Field::Title => &mut title, + Field::Description => &mut description, + Field::Tags => &mut tags, + }, + value, + ); + continue; + } + + if let Some(field) = current { + append_generated_field( + match field { + Field::Title => &mut title, + Field::Description => &mut description, + Field::Tags => &mut tags, + }, + line, + ); + } + } + + (!title.trim().is_empty() || !description.trim().is_empty() || !tags.trim().is_empty()) + .then_some((title, description, tags)) +} + +fn parse_labeled_line(line: &str) -> Option<(Field, &str)> { + let line = line + .trim_start_matches(|ch: char| matches!(ch, '-' | '*')) + .trim(); + let (label, value) = line.split_once(':')?; + let field = match label.trim().to_ascii_lowercase().as_str() { + "title" => Field::Title, + "description" | "caption" => Field::Description, + "tags" => Field::Tags, + _ => return None, + }; + + Some((field, value.trim())) +} + +#[derive(Clone, Copy)] +enum Field { + Title, + Description, + Tags, +} + +fn append_generated_field(target: &mut String, value: &str) { + if value.trim().is_empty() { + return; + } + if !target.is_empty() { + target.push(' '); + } + target.push_str(value.trim()); +} + +fn strip_code_fence(output: &str) -> &str { + let trimmed = output.trim(); + let without_start = trimmed + .strip_prefix("```text") + .or_else(|| trimmed.strip_prefix("```")) + .unwrap_or(trimmed) + .trim(); + + without_start + .strip_suffix("```") + .unwrap_or(without_start) + .trim() +} + +fn clean_generated_text(value: &str) -> Option { + let cleaned = value + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|ch: char| matches!(ch, '"' | '\'' | '`')) + .trim() + .to_string(); + + (!cleaned.is_empty()).then_some(cleaned) +} + +fn clean_generated_tag(value: &str) -> Option { + let tag = value + .trim() + .trim_matches(|ch: char| matches!(ch, '"' | '\'' | '`' | '.' | ',' | ';' | ':')) + .to_ascii_lowercase() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ' ' | '-' | '_')) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + + (!tag.is_empty()).then_some(tag) +} + +fn title_from_description(description: &str) -> Option { + let words = description + .split_whitespace() + .take(7) + .map(|word| { + word.trim_matches(|ch: char| !ch.is_alphanumeric()) + .to_string() + }) + .filter(|word| !word.is_empty()) + .collect::>(); + + (!words.is_empty()).then(|| titleize_tag(&words.join(" "))) +} + +fn titleize_tag(tag: &str) -> String { + tag.split_whitespace() + .map(|word| { + if word.eq_ignore_ascii_case("tv") { + return "TV".to_string(); + } + + let mut chars = word.chars(); + match chars.next() { + Some(first) => { + let mut title = String::new(); + title.push(first.to_ascii_uppercase()); + title.push_str(chars.as_str()); + title + } + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn write_gallery_manifest(path: &Path, entries: &[GalleryEntry]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut body = serde_json::to_string_pretty(entries)?; + body.push('\n'); + fs::write(path, body)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // #[test] + // fn prepares_nightshade_staging_names_and_tags() { + // assert_eq!( + // staged_photo_file_name("p1073908", 1, Path::new("P1073908.JPG")), + // "p1073908.jpg" + // ); + // assert_eq!( + // staged_photo_file_name("p1073908", 2, Path::new("P1073908.PNG")), + // "p1073908-2.png" + // ); + // assert_eq!(nightshade_tag(&[]), "photo"); + // assert_eq!(nightshade_tag(&["cat".to_string()]), "cat"); + // assert_eq!(csv_field("cat, portrait"), "\"cat, portrait\""); + // } + + // #[test] + // fn parses_setup_nightshade_archive_and_url_options() { + // let site = Site { + // root: PathBuf::from("C:/repo"), + // ci: false, + // }; + // let options = parse_setup_options( + // &site, + // &[ + // "--archive".to_string(), + // "downloads/Nightshade.zip".to_string(), + // "--url".to_string(), + // "https://example.invalid/Nightshade.zip".to_string(), + // ], + // ) + // .unwrap(); + + // assert_eq!( + // options.archive.as_deref(), + // Some(Path::new("C:/repo/downloads/Nightshade.zip")) + // ); + // assert_eq!( + // options.url.as_deref(), + // Some("https://example.invalid/Nightshade.zip") + // ); + // } + + #[test] + fn parses_ollama_photo_describer_options() { + let site = Site { + root: PathBuf::from("C:/repo"), + ci: false, + }; + let options = parse_process_options( + &site, + &[ + "--describer".to_string(), + "ollama".to_string(), + "--ollama-model".to_string(), + "moondream".to_string(), + "--description-prompt".to_string(), + "describe briefly".to_string(), + ], + ) + .unwrap(); + + assert_eq!(options.describer.ollama_model, "moondream"); + assert_eq!(options.describer.prompt, "describe briefly"); + } + + #[test] + fn defaults_to_ollama_photo_describer() { + let site = Site { + root: PathBuf::from("C:/repo"), + ci: false, + }; + let options = parse_process_options(&site, &[]).unwrap(); + + assert_eq!(options.describer.ollama_model, DEFAULT_OLLAMA_PHOTO_MODEL); + assert_eq!(options.describer.prompt, DEFAULT_PHOTO_DESCRIPTION_PROMPT); + } + + #[test] + fn parses_labeled_photo_description() { + let classification = parse_generated_photo_description( + r#"Title: Bicycle by Brick Wall +Description: A bicycle leans against a brick wall on a quiet street. +Tags: Bicycle, brick wall, street!, +```"#, + ) + .unwrap(); + + assert_eq!(classification.title, "Bicycle by Brick Wall"); + assert_eq!( + classification.description, + "A bicycle leans against a brick wall on a quiet street." + ); + assert_eq!( + classification.tags, + vec![ + "bicycle".to_string(), + "brick wall".to_string(), + "street".to_string() + ] + ); + } + + #[test] + fn parses_wrapped_ollama_description_output() { + let classification = parse_generated_photo_description( + r#"Title: Red Cat in Motion +Description: A blurred, warm-toned image of a ginger cat in a moment +of movement. +Tags: cat, motion blur, ginger, pet, animal, texture, +warm tones, photography"#, + ) + .unwrap(); + + assert_eq!(classification.title, "Red Cat in Motion"); + assert_eq!( + classification.description, + "A blurred, warm-toned image of a ginger cat in a moment of movement." + ); + assert_eq!( + classification.tags, + vec![ + "cat".to_string(), + "motion blur".to_string(), + "ginger".to_string(), + "pet".to_string(), + "animal".to_string(), + "texture".to_string(), + "warm tones".to_string(), + "photography".to_string() + ] + ); + } + + #[test] + fn parses_bulleted_labeled_photo_description() { + let classification = parse_generated_photo_description( + r#"- Title: Warm Cat Blur +- Description: A ginger cat moves through warm light. +- Tags: cat, motion blur, warm tones"#, + ) + .unwrap(); + + assert_eq!(classification.title, "Warm Cat Blur"); + assert_eq!( + classification.description, + "A ginger cat moves through warm light." + ); + assert_eq!( + classification.tags, + vec![ + "cat".to_string(), + "motion blur".to_string(), + "warm tones".to_string() + ] + ); + } + + #[test] + fn derives_title_from_generated_description_when_missing() { + let classification = parse_generated_photo_description( + r#"Description: a foggy hill path curves into the trees. +Tags: fog, hill path, trees"#, + ) + .unwrap(); + + assert_eq!(classification.title, "A Foggy Hill Path Curves Into The"); + assert_eq!( + classification.description, + "a foggy hill path curves into the trees." + ); + } + + #[test] + fn rejects_unlabeled_photo_description() { + assert!( + parse_generated_photo_description("A foggy hill path curves into the trees.").is_err() + ); + } +} diff --git a/tools/site/src/process.rs b/tools/site/src/process.rs new file mode 100644 index 00000000..9511b337 --- /dev/null +++ b/tools/site/src/process.rs @@ -0,0 +1,284 @@ +use crate::{Result, Site, SiteError}; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; + +pub(crate) struct ChildGuard { + child: Child, + label: String, +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return; + } + + if let Err(error) = self.child.kill() { + eprintln!("warning: failed to stop {}: {error}", self.label); + return; + } + + if let Err(error) = self.child.wait() { + eprintln!("warning: failed to wait for {}: {error}", self.label); + } + } +} + +#[derive(Clone, Copy)] +pub(crate) enum InstallMode { + Locked, + Unlocked, +} + +impl Site { + pub(crate) fn bun_install(&self, dir: &Path, mode: InstallMode) -> Result<()> { + let args = match mode { + InstallMode::Locked if self.ci => os_args(&["ci"]), + InstallMode::Locked => os_args(&["install"]), + InstallMode::Unlocked => os_args(&["install"]), + }; + + if bun_program().is_none() && dir.join("node_modules").is_dir() { + self.warn(&format!( + "bun was not found; reusing existing dependencies in {}", + dir.display() + )); + return Ok(()); + } + + self.run_bun(dir, &args) + } + + pub(crate) fn run_bun(&self, cwd: &Path, args: &[OsString]) -> Result<()> { + if let Some(program) = bun_program() { + return self.run(cwd, program, args); + } + + Err(Box::new(SiteError::new( + "could not find bun; install Bun or run bun install before building", + ))) + } + + pub(crate) fn spawn_bun(&self, cwd: &Path, args: &[OsString]) -> Result { + if let Some(program) = bun_program() { + return self.spawn(cwd, program, args); + } + + Err(Box::new(SiteError::new( + "could not find bun; install Bun or run bun install before building", + ))) + } + + pub(crate) fn git_iso_date(&self, args: &[OsString]) -> Result> { + let Some(output) = self.output_optional(&self.root, "git", args)? else { + return Ok(None); + }; + + Ok(output + .split('T') + .next() + .filter(|value| !value.is_empty()) + .map(str::to_string)) + } + + pub(crate) fn relative_git_path(&self, path: &Path) -> Result { + Ok(path + .strip_prefix(&self.root)? + .to_string_lossy() + .replace('\\', "/")) + } + + pub(crate) fn warn(&self, message: &str) { + if self.ci { + println!("::warning::{message}"); + } else { + eprintln!("warning: {message}"); + } + } + + pub(crate) fn run

(&self, cwd: &Path, program: P, args: &[OsString]) -> Result<()> + where + P: AsRef, + { + self.run_with_env(cwd, program, args, &[]) + } + + pub(crate) fn spawn

(&self, cwd: &Path, program: P, args: &[OsString]) -> Result + where + P: AsRef, + { + let program = program.as_ref(); + self.print_command(cwd, program, args); + + let child = Command::new(program) + .args(args) + .current_dir(cwd) + .spawn() + .map_err(|source| { + SiteError(format!( + "failed to run {}: {source}", + format_command(program, args) + )) + })?; + + Ok(ChildGuard { + child, + label: format_command(program, args), + }) + } + + pub(crate) fn run_with_env

( + &self, + cwd: &Path, + program: P, + args: &[OsString], + envs: &[(&str, &str)], + ) -> Result<()> + where + P: AsRef, + { + let program = program.as_ref(); + self.print_command(cwd, program, args); + + let mut command = Command::new(program); + command.args(args).current_dir(cwd); + for (key, value) in envs { + command.env(key, value); + } + + let status = command.status().map_err(|source| { + SiteError(format!( + "failed to run {}: {source}", + format_command(program, args) + )) + })?; + + if status.success() { + Ok(()) + } else { + Err(Box::new(SiteError(format!( + "command failed with {status}: {}", + format_command(program, args) + )))) + } + } + + pub(crate) fn output_optional

( + &self, + cwd: &Path, + program: P, + args: &[OsString], + ) -> Result> + where + P: AsRef, + { + let program = program.as_ref(); + let output = Command::new(program) + .args(args) + .current_dir(cwd) + .stderr(Stdio::inherit()) + .output() + .map_err(|source| { + SiteError(format!( + "failed to run {}: {source}", + format_command(program, args) + )) + })?; + + if !output.status.success() { + return Ok(None); + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok((!value.is_empty()).then_some(value)) + } + + pub(crate) fn status_success

( + &self, + cwd: &Path, + program: P, + args: &[OsString], + ) -> Result + where + P: AsRef, + { + let program = program.as_ref(); + Ok(Command::new(program) + .args(args) + .current_dir(cwd) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|source| { + SiteError(format!( + "failed to run {}: {source}", + format_command(program, args) + )) + })? + .success()) + } + + fn print_command(&self, cwd: &Path, program: &OsStr, args: &[OsString]) { + let relative = cwd.strip_prefix(&self.root).unwrap_or(cwd); + let label = if relative.as_os_str().is_empty() { + ".".to_string() + } else { + relative.display().to_string() + }; + + println!("$ (cd {label}) {}", format_command(program, args)); + } +} + +pub(crate) fn os_args(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() +} + +fn command_succeeds

(program: P, args: &[&str]) -> bool +where + P: AsRef, +{ + Command::new(program) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn bun_program() -> Option { + if command_succeeds("bun", &["--version"]) { + return Some("bun".into()); + } + + if cfg!(windows) { + let path = env::var_os("USERPROFILE") + .map(PathBuf::from) + .map(|home| home.join(".bun/bin/bun.exe")); + + if let Some(path) = path { + if path.is_file() && command_succeeds(path.as_os_str(), &["--version"]) { + return Some(path.into_os_string()); + } + } + } + + None +} + +fn format_command(program: &OsStr, args: &[OsString]) -> String { + let mut parts = vec![format_os(program)]; + parts.extend(args.iter().map(|arg| format_os(arg.as_os_str()))); + parts.join(" ") +} + +fn format_os(value: &OsStr) -> String { + let value = value.to_string_lossy(); + if value.chars().any(char::is_whitespace) { + format!("{value:?}") + } else { + value.into_owned() + } +} diff --git a/ts/src/photography.ts b/ts/src/photography.ts index 5c5540fc..99fb2541 100644 --- a/ts/src/photography.ts +++ b/ts/src/photography.ts @@ -1,9 +1,11 @@ -import { photo_caption, photo_count_label, photo_manifest_entry_is_valid } from "wasm" +import { photo_count_label } from "wasm" interface Photo { src: string title: string + description?: string meta?: string + tags?: string[] } const fallbackPhotos: Photo[] = [] @@ -20,17 +22,59 @@ const nextButton = dialog?.querySelector(".photo-lightbox-nex let photos = fallbackPhotos let currentIndex = 0 -function isPhoto(value: unknown): value is Photo { +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function titleFromPath(path: string): string | undefined { + const filename = path.split(/[\\/]/).pop()?.split(/[?#]/)[0] ?? path + const stem = filename.replace(/\.[^.]*$/, "") + const title = stem.replace(/[_-]+/g, " ").trim() + return title.length > 0 ? title : undefined +} + +function photoTags(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + + const tags = value.flatMap((tag) => { + const trimmed = nonEmptyString(tag) + return trimmed ? [trimmed] : [] + }) + + return tags.length > 0 ? tags : undefined +} + +function toPhoto(value: unknown): Photo | null { if (!value || typeof value !== "object") { - return false + return null } - const candidate = value as Partial - return ( - typeof candidate.src === "string" && - typeof candidate.title === "string" && - photo_manifest_entry_is_valid(candidate.src, candidate.title) - ) + const candidate = value as Record + const src = nonEmptyString(candidate.src) + if (!src) { + return null + } + + const description = nonEmptyString(candidate.description) + const meta = nonEmptyString(candidate.meta) + const title = + nonEmptyString(candidate.title) ?? titleFromPath(meta ?? src) ?? description ?? "Untitled photo" + const tags = photoTags(candidate.tags) + + return { + src, + title, + ...(description ? { description } : {}), + ...(meta ? { meta } : {}), + ...(tags ? { tags } : {}), + } } async function loadPhotos(): Promise { @@ -38,8 +82,12 @@ async function loadPhotos(): Promise { const response = await fetch("/photography/gallery.json", { cache: "no-cache" }) if (response.ok) { const loaded: unknown = await response.json() - if (Array.isArray(loaded) && loaded.length > 0 && loaded.every(isPhoto)) { - photos = loaded + if (Array.isArray(loaded)) { + const loadedPhotos = loaded.map(toPhoto).filter((photo): photo is Photo => photo !== null) + + if (loadedPhotos.length > 0) { + photos = loadedPhotos + } } } } catch { @@ -66,9 +114,7 @@ function renderGallery(): void { const text = document.createElement("span") const title = document.createElement("strong") title.textContent = photo.title - const meta = document.createElement("small") - meta.textContent = photo.meta ?? "" - text.append(title, meta) + text.append(title) card.append(image, text) card.addEventListener("click", () => showPhoto(index)) @@ -88,11 +134,28 @@ function showPhoto(index: number): void { currentIndex = (index + photos.length) % photos.length const photo = photos[currentIndex] dialogImage.src = photo.src - dialogImage.alt = photo.title - dialogCaption.textContent = photo_caption(photo.title, photo.meta ?? "") + dialogImage.alt = photo.description || photo.title + renderCaption(photo) dialog.showModal() } +function renderCaption(photo: Photo): void { + if (!dialogCaption) { + return + } + + const title = document.createElement("strong") + title.textContent = photo.title + dialogCaption.replaceChildren(title) + + const description = photo.description?.trim() + if (description) { + const body = document.createElement("span") + body.textContent = description + dialogCaption.append(body) + } +} + function stepPhoto(delta: number): void { showPhoto(currentIndex + delta) } diff --git a/wasm/wasm/src/photography.rs b/wasm/wasm/src/photography.rs index d69c241f..7ba5636a 100644 --- a/wasm/wasm/src/photography.rs +++ b/wasm/wasm/src/photography.rs @@ -20,8 +20,8 @@ pub fn photo_caption(title: &str, meta: &str) -> String { } #[wasm_bindgen] -pub fn photo_manifest_entry_is_valid(src: &str, title: &str) -> bool { - !src.trim().is_empty() && !title.trim().is_empty() +pub fn photo_manifest_entry_is_valid(src: &str, _title: &str) -> bool { + !src.trim().is_empty() } #[cfg(test)] @@ -44,6 +44,6 @@ mod tests { fn validates_manifest_entry_shape() { assert!(photo_manifest_entry_is_valid("/photo.jpg", "photo")); assert!(!photo_manifest_entry_is_valid("", "photo")); - assert!(!photo_manifest_entry_is_valid("/photo.jpg", "")); + assert!(photo_manifest_entry_is_valid("/photo.jpg", "")); } }