diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn
index 8190da291..f2f5db14b 100644
--- a/.clj-kondo/config.edn
+++ b/.clj-kondo/config.edn
@@ -97,6 +97,13 @@
:unresolved-symbol
{:exclude
[read-string
+ ;; test macros that bind their first argument -- clj-kondo cannot
+ ;; see through them, so each use reads as an unresolved symbol
+ (orcpub.portrait-credit-test/with-conn)
+ (orcpub.portrait-og-test/with-conn)
+ (orcpub.portrait-artists-endpoint-test/with-conn)
+ (orcpub.portrait-store-test/with-conn)
+ (orcpub.page-escape-test/with-conn)
(clojure.test.check.clojure-test/defspec)
(clojure.core.match/match)
(cljs.core.match/match)
@@ -132,6 +139,8 @@
(orcpub.routes.share-test/with-conn)
(orcpub.routes.party-test/with-conn)
(orcpub.heartbeat-test/with-conn)
+ (orcpub.artist-accounts-test/with-conn)
+ (orcpub.artist-credit-test/with-conn)
(user/with-db)]}}
;; native/cljs and web/cljs are separate source roots; kondo doesn't know
;; about them so ns names appear to mismatch their file paths.
diff --git a/.env.example b/.env.example
index a0e5a19b7..7e70223f6 100644
--- a/.env.example
+++ b/.env.example
@@ -122,6 +122,25 @@ EMAIL_TLS=FALSE
# to be empty; ignored otherwise. Private instances only.
ALLOW_UNVERIFIED_REGISTRATION=false
+# --- Preferred names and portrait artists ---
+# Encrypts the preferred names people give on My Account. Comma-separated id:key pairs, each
+# key 32 random bytes in base64; the first encrypts, all decrypt (put a new key first to
+# rotate). Keep it safe: lose it and stored names cannot be read. Without it, preferred
+# names cannot be saved. Generate: echo "k1:$(openssl rand -base64 32)"
+PROFILE_ENCRYPTION_KEYS=
+#
+# Portrait artist credits and accounts, as JSON (see ARTISTS.md). An "account" email gets an
+# artist account and a welcome email when the server starts, so leave real addresses out of
+# a test server unless that is wanted. Artist emails link to APP_URL (Branding, below).
+# PORTRAIT_ARTISTS={"house-pack": {"name": "Fusspot", "account": "artist@example.test"}}
+# Who hears about artist account changes (falls back to EMAIL_ERRORS_TO).
+# EMAIL_ADMIN_TO=
+#
+# Where the server keeps its drawings of shared-link portraits (default: orcpub-portraits in the
+# system temp directory, which the Docker image's user can write). Point several containers at one shared volume and each portrait is
+# drawn once for all of them. They are derived files: deleting them only means they are drawn again.
+# PORTRAIT_STORE_DIR=/var/lib/orcpub/portraits (mount a shared volume there; the image owns it for the app)
+
# --- Branding (optional) ---
# Override app identity for forks. All have sensible defaults in fork/branding.clj.
# APP_NAME=Dungeon Master's Vault
diff --git a/.github/workflows/portrait-art-guard.yml b/.github/workflows/portrait-art-guard.yml
new file mode 100644
index 000000000..e7c0a0566
--- /dev/null
+++ b/.github/workflows/portrait-art-guard.yml
@@ -0,0 +1,32 @@
+# Guard: the licensed portrait art lives only on the private deployment branch (Gitea), never
+# here. scripts/private-art.sh writes resources/public/image/portraits/PRIVATE-ART when it lays
+# the art in, so a push or pull request carrying that marker is the private branch arriving in
+# the public repository. Art committed without the marker is caught by scripts/check-images.sh
+# (and orcpub.portrait-art-guard-test): every image, anywhere, must be a recorded silhouette or a
+# reviewed image.
+
+name: Portrait art guard
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ no-private-art:
+ name: No private portrait art
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: The private-art marker must not be in the public repository
+ run: |
+ if [ -e resources/public/image/portraits/PRIVATE-ART ]; then
+ echo "::error file=resources/public/image/portraits/PRIVATE-ART::This branch carries the licensed portrait art. It belongs only on the private deployment branch. Remove it from GitHub (and from the history if it was pushed) and tell the art owner."
+ exit 1
+ fi
+ echo "OK - no private portrait art on $GITHUB_REF_NAME."
+ - name: Every image is a recorded silhouette or reviewed
+ run: scripts/check-images.sh
diff --git a/.gitignore b/.gitignore
index c423e1ebb..9706470a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -143,3 +143,6 @@ _*.js
# Python bytecode from scripts/ (scripts/lint-changelog.py is run, not imported)
__pycache__/
*.pyc
+
+# strand fields are derived from the real portrait art and travel with it
+resources/public/image/portraits/**/*.strands.png
diff --git a/ARTISTS.md b/ARTISTS.md
new file mode 100644
index 000000000..1a9b9ca97
--- /dev/null
+++ b/ARTISTS.md
@@ -0,0 +1,139 @@
+# Artists
+
+The portrait art in this repository was drawn by hand. It is not generated.
+
+## Fusspot
+
+- Site:
+- Twitch: — where most of the work happens, live
+
+These are the two she asked to be listed. She has others; they are on her site,
+which is the point of linking it.
+
+Every portrait layer in `resources/public/image/portraits/` is Fusspot's work:
+the heads, hair, ears, eyes, noses, mouths and shirts that the portrait maker
+composes.
+
+The files currently committed are **silhouettes** — alpha shapes derived from
+her drawings, at the source proportions, without line detail. They are derived
+works of her art and carry the same credit. The full line art replaces them at
+the same paths.
+
+`scalp/l2b_scalp_01.png` is the one exception: a generated blob that fills a
+gap between two hair pieces. Its shape is derived from hers, so it is listed
+here for completeness rather than claimed as anyone's drawing.
+
+## How the credit is rendered
+
+`orcpub.dnd.e5.portrait-assets/credit-line` produces one line — `Art: Fusspot`
+— and the character sheet, the share card and the portrait summary all use it,
+so the three always say the same thing. Attribution resolves **per asset**, so
+a portrait built from two artists' pieces credits both.
+
+An artist who has not said how they want to be credited is skipped rather than
+given a byline nobody chose.
+
+`:artist/link` is the single link, for surfaces that can only hold one -- the
+character-page credit is a 100px strip. It points at the artist's own homepage,
+because that is the list they maintain, so it cannot go stale the way a copied
+handle can.
+
+`:artist/links` is the full labelled set and appears only in the builder, which
+has room. It is chosen with the artist rather than harvested from their site:
+these are the accounts they want on this work, not every account they have.
+
+Neither is part of `credit-line`. That string is burned into share-card PNGs
+and PDF sheets, where a URL cannot be followed, and it would ride along on
+every copy of somebody else's character.
+
+## For forks
+
+A deployment can restate a credit without editing shared code, via
+`PORTRAIT_ARTISTS`:
+
+```
+PORTRAIT_ARTISTS='{"house-pack": {"name": "…", "link": "https://…"}}'
+```
+
+Only `name`, `link`, `links` and `license` are honoured. Which artist drew which asset
+is structural, lives in the registry, and is not overridable — a configuration
+cannot quietly re-attribute someone's work to a different pack.
+
+**This does not grant a licence.** If you fork this repository, the art is
+still Fusspot's. Ask her before shipping it.
+
+## Artist accounts
+
+An artist can have an account that controls how they're credited: their
+name, where it links, and which link icons sit beside it. The account can't
+change which art is theirs. That stays in the registry.
+
+There's no admin page and no invite endpoint. Accounts come from the same
+`PORTRAIT_ARTISTS` entry as the credit, read when the server starts:
+
+```
+PORTRAIT_ARTISTS='{"house-pack": {
+ "name": "Fusspot",
+ "account": "hello@fusspot.rip",
+ "username": "fusspot",
+ "preferred_name": "Fuss",
+ "welcome_subject": "We did it, Fuss! Your art is officially on OrcPub",
+ "welcome_note": "We did it! Your art is officially on OrcPub! …"
+}}'
+```
+
+On each start:
+
+| The address has… | What happens |
+|-----------------------------------|-------------------------------------------------------------------------|
+| no account | one is made and linked, and a welcome email with a set-password link that lasts a week is sent |
+| a confirmed account | it's linked, and an upgrade email is sent |
+| an unconfirmed account | it is claimed: given a new random password and linked, and the welcome email is sent to the address, so only the address's owner can get in |
+| an account that's already linked | nothing, so restarts never resend (a welcome the mail server refused is sent again) |
+
+Only `account` is needed; the rest just make the welcome warmer. Changing
+`account` moves the link to the new address and tells the old account.
+`"account": null` removes the link. Deleting the entry does nothing, so a
+bad edit can't quietly strip an artist's access. Several containers
+starting together make one account and send one email.
+
+What the site shows is layered, and later layers win field by field:
+
+1. the registry in `portrait_assets.cljc` (the public default)
+2. the artist's own edits, from My Account → Artist credit
+3. `name`, `link` and `links` in `PORTRAIT_ARTISTS`
+
+So you can always correct a credit from config without touching the
+account. Fields the config sets show as fixed on the artist's settings
+page. Every edit is emailed to the artist and to the admin.
+
+### Settings these need
+
+| Setting | For |
+|---------------------------------|---------------------------------------------------------------------|
+| `APP_URL` | links in the startup emails; without it they're skipped (logged) and the artist can use "Forgot password" |
+| `EMAIL_SERVER_URL` etc. | sending anything at all |
+| `EMAIL_ADMIN_TO` | the admin's copies; falls back to `EMAIL_ERRORS_TO` |
+| `APP_EMAIL_SIGNOFF` | who the warmer emails are signed from (default "The ") |
+| `PROFILE_ENCRYPTION_KEYS` | preferred names (see below); a Docker secret `profile_encryption_keys` takes precedence |
+
+## Preferred names
+
+Every account can set "What should we call you?" on My Account. Site emails
+open with it ("Hi Kaylee,") and say "Hi there," without it.
+
+It's **encrypted, not hashed**: a hash can't be read back, and the site has
+to write the name into the email. The database holds only ciphertext, and
+the key lives outside it, so a copied database or backup doesn't reveal the
+names. A compromised app server still could, because it holds the key.
+
+```
+# one key: id:base64 of 32 random bytes
+PROFILE_ENCRYPTION_KEYS="k1:$(openssl rand -base64 32)"
+```
+
+Prefer the Docker secret `profile_encryption_keys` in swarm. To rotate, put a
+new key first and keep the old ones after it (`k2:…,k1:…`); the first
+encrypts and all of them decrypt. **Losing every key loses the names**, and
+only the names: they fall back to "Hi there,". With no key set the field says
+it isn't available yet, and nothing is ever stored readable.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f5f8677d..9cff75a6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,7 +12,7 @@ House style and the branch → release fold: [`docs/branch-changelog.template.md
### Highlights
-Homebrew can be moved, switched off and repaired instead of lost, and a character that used to blank the page now recovers. Characters can be shared with the homebrew they use, printed sheets no longer drop spells and gain a spellbook, and accounts get new sign-in pages and stronger password rules. A new Rules tab holds the SRD's rules and conditions, with links that preview a spell, condition or rule as you read.
+Homebrew can be moved, switched off and repaired instead of lost, and a character that used to blank the page now recovers. Characters can be shared with the homebrew they use and given a portrait drawn from an illustrator's layered art, printed sheets no longer drop spells and gain a spellbook, and accounts get new sign-in pages and stronger password rules. A new Rules tab holds the SRD's rules and conditions, with links that preview a spell, condition or rule as you read.
### Added
@@ -48,6 +48,26 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte
- **Rules:** clarification notes on rules, in our own words, linked to where Wizards published
the ruling.
- **Spells:** conditions named in spell and magic item text link to their page, with a preview.
+- **Characters:** a portrait builder: pick or randomize layered art for hair, head, ears, eyes,
+ nose, mouth and shirt, from the summary thumbnail, beside the Image URL field, or in a Portrait
+ tab in the builder.
+- **Characters:** portrait colours per character for hair, skin, eyes, eye whites, lips and shirt,
+ with a lighter, darker or different colour for any single piece.
+- **Characters:** hair ombré with streaks that follow the drawn strands, a shine you can place,
+ dyed bangs, and soft shadows where hair falls over the face.
+- **Characters:** eyes with slit or goat pupils, a different colour for each eye, brightness, and
+ glowing eyes.
+- **Characters:** blush, freckles and shading under the hair.
+- **Characters:** mood light: torch, moonlight or arcane, as a lit edge, a cast or a rim.
+- **Characters:** the artist is credited under the portrait, on the sheet, in shared link previews
+ and in the picture itself, with the links the artist chose.
+- **Printing:** the portrait prints on the exported character sheet, at full resolution.
+- **Sharing:** a shared character link previews with the character's own portrait, and the
+ preview updates when the portrait changes.
+- **Accounts:** a preferred name, used to greet you in site emails.
+- **Accounts:** artists get an account with a welcome email and can change how they are credited
+ on My Account.
+- **Site:** pages and portrait images ask AI crawlers not to train on them.
### Changed
@@ -59,6 +79,9 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte
- **Site:** on phones the header, menus and ability buttons fit the screen. (#711)
- **Server:** blank settings count as unset, Docker secrets work for the login key, and the Windows start scripts find their ports. (#701)
- **Server:** without email configured, registration is refused unless explicitly allowed. (#712)
+- **Accounts:** site emails greet you by your preferred name, or by your username when you have
+ not set one, instead of "Hi there".
+- **Characters:** the builder's tab bar wraps on narrow screens.
### Fixed
@@ -97,6 +120,13 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte
- **Equipment:** tools, musical instruments, gaming sets and equipment packs show their SRD prices,
and tools and instruments their weights.
- **Magic items:** the Alchemy Jug shows that its liquid can be created once a day.
+- **Characters:** a late save for one character no longer replaces another character open in the
+ builder, along with its unsaved edits.
+- **Sharing:** link previews of shared characters failed on Discord, Mastodon, Slack, Telegram and
+ X, because `robots.txt` blocked every crawler that honours it.
+- **Sharing:** every shared character link had an empty title and image in its preview.
+- **Printing:** exported sheets reported "Adobe InDesign CS6" as their creator and had no title or
+ author.
### Security
diff --git a/dev/e2e_boot.clj b/dev/e2e_boot.clj
index f410e45ce..9e3fc0c3e 100644
--- a/dev/e2e_boot.clj
+++ b/dev/e2e_boot.clj
@@ -12,6 +12,7 @@
(:require [com.stuartsierra.component :as component]
[datomic.api :as d]
[orcpub.system :as s]
+ [orcpub.artist-accounts :as artist-accounts]
[orcpub.routes :as routes]
[orcpub.entity.strict :as se]
[orcpub.dnd.e5.character :as char5e]
@@ -73,6 +74,14 @@
(item "kaylee" "Kaylee Seeded Beta")
(item "kaylee" "Kaylee Seeded Gamma")
(item "zoe" "Zoe Seeded Only")])
+ ;; E2E_ARTIST= makes kaylee that artist's account, through the
+ ;; same linking the live site does. Not via PORTRAIT_ARTISTS: the server's
+ ;; own startup pass runs before the seeding above, so it would find no
+ ;; kaylee and create a separate account instead.
+ (when-let [artist (not-empty (System/getenv "E2E_ARTIST"))]
+ (println "e2e-boot: kaylee as artist"
+ (artist-accounts/reconcile! conn {(keyword artist) {:account "kaylee@example.com"}}
+ artist-accounts/log-only-notify)))
;; Printed so a check can open the character without logging in.
(let [{:keys [status body]} (routes/do-save-character (d/db conn) conn
(fighter-carrying "Bree Tinker" :kaylee-seeded-alpha)
diff --git a/dev/orcpub/portrait_gallery.clj b/dev/orcpub/portrait_gallery.clj
new file mode 100644
index 000000000..3f88a6c7a
--- /dev/null
+++ b/dev/orcpub/portrait_gallery.clj
@@ -0,0 +1,1628 @@
+(ns orcpub.portrait-gallery
+ "Galleries and measurements of a real art pack, used to settle how the portraits are coloured
+ (docs/PORTRAIT-TINTING.md). A REPL tool, not part of the suite: load it under the :tools profile
+ and run (clojure.test/run-tests 'orcpub.portrait-gallery). The art check that guards the app is
+ orcpub.portrait-scalp-coverage-test.
+
+ Compose a real pack both ways and look at the difference.
+
+ Point ORCPUB_PACK at an unpacked portrait pack -- /.png, the
+ shape the Loom and build-portrait-assets.py both emit -- and this stacks the
+ layers in z-order twice: once through the mask the app uses today, once
+ through multiply. Writes both to ORCPUB_PACK_OUT.
+
+ Skips when the pack is not there, because it describes art that does not live
+ in this repository."
+ (:require [clojure.test :refer [deftest is testing]]
+ [clojure.java.io :as io]
+ [orcpub.dnd.e5.portrait-assets :as pa]
+ [orcpub.dnd.e5.portrait-layout :as layout]
+ [clojure.data.json :as json]
+ [clojure.string :as s]
+ [orcpub.art-mark :as art-mark]
+ [orcpub.dnd.e5.portrait-random :as rnd])
+ (:import [java.awt AlphaComposite Color RenderingHints]
+ [java.awt.geom Area Path2D$Double Ellipse2D$Double]
+ [java.awt.image BufferedImage]
+ [java.io File]
+ [javax.imageio ImageIO]))
+
+(def ^:private W 464)
+(def ^:private H 638)
+
+;; A character, so the comparison is about the art rather than about grey.
+(def ^:private colours
+ {:hair (Color. 0x5C 0x3A 0x1E)
+ :skin (Color. 0xE8 0xC6 0x9C)
+ :eyes (Color. 0x3B 0x6E 0xA5)
+ :shirt (Color. 0x7A 0x5C 0x3A)})
+
+(def ^:private category-fallback (Color. 0xB5 0x6B 0x5E)) ; mouth: clay red
+
+(defn- pack-dir []
+ (when-let [d (System/getenv "ORCPUB_PACK")]
+ (let [f (File. d)] (when (.isDirectory f) f))))
+
+(defn- assets-in [^File pack layer-key]
+ (let [d (File. pack (name layer-key))]
+ (when (.isDirectory d)
+ (->> (.listFiles d)
+ (filter #(re-find #"(?i)\.png$" (.getName ^File %)))
+ (sort-by #(.getName ^File %))
+ vec))))
+
+(defn- asset-for [^File pack layer-key]
+ (let [d (File. pack (name layer-key))]
+ (when (.isDirectory d)
+ (->> (.listFiles d)
+ (filter #(re-find #"(?i)\.png$" (.getName ^File %)))
+ sort first))))
+
+(defn- px
+ "The backing int[] of a TYPE_INT_ARGB image.
+
+ .getRGB/.setRGB go through the ColorModel on every call. These loops run
+ over ~300k pixels per layer per variant, so that indirection was most of
+ the render time."
+ ^ints [^BufferedImage img]
+ (.. img getRaster getDataBuffer getData))
+
+(defn- scaled*
+ "The asset placed in the frame the way both renderers place it."
+ ^BufferedImage [^File f]
+ (when-let [src (ImageIO/read f)]
+ (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics out)
+ [x y dw dh] (layout/contain-rect (.getWidth src) (.getHeight src) W H)]
+ (try
+ (.setRenderingHint g RenderingHints/KEY_INTERPOLATION
+ RenderingHints/VALUE_INTERPOLATION_BILINEAR)
+ (.drawImage g src (int x) (int y) (int dw) (int dh) nil)
+ (finally (.dispose g)))
+ out)))
+
+(def ^:private scaled
+ "Memoized: a sweep composes the same ten layers dozens of times over, and
+ every call was an ImageIO/read off disk plus a bilinear rescale."
+ (memoize scaled*))
+
+(defn- masked
+ "What the app does today: keep the alpha, replace every colour."
+ ^BufferedImage [^BufferedImage art ^Color tint]
+ (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics out)]
+ (try
+ (.drawImage g art 0 0 nil)
+ (.setComposite g AlphaComposite/SrcIn)
+ (.setColor g tint)
+ (.fillRect g 0 0 W H)
+ (finally (.dispose g)))
+ out))
+
+(defn- multiplied
+ "Tint through the alpha, then put the drawing back over it."
+ ^BufferedImage [^BufferedImage art ^Color tint]
+ (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ src (px art) dst (px out)
+ tr (.getRed tint) tg (.getGreen tint) tb (.getBlue tint)]
+ (dotimes [i (* W H)]
+ (let [argb (aget src i)
+ a (bit-and (unsigned-bit-shift-right argb 24) 0xff)]
+ (when (pos? a)
+ (let [r (bit-and (unsigned-bit-shift-right argb 16) 0xff)
+ g (bit-and (unsigned-bit-shift-right argb 8) 0xff)
+ b (bit-and argb 0xff)]
+ (aset-int dst i
+ (unchecked-int
+ (bit-or (bit-shift-left a 24)
+ (bit-shift-left (quot (* tr r) 255) 16)
+ (bit-shift-left (quot (* tg g) 255) 8)
+ (quot (* tb b) 255))))))))
+ out))
+
+(defn- compose [^File pack tint-fn]
+ (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)
+ drawn (atom [])]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [f (asset-for pack layer-key)]
+ (when-let [art (scaled f)]
+ (let [slot (get pa/color-slots layer-key)
+ tint (get colours slot category-fallback)]
+ (.drawImage g ^BufferedImage (tint-fn art tint) 0 0 nil)
+ (swap! drawn conj layer-key)))))
+ (finally (.dispose g)))
+ {:image canvas :layers @drawn}))
+
+(defn- opaque-colour-count [^BufferedImage img]
+ (count (into #{} (for [x (range W) y (range H)
+ :let [argb (.getRGB img x y)]
+ :when (= 255 (bit-and (unsigned-bit-shift-right argb 24) 0xff))]
+ (bit-and argb 0xffffff)))))
+
+(def ^:private pack-mark
+ "The licensed-art mark for what is drawn from the pack at ORCPUB_PACK: composed from its pieces'
+ marks, or nil when none is marked (silhouettes)."
+ (delay (when-let [pack (pack-dir)]
+ (art-mark/composed
+ (for [^File f (file-seq pack)
+ :when (and (.isFile f) (re-find #"(?i)\.png$" (.getName f)))]
+ (art-mark/mark-of (java.nio.file.Files/readAllBytes (.toPath f))))))))
+
+(defn- write!
+ "Write `img` as .png to ORCPUB_PACK_OUT, carrying the pack's licensed-art mark when it has
+ one: anything drawn from the real art is marked like the art."
+ [^BufferedImage img nm]
+ (when-let [dir (System/getenv "ORCPUB_PACK_OUT")]
+ (.mkdirs (File. dir))
+ (let [out (java.io.ByteArrayOutputStream.)]
+ (ImageIO/write img "png" out)
+ (io/copy (cond-> (.toByteArray out) @pack-mark (art-mark/with-mark @pack-mark))
+ (File. dir (str nm ".png"))))
+ (println (format " wrote %s/%s.png" dir nm))))
+
+(deftest compose-a-real-pack-both-ways
+ (if-let [pack (pack-dir)]
+ (let [m (compose pack masked)
+ x (compose pack multiplied)]
+ (println (format "\n composed %d layers: %s"
+ (count (:layers m)) (pr-str (:layers m))))
+ (write! (:image m) "composed-mask")
+ (write! (:image x) "composed-multiply")
+
+ (is (seq (:layers m)) "found layers in the pack")
+ (is (= (:layers m) (:layers x)) "same stack both ways")
+
+ (let [mc (opaque-colour-count (:image m))
+ xc (opaque-colour-count (:image x))]
+ (println (format " distinct opaque colours -- mask: %d, multiply: %d\n" mc xc))
+ ;; A first version asserted the mask leaves about as many colours as
+ ;; there are slots. That was wrong, and the real pack said so: ten
+ ;; layers with antialiased edges blend into each other, so masking
+ ;; still yields ~1000. The absolute count says nothing. The RATIO does.
+ (testing "multiply keeps the drawing, which is several times the colour"
+ (is (> xc (* 3 mc))
+ (str "multiply produced " xc " against the mask's " mc)))))
+ (println "\n ORCPUB_PACK not set or not a directory -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; Several characters from one pack, so the question is what the compositor
+;; does across combinations rather than what it did to one.
+;; ---------------------------------------------------------------------------
+
+(def ^:private palettes
+ [{:hair (Color. 0x5C 0x3A 0x1E) :skin (Color. 0xE8 0xC6 0x9C)
+ :eyes (Color. 0x3B 0x6E 0xA5) :shirt (Color. 0x7A 0x5C 0x3A)}
+ {:hair (Color. 0x1B 0x14 0x12) :skin (Color. 0x8D 0x5A 0x3C)
+ :eyes (Color. 0x3E 0x2A 0x1C) :shirt (Color. 0x2F 0x4F 0x6B)}
+ {:hair (Color. 0xC9 0x8E 0x3A) :skin (Color. 0xF2 0xD8 0xBE)
+ :eyes (Color. 0x4E 0x7A 0x46) :shirt (Color. 0x7B 0x2F 0x3A)}
+ {:hair (Color. 0x9B 0x3D 0x2E) :skin (Color. 0xC8 0x96 0x6E)
+ :eyes (Color. 0x5B 0x4A 0x7A) :shirt (Color. 0x33 0x3A 0x33)}])
+
+(defn- compose-variant
+ "Character `n`: the nth option of every layer, wrapping where a layer has
+ fewer, painted from the nth palette."
+ [^File pack n tint-fn]
+ (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)
+ palette (nth palettes (mod n (count palettes)))]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [choices (seq (assets-in pack layer-key))]
+ (when-let [art (scaled (nth choices (mod n (count choices))))]
+ (let [slot (get pa/color-slots layer-key)]
+ (.drawImage g ^BufferedImage (tint-fn art (get palette slot category-fallback))
+ 0 0 nil)))))
+ (finally (.dispose g)))
+ canvas))
+
+(defn- contact-sheet [images]
+ (let [sheet (BufferedImage. (* W (count images)) H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics sheet)]
+ (try
+ (.setColor g Color/WHITE)
+ (.fillRect g 0 0 (.getWidth sheet) H)
+ (doseq [[i ^BufferedImage img] (map-indexed vector images)]
+ (.drawImage g img (int (* i W)) 0 nil))
+ (finally (.dispose g)))
+ sheet))
+
+(deftest four-characters-from-one-pack
+ (if-let [pack (pack-dir)]
+ (let [n 4
+ multi (mapv #(compose-variant pack % multiplied) (range n))
+ mask (mapv #(compose-variant pack % masked) (range n))]
+ (write! (contact-sheet multi) "four-multiply")
+ (write! (contact-sheet mask) "four-mask")
+ (doseq [[i ^BufferedImage img] (map-indexed vector multi)]
+ (is (pos? (opaque-colour-count img))
+ (str "variant " i " drew something")))
+ (testing "the variants differ from each other -- the pack really does
+ offer choices, rather than one face in four colours"
+ (is (apply distinct? (map opaque-colour-count multi)))))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; Layers that hold more than one thing.
+;;
+;; A smiling mouth is lips AND teeth AND the dark inside. An open eye is sclera
+;; AND iris AND pupil. One tint over the whole asset is wrong for both, whether
+;; that tint arrives by mask or by multiply -- character three came out with
+;; clay-red teeth because the mouth layer's category colour flooded everything
+;; in it.
+;;
+;; The third option is to tint such a layer not at all and draw it as the
+;; illustrator coloured it. That costs the layer its colour slot, which is free
+;; for the mouth (nobody picks a lip colour) and not free for the eyes, where
+;; the iris would have to become its own layer for an eye-colour slot to have
+;; anything to act on.
+;; ---------------------------------------------------------------------------
+
+(defn- as-drawn
+ "No tint at all: the artist's own colours."
+ ^BufferedImage [^BufferedImage art ^Color _]
+ art)
+
+(def ^:private untinted-layers #{:mouth :eyes})
+
+(defn- compose-mixed
+ "multiply everywhere, except the layers that carry their own colours."
+ [^File pack n]
+ (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)
+ palette (nth palettes (mod n (count palettes)))]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [choices (seq (assets-in pack layer-key))]
+ (when-let [art (scaled (nth choices (mod n (count choices))))]
+ (let [tint-fn (if (untinted-layers layer-key) as-drawn multiplied)
+ slot (get pa/color-slots layer-key)]
+ (.drawImage g ^BufferedImage (tint-fn art (get palette slot category-fallback))
+ 0 0 nil)))))
+ (finally (.dispose g)))
+ canvas))
+
+(deftest teeth-should-not-be-pink
+ (if-let [pack (pack-dir)]
+ ;; variant 2 is the one with the open smile
+ (let [n 2
+ all-multiply (compose-variant pack n multiplied)
+ mixed (compose-mixed pack n)]
+ (write! (contact-sheet [all-multiply mixed]) "mouth-tinted-vs-as-drawn")
+ (is (not= (opaque-colour-count all-multiply) (opaque-colour-count mixed))
+ "leaving two layers untinted changes the picture")
+ (testing "and it is the mouth that changes: tinting it paints teeth and
+ lips the same clay red, drawing it as-is keeps whatever the
+ illustrator put there"
+ (let [mouth (first (assets-in pack :mouth))]
+ (is (some? mouth) "the pack has a mouth layer to reason about"))))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; The mode per layer, which is the shape the registry would carry.
+;;
+;; multiply keeps a drawing and colours it, so it suits every layer that is
+;; one material. It is wrong where an asset holds two things that want
+;; different answers -- teeth are not lips. For the eyes there is a third
+;; possibility worth looking at rather than reasoning about: MASK them. It
+;; flattens the whole eye to the chosen colour, lashes included, which is
+;; destructive in principle and may be unnoticeable in practice, because the
+;; region is tiny and stylised. Keeping it also keeps the eye-colour picker,
+;; which drawing them as-is gives up.
+;; ---------------------------------------------------------------------------
+
+(def ^:private layer-modes
+ "One exception, not two. Looked at side by side on the same face at 3x:
+
+ mask flat blue almonds -- no pupil, no lashes, no highlight
+ multiply iris, pupil, lashes and highlight, in the chosen colour
+ as-drawn the eye in black and white, since this asset carries no colour
+
+ So the eyes want multiply like everything else, and the worry that it would
+ flood the sclera was wrong: these eyes are drawn with a large iris, so the
+ pale area is small and reading it as eye colour is right rather than a
+ mistake. Only the mouth is special, because teeth are white and must stay
+ white while lips are not."
+ {:mouth :none})
+
+(defn- tint-fn-for [layer-key]
+ (case (get layer-modes layer-key :multiply)
+ :mask masked
+ :none as-drawn
+ multiplied))
+
+(defn- compose-by-mode [^File pack n]
+ (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)
+ palette (nth palettes (mod n (count palettes)))]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [choices (seq (assets-in pack layer-key))]
+ (when-let [art (scaled (nth choices (mod n (count choices))))]
+ (.drawImage g ^BufferedImage
+ ((tint-fn-for layer-key) art
+ (get palette (get pa/color-slots layer-key) category-fallback))
+ 0 0 nil))))
+ (finally (.dispose g)))
+ canvas))
+
+(deftest one-character-three-eye-treatments
+ (testing "the same face three ways, so the eyes can be compared without a
+ different character confusing the picture"
+ (if-let [pack (pack-dir)]
+ (let [n 0]
+ (write! (compose-variant pack n masked) "eyes-a-mask")
+ (write! (compose-variant pack n multiplied) "eyes-b-multiply")
+ (write! (compose-mixed pack n) "eyes-c-as-drawn")
+ (is true))
+ (println "\n ORCPUB_PACK not set -- skipping.\n"))))
+
+(deftest per-layer-modes-across-four-characters
+ (if-let [pack (pack-dir)]
+ (let [sheet (contact-sheet (mapv #(compose-by-mode pack %) (range 4)))]
+ (write! sheet "four-by-mode")
+ (is (pos? (opaque-colour-count sheet))))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; The eyes, rendered the way the SERVER will render them.
+;;
+;; Every picture in this conversation so far came out of a scratch script in a
+;; third language -- not the browser's canvas, not this Java2D path, a Python
+;; reimplementation of both. That is how a render came back with clay-red teeth
+;; long after the mouth had been settled: the script did not know. A preview
+;; that is not produced by the shipping code proves nothing about the shipping
+;; code, so this reads the pack's own manifest and draws through Java2D.
+;; ---------------------------------------------------------------------------
+
+(defn- read-manifest [^File pack]
+ (let [f (File. pack "manifest.json")]
+ (when (.isFile f)
+ (json/read-str (slurp f) :key-fn keyword))))
+
+(defn- iris-spec
+ "The regions someone placed in the Loom, by asset file name."
+ [manifest]
+ (into {}
+ (for [layer (:layers manifest)
+ a (:assets layer)
+ :when (seq (:iris a))]
+ [(:file a) a])))
+
+(defn- ellipse-area
+ "An iris oval as a shape, in frame pixels."
+ ^Area [e scale]
+ (let [rx (* (:rx e) W scale), ry (* (:ry e) H scale)
+ cx (* (:cx e) W), cy (* (:cy e) H)
+ el (Ellipse2D$Double. (- rx) (- ry) (* 2 rx) (* 2 ry))
+ tx (doto (java.awt.geom.AffineTransform.)
+ (.translate cx cy)
+ (.rotate (:rot e)))]
+ (Area. (.createTransformedShape tx el))))
+
+(defn- above-lid
+ "Everything above the lid curve -- the part the lash covers."
+ ^Area [lid]
+ (when lid
+ (let [p (Path2D$Double.)]
+ (.moveTo p (* (:x0 lid) W) (* (:y0 lid) H))
+ (.quadTo p (* (:mx lid) W) (* (:my lid) H)
+ (* (:x1 lid) W) (* (:y1 lid) H))
+ (.lineTo p (* (:x1 lid) W) (- H))
+ (.lineTo p (* (:x0 lid) W) (- H))
+ (.closePath p)
+ (Area. p))))
+
+(defn- shifted
+ "The pupil, moved off the iris centre. A pupil pinned to the middle of the
+ oval stares straight out of the page, which on a wide eye reads as startled
+ rather than as a character looking at something."
+ [e]
+ (let [px (or (:px e) 0.0), py (or (:py e) 0.0)]
+ (assoc e
+ :cx (+ (:cx e) (* px (:rx e)))
+ :cy (+ (:cy e) (* py (:ry e))))))
+
+(defn- iris-region
+ "Oval minus pupil minus whatever the lid covers."
+ ^Area [spec]
+ (let [pupil (or (:pupil spec) 0.45)
+ area (Area.)]
+ (doseq [e (:iris spec)]
+ (let [a (ellipse-area e 1.0)]
+ (.subtract a (ellipse-area (shifted e) pupil))
+ (when-let [lid (above-lid (:lid e))] (.subtract a lid))
+ (.add area a)))
+ area))
+
+(defn- coverage-mask
+ "How much of each pixel the region covers, 0..255.
+
+ Area/contains is a yes-or-no test, so the iris and pupil edges came out
+ stair-stepped against artwork that is smooth everywhere else. Invisible at
+ portrait size and not invisible on a printed sheet. Filling the shape with
+ antialiasing on gives partial coverage at the boundary, which is then a
+ blend weight rather than a switch."
+ ^bytes [^Area region]
+ (let [img (BufferedImage. W H BufferedImage/TYPE_BYTE_GRAY)
+ g (.createGraphics img)]
+ (try
+ (.setRenderingHint g RenderingHints/KEY_ANTIALIASING
+ RenderingHints/VALUE_ANTIALIAS_ON)
+ (.setColor g Color/WHITE)
+ (.fill g region)
+ (finally (.dispose g)))
+ (.. img getRaster getDataBuffer getData)))
+
+(def ^:dynamic *iris-gamma*
+ "Bends the drawing's luminance before the ramp reads it.
+
+ The floor alone cannot serve both viewing distances: raising it lifts the
+ iris's AVERAGE colour, which is what a glance sees, by squeezing the range
+ between its darkest and lightest ink, which is what a close look sees. One
+ knob doing two jobs is why 0.55 read better up close and 0.75 better across
+ the room.
+
+ A gamma below 1 lifts the midtones -- where the body of the iris sits --
+ while leaving the darkest ink alone, so the average moves toward the colour
+ and the contrast stays."
+ 1.0)
+
+(def ^:dynamic *iris-floor*
+ "How much of the eye colour the darkest iris ink keeps. 0.30 was the first
+ guess and it lost the colour on this art, whose irises are drawn almost
+ black."
+ 0.30)
+
+(defn- colorize-through
+ "Map a drawing's own luminance through `colour`, wherever `cov` says.
+
+ Dark stays dark, mid becomes the colour, the highlight stays a highlight.
+ The input has to be a luminance ramp with no hue of its own -- which is how
+ the irises are drawn, and, it turns out, the lips.
+
+ `cov` is per-pixel coverage 0..255, so a region's antialiased edge fades into
+ the drawing instead of stepping."
+ ^BufferedImage [^bytes cov ^BufferedImage art ^Color colour]
+ (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ src (px art) dst (px out)
+ floor (double *iris-floor*) gamma (double *iris-gamma*)
+ er (.getRed colour) eg (.getGreen colour) eb (.getBlue colour)]
+ (dotimes [i (* W H)]
+ (let [argb (aget src i)
+ a (bit-and (unsigned-bit-shift-right argb 24) 0xff)]
+ (when (pos? a)
+ (let [k (/ (bit-and (aget cov i) 0xff) 255.0)]
+ (if (zero? k)
+ (aset-int dst i argb)
+ (let [r (bit-and (unsigned-bit-shift-right argb 16) 0xff)
+ g (bit-and (unsigned-bit-shift-right argb 8) 0xff)
+ b (bit-and argb 0xff)
+ l (/ (+ r g b) 765.0)
+ lg (Math/pow l gamma)
+ mix (fn [c] (if (< lg 0.5)
+ (* c (+ floor (* (- 1.0 floor) 2.0 lg)))
+ (+ c (* (- 255 c) (* 2.0 (- lg 0.5))))))
+ blend (fn [s tgt] (int (min 255.0 (+ (* s (- 1.0 k))
+ (* tgt k)))))]
+ (aset-int dst i
+ (unchecked-int
+ (bit-or (bit-shift-left a 24)
+ (bit-shift-left (blend r (mix er)) 16)
+ (bit-shift-left (blend g (mix eg)) 8)
+ (blend b (mix eb)))))))))))
+ out))
+
+(defn- colorize-iris
+ "Paint the iris its colour instead of ADDING colour to it.
+
+ Adding is what every preview so far did, and it is why the irises looked
+ thin: the artwork shows through, so shading and the highlight wash toward
+ white rather than reading as a coloured eye. This maps the drawing's own
+ luminance through the chosen colour -- dark stays dark, mid becomes the
+ colour, the highlight stays a highlight -- which is opaque where the drawing
+ is opaque."
+ ^BufferedImage [^BufferedImage art ^Area region ^Color eye]
+ (colorize-through (coverage-mask region) art eye))
+
+(defn- whole-asset-coverage
+ "Every opaque pixel, as a coverage array. The degenerate region, for an asset
+ that is nothing but the thing to be coloured."
+ ^bytes [^BufferedImage art]
+ (let [cov (byte-array (* W H)) src (px art)]
+ (dotimes [i (* W H)]
+ (when (pos? (bit-and (unsigned-bit-shift-right (aget src i) 24) 0xff))
+ (aset-byte cov i (unchecked-byte 255))))
+ cov))
+
+(defn- compose-with-eyes
+ "A character, drawn the way the app will draw it: multiply everywhere, the
+ mouth left alone, the eyes coloured inside their placed regions."
+ ([^File pack manifest n] (compose-with-eyes pack manifest n nil nil))
+ ([^File pack manifest n eye-idx eye-colour]
+ (let [spec (iris-spec manifest)
+ palette (cond-> (nth palettes (mod n (count palettes)))
+ eye-colour (assoc :eyes eye-colour))
+ canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [choices (seq (assets-in pack layer-key))]
+ ;; an eye asset is one that carries a placed iris; when a style is
+ ;; being held fixed it is picked by eye-idx rather than by n
+ (let [eyes? (some #(get spec (.getName ^File %)) choices)
+ ^File f (nth choices (mod (if (and eyes? eye-idx) eye-idx n)
+ (count choices)))]
+ (when-let [art (scaled f)]
+ (let [slot (get pa/color-slots layer-key)
+ s (get spec (.getName f))]
+ (cond
+ s (.drawImage g (colorize-iris art (iris-region s)
+ (get palette :eyes))
+ 0 0 nil)
+ ;; the mouth carries its own colours: teeth are not lips
+ (nil? slot) (.drawImage g art 0 0 nil)
+ :else (.drawImage g ^BufferedImage
+ (multiplied art (get palette slot)) 0 0 nil)))))))
+ (finally (.dispose g)))
+ canvas)))
+
+(deftest render-the-pack-the-way-the-server-will
+ (if-let [pack (pack-dir)]
+ (let [manifest (read-manifest pack)]
+ (if-not manifest
+ (println "\n no manifest.json in the pack -- skipping\n")
+ (let [sheet (contact-sheet (mapv #(compose-with-eyes pack manifest %) (range 4)))]
+ (write! sheet "server-render")
+ (println (format " iris specs found: %s"
+ (pr-str (keys (iris-spec manifest)))))
+ (is (pos? (opaque-colour-count sheet))))))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; A grade over the finished portrait.
+;;
+;; Observed on a laptop with night-light on: everything looked warmer and
+;; richer. Worth testing rather than assuming, because a display filter warms
+;; the WHOLE field -- the art, the page, the chrome -- and the eye adapts to
+;; all of it. The same shift applied to the portrait ALONE has to survive
+;; sitting next to neutral white, which is a different question.
+;;
+;; Written here, in the shipping language, so that if any of it is worth
+;; keeping it is already the implementation rather than a sketch of one.
+;; ---------------------------------------------------------------------------
+
+(defn- grade
+ "Per-channel gain plus a pull toward or away from grey."
+ ^BufferedImage [^BufferedImage src [rg gg bg] sat]
+ (let [w (.getWidth src) h (.getHeight src)
+ out (BufferedImage. w h BufferedImage/TYPE_INT_ARGB)]
+ (dotimes [y h]
+ (dotimes [x w]
+ (let [argb (.getRGB src x y)
+ a (bit-and (unsigned-bit-shift-right argb 24) 0xff)
+ r (* (bit-and (unsigned-bit-shift-right argb 16) 0xff) (double rg))
+ g (* (bit-and (unsigned-bit-shift-right argb 8) 0xff) (double gg))
+ b (* (bit-and argb 0xff) (double bg))
+ ;; luma with the usual weights, so saturation moves colour without
+ ;; moving brightness
+ l (+ (* 0.2126 r) (* 0.7152 g) (* 0.0722 b))
+ mix (fn [c] (int (max 0 (min 255 (+ l (* sat (- c l)))))))]
+ (.setRGB out x y
+ (unchecked-int (bit-or (bit-shift-left a 24)
+ (bit-shift-left (mix r) 16)
+ (bit-shift-left (mix g) 8)
+ (mix b)))))))
+ out))
+
+(defn- stack [images]
+ (let [w (.getWidth ^BufferedImage (first images))
+ h (.getHeight ^BufferedImage (first images))
+ sheet (BufferedImage. w (* h (count images)) BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics sheet)]
+ (try
+ (.setColor g Color/WHITE)
+ (.fillRect g 0 0 w (* h (count images)))
+ (doseq [[i ^BufferedImage im] (map-indexed vector images)]
+ (.drawImage g im 0 (* i h) nil))
+ (finally (.dispose g)))
+ sheet))
+
+(deftest see-whether-a-grade-is-worth-having
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (let [base (contact-sheet (mapv #(compose-with-eyes pack manifest %) (range 4)))
+ ;; night-light knocks blue back hard and green a little
+ warm (grade base [1.0 0.96 0.82] 1.0)
+ ;; the same warmth with a touch more colour, since "pop" may be
+ ;; saturation rather than temperature
+ warm+ (grade base [1.0 0.96 0.82] 1.18)
+ ;; and saturation on its own, to separate the two
+ richer (grade base [1.0 1.0 1.0] 1.22)]
+ (write! (stack [base warm warm+ richer]) "grades")
+ (is (pos? (opaque-colour-count base))))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; Two things that make an eye colour readable: how much of the colour the ink
+;; keeps, and whether the colour was strong enough to begin with.
+;; ---------------------------------------------------------------------------
+
+(defn- richer
+ "Push a colour away from grey and up in value. The shipped presets are muted
+ mid-tones, which is a reasonable instinct for clothing and the wrong one for
+ an iris drawn in near-black ink."
+ ^Color [^Color c sat val]
+ (let [hsb (Color/RGBtoHSB (.getRed c) (.getGreen c) (.getBlue c) nil)]
+ (Color. (Color/HSBtoRGB (aget hsb 0)
+ (float (min 1.0 (* (aget hsb 1) sat)))
+ (float (min 1.0 (* (aget hsb 2) val)))))))
+
+(deftest compare-iris-floor-and-preset-strength
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (let [row (fn [floor sat val]
+ (binding [*iris-floor* floor]
+ (contact-sheet
+ (mapv (fn [n]
+ (let [p (nth palettes (mod n (count palettes)))]
+ (with-redefs [palettes (mapv #(update % :eyes richer sat val)
+ palettes)]
+ (compose-with-eyes pack manifest n))))
+ (range 4)))))]
+ (write! (stack [(row 0.30 1.0 1.0) ; what ships now
+ (row 0.55 1.0 1.0) ; lift the floor only
+ (row 0.75 1.0 1.0) ; lift it further
+ (row 0.75 1.35 1.15)]) ; and richer presets with it
+ "iris-strength")
+ (is true))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; Near and far are different tests, so run both.
+;; ---------------------------------------------------------------------------
+
+(defn- shrunk
+ "The portrait at thumbnail size -- what a glance actually sees."
+ ^BufferedImage [^BufferedImage src scale]
+ (let [w (int (* (.getWidth src) scale)) h (int (* (.getHeight src) scale))
+ out (BufferedImage. w h BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics out)]
+ (try
+ (.setRenderingHint g RenderingHints/KEY_INTERPOLATION
+ RenderingHints/VALUE_INTERPOLATION_BILINEAR)
+ (.drawImage g src 0 0 w h nil)
+ (finally (.dispose g)))
+ out))
+
+(deftest reconcile-near-and-far
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (let [variant (fn [floor gamma sat val]
+ (binding [*iris-floor* floor *iris-gamma* gamma]
+ (with-redefs [palettes (mapv #(update % :eyes richer sat val)
+ palettes)]
+ (compose-with-eyes pack manifest 0))))
+ candidates [["floor 0.55, no gamma (better up close)" (variant 0.55 1.0 1.0 1.0)]
+ ["floor 0.75 + richer (better at distance)" (variant 0.75 1.0 1.35 1.15)]
+ ["floor 0.55, gamma 0.5 + richer (the settled pair)" (variant 0.55 0.5 1.35 1.15)]
+ ["floor 0.45, gamma 0.4 + richer (more modelling still)" (variant 0.45 0.4 1.35 1.15)]]]
+ (doseq [[label img] candidates]
+ (write! img (str "near-" (-> label (.replaceAll "[^a-z0-9]+" "-")))))
+ ;; and the same four at a sixth the size, which is the glance test
+ (write! (contact-sheet (mapv (fn [[_ img]] (shrunk img 1.0)) candidates))
+ "near-row")
+ (write! (stack (mapv (fn [[_ img]] (shrunk img 0.22)) candidates))
+ "far-column")
+ (is true))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; The two viewing distances measured, so the choice is not a matter of taste.
+;;
+;; A glance integrates the iris into one average colour, so how strongly the
+;; eye reads across the room is its MEAN. A close look sees the shading inside
+;; it, so how modelled it looks is the SPREAD between its darkest and lightest
+;; ink. The floor moves both together -- lifting it raises the mean by squeezing
+;; the ramp -- which is why no single floor won at both distances.
+;;
+;; A gamma below 1 lifts the midtones, where the body of the iris sits, without
+;; touching the darkest ink, so the two can move apart.
+;; ---------------------------------------------------------------------------
+
+(defn- iris-stats
+ "Mean and spread of the coloured iris, over the region only.
+
+ Mean is the distance test. Spread is p90-p10 rather than min-max, because a
+ single antialiased pixel at either end would decide the answer."
+ [^BufferedImage img ^Area region]
+ (let [cov (coverage-mask region)
+ vals (persistent!
+ (reduce (fn [acc i]
+ (if (< 200 (bit-and (aget cov i) 0xff))
+ (let [x (rem i W) y (quot i W)
+ argb (.getRGB img x y)]
+ (if (pos? (bit-and (unsigned-bit-shift-right argb 24) 0xff))
+ (conj! acc (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff)
+ (bit-and (unsigned-bit-shift-right argb 8) 0xff)
+ (bit-and argb 0xff))
+ 765.0))
+ acc))
+ acc))
+ (transient []) (range (* W H))))
+ sorted (vec (sort vals))
+ n (count sorted)
+ at (fn [q] (nth sorted (min (dec n) (int (* q n)))))]
+ (when (pos? n)
+ {:n n
+ :mean (/ (reduce + sorted) (double n))
+ :spread (- (at 0.90) (at 0.10))})))
+
+(deftest gamma-separates-legibility-from-modelling
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (let [spec (iris-spec manifest)
+ ;; whichever eye asset this pack's first character uses
+ eye-file (first (filter #(get spec (.getName ^File %))
+ (mapcat #(assets-in pack %) pa/layer-order)))]
+ (if-not eye-file
+ (println "\n no placed iris in this pack -- skipping\n")
+ (let [region (iris-region (get spec (.getName ^File eye-file)))
+ art (scaled eye-file)
+ blue (Color. 0x1d 0x54 0xae)
+ measure (fn [floor gamma]
+ (binding [*iris-floor* floor *iris-gamma* gamma]
+ (iris-stats (colorize-iris art region blue) region)))
+ close (measure 0.55 1.0) ; better up close, said the eye
+ far (measure 0.75 1.0) ; better at distance, said the eye
+ both (measure 0.55 0.5)] ; the settled pair
+ (println (format "\n iris over %d px%n floor .55 gamma 1.0 mean %.3f spread %.3f%n floor .75 gamma 1.0 mean %.3f spread %.3f%n floor .55 gamma 0.5 mean %.3f spread %.3f%n"
+ (:n close)
+ (:mean close) (:spread close)
+ (:mean far) (:spread far)
+ (:mean both) (:spread both)))
+
+ (testing "the floor really does trade one for the other -- this is
+ the effect being worked around, and if it ever stops
+ holding the rest of this reasoning is void"
+ (is (> (:mean far) (:mean close))
+ "raising the floor lifts the mean, which is why 0.75 won at a distance")
+ (is (< (:spread far) (:spread close))
+ "and flattens the spread, which is why 0.55 won up close"))
+
+ (testing "gamma gets the distance legibility"
+ (is (>= (:mean both) (* 0.97 (:mean far)))
+ "as bright on average as the floor-0.75 render, which is the
+ one that read across the room"))
+
+ (testing "without paying for it in modelling"
+ (is (> (:spread both) (:spread far))
+ "more tonal range inside the iris than floor 0.75 leaves")
+ (is (> (:spread both) (:spread close))
+ "and MORE tonal range than floor 0.55 kept -- the row that
+ looked right up close -- so neither distance is being
+ traded away")))))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; Every eye style, at the settled floor and gamma, across the colour slots.
+;; Each style was given its own iris ellipse, pupil offset and lid curve by
+;; hand, so each needs looking at separately -- a setting that flatters the
+;; style with the biggest irises can still fail the one with the narrowest.
+;; ---------------------------------------------------------------------------
+
+(def ^:private eye-slot-colours
+ "The eye column of the richer presets, which is what will actually ship."
+ [["brown" (Color. 0x83 0x48 0x10)]
+ ["blue" (Color. 0x1d 0x54 0xae)]
+ ["green" (Color. 0x40 0x95 0x43)]
+ ["amber" (Color. 0xa8 0x90 0x24)]
+ ["red" (Color. 0xa8 0x37 0x37)]
+ ["teal" (Color. 0x37 0xa8 0xa8)]
+ ["violet" (Color. 0x8c 0x37 0xa8)]
+ ["pale" (Color. 0xef 0xd3 0x89)]])
+
+(defn- row
+ "Join images left to right at whatever size they actually are.
+
+ contact-sheet assumes every tile is a full WxH portrait, so handing it
+ cropped bands stranded them at the top of a tall white canvas and clipped
+ the last one off the right edge."
+ ^BufferedImage [images]
+ (let [ws (mapv #(.getWidth ^BufferedImage %) images)
+ h (apply max (mapv #(.getHeight ^BufferedImage %) images))
+ sheet (BufferedImage. (reduce + ws) h BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics sheet)]
+ (try
+ (.setColor g Color/WHITE)
+ (.fillRect g 0 0 (.getWidth sheet) h)
+ (loop [x 0 [^BufferedImage i & more] images]
+ (when i
+ (.drawImage g i (int x) 0 nil)
+ (recur (+ x (.getWidth i)) more)))
+ (finally (.dispose g)))
+ sheet))
+
+(defn- column
+ "Join images top to bottom at whatever size they actually are."
+ ^BufferedImage [images]
+ (let [w (apply max (mapv #(.getWidth ^BufferedImage %) images))
+ hs (mapv #(.getHeight ^BufferedImage %) images)
+ sheet (BufferedImage. w (reduce + hs) BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics sheet)]
+ (try
+ (.setColor g Color/WHITE)
+ (.fillRect g 0 0 w (.getHeight sheet))
+ (loop [y 0 [^BufferedImage i & more] images]
+ (when i
+ (.drawImage g i 0 (int y) nil)
+ (recur (+ y (.getHeight i)) more)))
+ (finally (.dispose g)))
+ sheet))
+
+(defn- eye-band
+ "The eye band of a portrait, at 2x, which is the only part worth looking at
+ when the question is about irises."
+ ^BufferedImage [^BufferedImage src]
+ (let [y0 (int (* H 0.28)) y1 (int (* H 0.44))
+ ;; the face sits in the middle; the hair either side is not the subject
+ x0 (int (* W 0.30)) x1 (int (* W 0.80))
+ bw (* (- x1 x0) 2) bh (* (- y1 y0) 2)
+ out (BufferedImage. bw bh BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics out)]
+ (try
+ (.setRenderingHint g RenderingHints/KEY_INTERPOLATION
+ RenderingHints/VALUE_INTERPOLATION_NEAREST_NEIGHBOR)
+ (.drawImage g src 0 0 bw bh x0 y0 x1 y1 nil)
+ (finally (.dispose g)))
+ out))
+
+(deftest every-eye-style-across-the-colour-slots
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (let [spec (iris-spec manifest)
+ styles (->> pa/layer-order
+ (mapcat #(assets-in pack %))
+ (filter #(get spec (.getName ^File %)))
+ vec)]
+ (if (empty? styles)
+ (println "\n no placed irises in this pack -- skipping\n")
+ (binding [*iris-floor* 0.55 *iris-gamma* 0.5]
+ (println (format "\n %d eye styles x %d colours, floor .55 gamma .5"
+ (count styles) (count eye-slot-colours)))
+ (dotimes [i (count styles)]
+ (let [nm (.getName ^File (nth styles i))]
+ ;; one row per style: every eye colour on the same face, so the
+ ;; only thing changing across the row is the slot
+ (write! (row
+ (mapv (fn [[_ c]]
+ (eye-band (compose-with-eyes pack manifest 0 i c)))
+ eye-slot-colours))
+ (str "style-" (inc i) "-" (s/replace nm #"\.png$" "") "-colours"))
+ ;; and the same style on each of the four base faces, because
+ ;; skin and hair around it change how the iris reads
+ (write! (row
+ (mapv #(eye-band (compose-with-eyes pack manifest % i nil))
+ (range 4)))
+ (str "style-" (inc i) "-" (s/replace nm #"\.png$" "") "-faces"))
+ ;; and the same row at a stronger gamma. The styles do not all
+ ;; want the same number: style 3's eyes are half-lidded and its
+ ;; iris is under 40% the area of style 1's, so it has far fewer
+ ;; pixels to say a colour with. Dropping the gamma lifts its
+ ;; mean AND its spread, where raising the floor lifts the mean
+ ;; and flattens it -- so this is the knob to tweak per style.
+ (write! (column
+ [(row (mapv (fn [[_ c]]
+ (binding [*iris-gamma* 0.5]
+ (eye-band (compose-with-eyes pack manifest 0 i c))))
+ eye-slot-colours))
+ (row (mapv (fn [[_ c]]
+ (binding [*iris-gamma* 0.35]
+ (eye-band (compose-with-eyes pack manifest 0 i c))))
+ eye-slot-colours))])
+ (str "style-" (inc i) "-" (s/replace nm #"\.png$" "")
+ "-gamma-50-over-35"))
+ (println (format " style %d: %s" (inc i) nm))))
+ ;; the three styles side by side on one colour, which is the
+ ;; comparison that shows whether one region needs re-authoring
+ (write! (row
+ (mapv #(eye-band (compose-with-eyes pack manifest 0 %
+ (Color. 0x1d 0x54 0xae)))
+ (range (count styles))))
+ "all-styles-one-colour")
+ (println)
+ (is (= 3 (count styles))
+ "this pack authored three eye styles; a style losing its iris
+ spec would silently drop out of these sheets otherwise"))))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; One setting, three styles. The floor and gamma were tuned on the style with
+;; the largest irises, and a style whose eyes are half-lidded has far fewer
+;; pixels to say "blue" with -- so the tuning has to be checked against the
+;; narrowest one, not the most flattering.
+;; ---------------------------------------------------------------------------
+
+(deftest the-settled-pair-checked-against-every-style
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (let [spec (iris-spec manifest)
+ styles (->> pa/layer-order
+ (mapcat #(assets-in pack %))
+ (filter #(get spec (.getName ^File %)))
+ vec)]
+ (if (empty? styles)
+ (println "\n no placed irises -- skipping\n")
+ (let [blue (Color. 0x1d 0x54 0xae)
+ stats (binding [*iris-floor* 0.55 *iris-gamma* 0.5]
+ (mapv (fn [^File f]
+ (let [region (iris-region (get spec (.getName f)))
+ st (iris-stats (colorize-iris (scaled f) region blue)
+ region)]
+ (assoc st :name (.getName f))))
+ styles))]
+ (println "\n at floor .55 gamma .5, one eye colour, per style:")
+ (doseq [{:keys [name n mean spread]} stats]
+ (println (format " %-18s %4d px mean %.3f spread %.3f"
+ name n mean spread)))
+ (println)
+
+ (testing "every style gets enough iris to carry a colour at all.
+ Style 3's eyes are half-lidded, so this is the one that
+ decides whether the setting is usable -- a threshold that
+ only style 1 has to clear is not a threshold."
+ (doseq [{:keys [name n]} stats]
+ (is (> n 150)
+ (str name " has " n " px of iris; below roughly 150 the "
+ "colour stops reading at portrait size"))))
+
+ (testing "and none of them is washed out by the shared setting"
+ (doseq [{:keys [name mean spread]} stats]
+ (is (> mean 0.20) (str name " mean " mean " -- iris reads grey"))
+ (is (> spread 0.05)
+ (str name " spread " spread " -- iris is a flat slab")))))))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+
+;; ---------------------------------------------------------------------------
+;; The mouths.
+;;
+;; Drawing the mouth layer untinted is what stopped the teeth coming out red.
+;; It is also why the lipped mouth comes out silver: EVERY mouth in this pack
+;; is drawn in greys, so "carries its own colour" was never the thing that
+;; separated them. Teeth should stay neutral and lips should take a hue, and
+;; which is which is a fact about the asset -- the same kind of per-asset fact
+;; as the iris region, and it wants the same mechanism.
+;; ---------------------------------------------------------------------------
+
+(defn- max-saturation
+ "The most saturated opaque pixel in an asset, 0..255. Zero means the artist
+ drew it in greys, with no hue of its own."
+ [^BufferedImage img]
+ (reduce max 0
+ (for [x (range (.getWidth img)) y (range (.getHeight img))
+ :let [argb (.getRGB img x y)]
+ :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff))
+ :let [r (bit-and (unsigned-bit-shift-right argb 16) 0xff)
+ g (bit-and (unsigned-bit-shift-right argb 8) 0xff)
+ b (bit-and argb 0xff)]]
+ (- (max r g b) (min r g b)))))
+
+(def ^:private lip-colours
+ [["bare" (Color. 0xc9 0x8d 0x82)]
+ ["rose" (Color. 0xc4 0x6a 0x6a)]
+ ["berry" (Color. 0x9b 0x3c 0x55)]
+ ["plum" (Color. 0x6e 0x33 0x4e)]
+ ["coral" (Color. 0xd8 0x7a 0x5c)]])
+
+(defn- compose-with-mouth
+ "The face on base `n`, mouth pinned to `mouth-idx`. With `lip-colour` the
+ mouth goes through the colorize instead of being drawn raw."
+ [^File pack manifest n mouth-idx lip-colour]
+ (let [spec (iris-spec manifest)
+ palette (nth palettes (mod n (count palettes)))
+ canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [choices (seq (assets-in pack layer-key))]
+ (let [mouth? (= :mouth layer-key)
+ ^File f (nth choices (mod (if mouth? mouth-idx n) (count choices)))]
+ (when-let [art (scaled f)]
+ (let [slot (get pa/color-slots layer-key)
+ sp (get spec (.getName f))]
+ (cond
+ sp (.drawImage g (colorize-iris art (iris-region sp)
+ (get palette :eyes)) 0 0 nil)
+ (and mouth? lip-colour)
+ (.drawImage g (colorize-through (whole-asset-coverage art)
+ art lip-colour) 0 0 nil)
+ (nil? slot) (.drawImage g art 0 0 nil)
+ :else (.drawImage g ^BufferedImage
+ (multiplied art (get palette slot)) 0 0 nil)))))))
+ (finally (.dispose g)))
+ canvas))
+
+(defn- mouth-band
+ ^BufferedImage [^BufferedImage src]
+ (let [y0 (int (* H 0.42)) y1 (int (* H 0.56))
+ x0 (int (* W 0.38)) x1 (int (* W 0.72))
+ bw (* (- x1 x0) 3) bh (* (- y1 y0) 3)
+ out (BufferedImage. bw bh BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics out)]
+ (try
+ (.setRenderingHint g RenderingHints/KEY_INTERPOLATION
+ RenderingHints/VALUE_INTERPOLATION_NEAREST_NEIGHBOR)
+ (.drawImage g src 0 0 bw bh x0 y0 x1 y1 nil)
+ (finally (.dispose g)))
+ out))
+
+(deftest the-mouths-as-drawn
+ (if-let [pack (pack-dir)]
+ (let [manifest (read-manifest pack)
+ mouths (vec (assets-in pack :mouth))]
+ (if (or (nil? manifest) (empty? mouths))
+ (println "\n no mouths or no manifest -- skipping\n")
+ (do
+ (println "\n mouth assets:")
+ (doseq [^File f mouths]
+ (println (format " %-18s max saturation %d" (.getName f)
+ (max-saturation (scaled f)))))
+ (println)
+ (write! (row (mapv #(mouth-band (compose-with-mouth pack manifest 0 % nil))
+ (range (count mouths))))
+ "mouths-untinted")
+
+ (testing "every mouth in this pack is drawn in greys, so 'carries its
+ own colour' cannot be what decides whether to tint one --
+ which is the flaw in the per-LAYER rule that is shipping"
+ (doseq [^File f mouths]
+ (is (< (max-saturation (scaled f)) 8)
+ (str (.getName f) " is drawn neutral"))))
+
+ ;; the lipped mouth through the SAME function the irises use, over its
+ ;; whole area. A luminance ramp with no hue is precisely what that
+ ;; function consumes, so no second mechanism is needed for lips.
+ (when-let [^File lips (first (filter #(re-find #"02" (.getName ^File %)) mouths))]
+ (binding [*iris-floor* 0.55 *iris-gamma* 0.5]
+ (write! (row (mapv (fn [[_ c]]
+ (mouth-band (compose-with-mouth
+ pack manifest 0 (.indexOf mouths lips) c)))
+ lip-colours))
+ "mouth-02-lips-colorized")
+ ;; where in the ramp the art actually sits, which is the thing
+ ;; that decides the parameters
+ (let [art (scaled lips)
+ ls (sort (for [x (range W) y (range H)
+ :let [argb (.getRGB art x y)]
+ :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff))]
+ (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff)
+ (bit-and (unsigned-bit-shift-right argb 8) 0xff)
+ (bit-and argb 0xff))
+ 765.0)))
+ n (count ls)
+ at (fn [q] (nth ls (min (dec n) (int (* q n)))))]
+ (println (format " lips luminance: p10 %.2f median %.2f p90 %.2f (%d px, %.0f%% above the ramp midpoint)"
+ (at 0.10) (at 0.50) (at 0.90) n
+ (* 100.0 (/ (count (filter #(> % 0.5) ls)) (double n))))))
+ (doseq [gm [0.5 1.0 1.6 2.2]]
+ (binding [*iris-gamma* gm]
+ (write! (row (mapv (fn [[_ c]]
+ (mouth-band (compose-with-mouth
+ pack manifest 0 (.indexOf mouths lips) c)))
+ lip-colours))
+ (format "mouth-02-lips-gamma-%.1f" gm))))
+ (testing "and the lips take a real hue when put through it"
+ (is (> (max-saturation
+ (colorize-through (whole-asset-coverage (scaled lips))
+ (scaled lips) (Color. 0x9b 0x3c 0x55)))
+ 40)
+ "colorized lips carry saturation the raw asset had none of"))
+
+ (testing "the gamma has to go the OTHER WAY for lips than for
+ irises, and that is not a preference -- the two are
+ drawn at opposite ends of the tonal range. The irises
+ are near-black, so they need a floor and a gamma below
+ 1 to lift them off it. The lips are drawn light, so
+ most of them sit in the half of the ramp that runs
+ toward WHITE, and lifting them further is what washed
+ them out. One global pair of numbers cannot serve both,
+ which is the case for putting them on the asset."
+ (let [median (fn [^BufferedImage art]
+ (let [ls (sort (for [x (range W) y (range H)
+ :let [argb (.getRGB art x y)]
+ :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff))]
+ (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff)
+ (bit-and (unsigned-bit-shift-right argb 8) 0xff)
+ (bit-and argb 0xff))
+ 765.0)))]
+ (when (seq ls) (nth ls (quot (count ls) 2)))))
+ eye-art (first (keep (fn [^File f]
+ (when (get (iris-spec manifest) (.getName f))
+ (scaled f)))
+ (mapcat #(assets-in pack %) pa/layer-order)))]
+ (is (> (median (scaled lips)) 0.55)
+ "the lips are drawn LIGHT, above the ramp's midpoint")
+ (when eye-art
+ (is (< (median eye-art) 0.55)
+ "and the eyes are drawn dark, below it -- so a gamma
+ that helps one hurts the other")))))))))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; The whole thing, composed by the REGISTRY's own rules.
+;;
+;; Everything above tried one decision at a time. This asks portrait-assets
+;; what to do with each asset -- render-mode, slot-for-asset, tint-gamma -- and
+;; does exactly that, so what comes out is the specification for the renderer
+;; port rather than another opinion about it. If these busts are right, the
+;; port's job is to make three renderers agree with this picture.
+;; ---------------------------------------------------------------------------
+
+(def ^:private registry-asset-by-file
+ "Every registry asset, keyed by the filename the pack uses. The pack and the
+ registry name the same 28 files, which is what lets the harness run the
+ shipped rules instead of restating them."
+ (delay
+ (into {}
+ (for [layer-key pa/layer-order
+ asset (pa/assets-for-layer layer-key)]
+ [[layer-key (last (s/split (:asset/url asset) #"/"))] asset]))))
+
+(def ^:private full-palettes
+ "Hair / skin / eyes / shirt / lips, as the slots would be filled in."
+ [{:hair "#5c3a1e" :skin "#e8c69c" :eyes "#1d54ae" :shirt "#3a4a5c" :lips "#c46a6a"}
+ {:hair "#2e180c" :skin "#8d5a3c" :eyes "#834810" :shirt "#7a94b8" :lips "#9b3c55"}
+ {:hair "#e1a243" :skin "#f7e0c5" :eyes "#409543" :shirt "#633737" :lips "#c98d82"}
+ {:hair "#9b3d2e" :skin "#cd986e" :eyes "#8c37a8" :shirt "#3b6137" :lips "#6e334e"}
+ {:hair "#d4d4d4" :skin "#6e4622" :eyes "#37a8a8" :shirt "#303030" :lips "#a85c4a"}
+ {:hair "#813872" :skin "#f2ddc4" :eyes "#a83737" :shirt "#d8cdae" :lips "#d87a5c"}])
+
+(defn- hex->awt ^Color [hex]
+ (Color. (Integer/parseInt (subs hex 1 3) 16)
+ (Integer/parseInt (subs hex 3 5) 16)
+ (Integer/parseInt (subs hex 5 7) 16)))
+
+(defn- compose-by-registry
+ "One bust, every layer handled the way portrait-assets says to handle it."
+ [^File pack manifest palette picks]
+ (let [spec (iris-spec manifest)
+ canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [choices (seq (assets-in pack layer-key))]
+ (let [^File f (nth choices (mod (get picks layer-key 0) (count choices)))
+ nm (.toLowerCase (.getName f))
+ asset (get @registry-asset-by-file [layer-key nm])
+ mode (pa/render-mode layer-key asset)
+ slot (pa/slot-for-asset layer-key asset)
+ colour (some-> (get palette slot) hex->awt)]
+ (when-let [art (scaled f)]
+ (cond
+ ;; already coloured by the illustrator: teeth, bare lines
+ (= :as-drawn mode) (.drawImage g art 0 0 nil)
+
+ ;; a hueless ramp mapped through the slot colour. The region is
+ ;; the authored iris for eyes, and the whole asset for lips,
+ ;; which are nothing but the thing being coloured.
+ (and (= :colorize mode) colour)
+ (binding [*iris-gamma* (pa/tint-gamma layer-key asset)]
+ (.drawImage g (colorize-through
+ (if-let [sp (get spec (.getName f))]
+ (coverage-mask (iris-region sp))
+ (whole-asset-coverage art))
+ art colour)
+ 0 0 nil))
+
+ colour (.drawImage g ^BufferedImage (multiplied art colour) 0 0 nil)
+ :else (.drawImage g art 0 0 nil))))))
+ (finally (.dispose g)))
+ canvas))
+
+(deftest the-range-of-busts
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (let [n-eyes (count (assets-in pack :eyes))
+ n-mouth (count (assets-in pack :mouth))
+ ;; the lipped mouth, so the lips slot is exercised
+ lip-idx (first (keep-indexed
+ (fn [i ^File f]
+ (when (= :lips (pa/slot-for-asset
+ :mouth (get @registry-asset-by-file
+ [:mouth (.toLowerCase (.getName f))])))
+ i))
+ (assets-in pack :mouth)))]
+ (binding [*iris-floor* 0.55]
+ ;; one sheet per eye style: six palettes, lips on
+ (dotimes [e n-eyes]
+ (write! (row (mapv (fn [p]
+ (compose-by-registry pack manifest p
+ {:eyes e :mouth (or lip-idx 0)}))
+ full-palettes))
+ (str "busts-eyes-0" (inc e) "-with-lips")))
+ ;; and one showing every mouth on one palette, so the teeth can be
+ ;; checked against the lips they are not
+ (write! (row (mapv (fn [m]
+ (compose-by-registry pack manifest (first full-palettes)
+ {:eyes 0 :mouth m}))
+ (range n-mouth)))
+ "busts-every-mouth")
+ ;; the same range at a sixth scale, which is how a summary shows it
+ (write! (row (mapv (fn [p]
+ (shrunk (compose-by-registry
+ pack manifest p
+ {:eyes 0 :mouth (or lip-idx 0)})
+ 0.3))
+ full-palettes))
+ "busts-thumbnail-scale"))
+
+ (testing "the lipped mouth really did go down the colorize path -- if
+ the registry lookup misses, every bust still renders and the
+ only sign is lips that came out grey"
+ (is (some? lip-idx) "a mouth asset in this pack claims the lips slot")
+ ;; not a count: a magic number breaks the next time an asset is
+ ;; added. Check the thing the sentence actually claims.
+ (doseq [layer-key pa/layer-order
+ ^File f (assets-in pack layer-key)]
+ (is (contains? @registry-asset-by-file
+ [layer-key (.toLowerCase (.getName f))])
+ (str (.getName f) " has no registry asset"))))
+
+ (testing "and the teeth did not take the lip colour"
+ (let [smile-idx (first (keep-indexed
+ (fn [i ^File f]
+ (when (re-find #"03" (.getName f)) i))
+ (assets-in pack :mouth)))
+ bust (compose-by-registry pack manifest
+ {:lips "#ff0000" :skin "#e8c69c"}
+ {:eyes 0 :mouth smile-idx})
+ reds (count (for [x (range W) y (range H)
+ :let [argb (.getRGB bust x y)]
+ :when (and (= 255 (bit-and (unsigned-bit-shift-right argb 24) 0xff))
+ (> (bit-and (unsigned-bit-shift-right argb 16) 0xff) 200)
+ (< (bit-and (unsigned-bit-shift-right argb 8) 0xff) 60)
+ (< (bit-and argb 0xff) 60))]
+ 1))]
+ (is (zero? reds)
+ (str "a pure-red lip colour put " reds " red pixels on a face "
+ "whose mouth is teeth")))))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; The actual range, which means varying the FEATURES and not just the palette.
+;;
+;; the-range-of-busts above pinned every layer but the eyes and the mouth, so
+;; six panels of it were six recolours of one face. The pack is 3x3x3x2x2x3x3x3
+;; x3x3 = 8748 combinations, and none of that was visible.
+;;
+;; These portraits come out of pa/compose-for-seed -- the app's own randomizer,
+;; the one behind the Randomize button -- so the sheet shows what a person
+;; would actually get rather than what the harness felt like showing.
+;; ---------------------------------------------------------------------------
+
+(defn- file-for-asset
+ "The pack file backing a registry asset, or nil when the pack lacks it."
+ ^File [^File pack layer-key asset-id]
+ (when-let [asset (pa/asset-by-id layer-key asset-id)]
+ (let [want (.toLowerCase ^String (last (s/split (:asset/url asset) #"/")))]
+ (first (filter #(= want (.toLowerCase (.getName ^File %)))
+ (assets-in pack layer-key))))))
+
+(defn- palette-for-seed
+ "A colour per slot, drawn from the presets the picker offers. Same seed, same
+ palette, on either platform -- it is the registry's own PRNG."
+ [seed]
+ (let [r (rnd/mulberry32 (rnd/seed->int (str seed "-colors")))]
+ (into {} (for [slot pa/color-slot-order
+ :let [choices (pa/color-presets slot)]
+ :when (seq choices)]
+ [slot (nth choices (int (Math/floor (* (r) (count choices)))))]))))
+
+(defn- compose-seeded
+ "One bust for `seed`: features from the app's randomizer, colours from the
+ presets, every layer routed by the registry's own render-mode."
+ [^File pack manifest seed]
+ (let [layers (pa/compose-for-seed seed)
+ palette (palette-for-seed seed)
+ spec (iris-spec manifest)
+ canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [^File f (some->> (get-in layers [layer-key :asset/id])
+ (file-for-asset pack layer-key))]
+ (let [asset (pa/asset-by-id layer-key (get-in layers [layer-key :asset/id]))
+ mode (pa/render-mode layer-key asset)
+ colour (some-> (get palette (pa/slot-for-asset layer-key asset)) hex->awt)]
+ (when-let [art (scaled f)]
+ (cond
+ (= :as-drawn mode) (.drawImage g art 0 0 nil)
+ (and (= :colorize mode) colour)
+ (binding [*iris-gamma* (pa/tint-gamma layer-key asset)]
+ (.drawImage g (colorize-through
+ (if-let [sp (get spec (.getName f))]
+ (coverage-mask (iris-region sp))
+ (whole-asset-coverage art))
+ art colour)
+ 0 0 nil))
+ colour (.drawImage g ^BufferedImage (multiplied art colour) 0 0 nil)
+ :else (.drawImage g art 0 0 nil))))))
+ (finally (.dispose g)))
+ canvas))
+
+(def ^:private gallery-seeds
+ (mapv #(str "bust-" %) (range 24)))
+
+(deftest a-gallery-of-actually-different-characters
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (binding [*iris-floor* 0.55]
+ (write! (column (mapv (fn [chunk] (row (mapv #(compose-seeded pack manifest %) chunk)))
+ (partition 6 gallery-seeds)))
+ "gallery")
+ (write! (column (mapv (fn [chunk]
+ (row (mapv #(shrunk (compose-seeded pack manifest %) 0.32)
+ chunk)))
+ (partition 8 gallery-seeds)))
+ "gallery-thumbnails")
+
+ (testing "the seeds really do produce different FACES, not one face in
+ different colours. This is the check the previous sheet did
+ not have, which is why six panels of it were one character."
+ (let [picks (mapv pa/compose-for-seed gallery-seeds)
+ distinct-combos (count (distinct picks))]
+ (println (format "\n %d seeds -> %d distinct feature combinations"
+ (count gallery-seeds) distinct-combos))
+ (is (> distinct-combos (* 0.8 (count gallery-seeds)))
+ (str "only " distinct-combos " of " (count gallery-seeds)
+ " seeds gave a different set of features"))
+ ;; only layers that HAVE a choice
+ (doseq [layer-key pa/layer-order
+ :when (> (pa/asset-count-for-layer layer-key) 1)]
+ (let [chosen (distinct (map #(get-in % [layer-key :asset/id]) picks))]
+ (is (> (count chosen) 1)
+ (str layer-key " has "
+ (pa/asset-count-for-layer layer-key)
+ " assets but never varied across " (count gallery-seeds)
+ " seeds -- that layer is pinned, which is the bug this
+ test exists to catch"))))))
+
+ (testing "and different colours too, or the gallery is one palette"
+ (let [pals (map palette-for-seed gallery-seeds)]
+ (doseq [slot pa/color-slot-order]
+ (is (> (count (distinct (map #(get % slot) pals))) 1)
+ (str slot " is the same colour on every bust"))))))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; I claimed in review that the shirt straps "stay near-black across all six
+;; palettes" and that this was the art. It was not: five of the six shirt
+;; colours I had picked were dark, and the asset has no straps -- what reads as
+;; one is the hair-back layer over the shoulder. This measures the thing rather
+;; than describing it.
+(deftest a-pale-shirt-colour-produces-a-pale-shirt
+ (when-let [pack (pack-dir)]
+ (when-let [manifest (read-manifest pack)]
+ (doseq [[label hex] [["near-white" "#f4f1ea"] ["mid" "#7a94b8"] ["dark" "#303030"]]]
+ (doseq [i (range (count (assets-in pack :shirt)))]
+ (let [^File f (nth (assets-in pack :shirt) i)
+ art (scaled f)
+ out (multiplied art (hex->awt hex))
+ ls (sort (for [x (range W) y (range H)
+ :let [argb (.getRGB out x y)]
+ :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff))]
+ (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff)
+ (bit-and (unsigned-bit-shift-right argb 8) 0xff)
+ (bit-and argb 0xff))
+ 765.0)))
+ n (count ls)]
+ (when (pos? n)
+ (println (format " shirt %d @ %-10s p10 %.2f median %.2f p90 %.2f (%d px)"
+ (inc i) label
+ (nth ls (int (* 0.10 n))) (nth ls (quot n 2))
+ (nth ls (int (* 0.90 n))) n))
+ (when (= "near-white" label)
+ (is (> (nth ls (quot n 2)) 0.40)
+ (str "shirt " (inc i) " at a near-white colour came out at "
+ (format "%.2f" (nth ls (quot n 2)))
+ " -- the tint is not reaching it")))))))
+ (write! (row (mapv (fn [hex]
+ (let [^File f (first (assets-in pack :shirt))]
+ (multiplied (scaled f) (hex->awt hex))))
+ ["#f4f1ea" "#d8cdae" "#7a94b8" "#303030"]))
+ "shirt-across-lightness"))))
+
+;; ---------------------------------------------------------------------------
+;; Two busts with the same bangs and the same hair-back, one of which shows a
+;; strip of scalp between the bangs and the ear. Different head, hair-front and
+;; ear assets -- so either one head is wider than the hair drawn to cover it,
+;; or one hair-front covers less. Marking the head layer says which.
+;; ---------------------------------------------------------------------------
+
+(defn- compose-marking
+ "The bust for `seed`, with `mark-layer` painted a flat signal colour so the
+ pixels it is responsible for are unmistakable."
+ [^File pack manifest seed mark-layer]
+ (let [layers (pa/compose-for-seed seed)
+ palette (palette-for-seed seed)
+ spec (iris-spec manifest)
+ canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)]
+ (try
+ (doseq [layer-key pa/layer-order]
+ (when-let [^File f (some->> (get-in layers [layer-key :asset/id])
+ (file-for-asset pack layer-key))]
+ (let [asset (pa/asset-by-id layer-key (get-in layers [layer-key :asset/id]))
+ mode (pa/render-mode layer-key asset)
+ colour (some-> (get palette (pa/slot-for-asset layer-key asset)) hex->awt)]
+ (when-let [art (scaled f)]
+ (cond
+ (= layer-key mark-layer)
+ (.drawImage g ^BufferedImage (multiplied art (Color. 0xff 0x00 0xff)) 0 0 nil)
+ (= :as-drawn mode) (.drawImage g art 0 0 nil)
+ (and (= :colorize mode) colour)
+ (binding [*iris-gamma* (pa/tint-gamma layer-key asset)]
+ (.drawImage g (colorize-through
+ (if-let [sp (get spec (.getName f))]
+ (coverage-mask (iris-region sp))
+ (whole-asset-coverage art))
+ art colour)
+ 0 0 nil))
+ colour (.drawImage g ^BufferedImage (multiplied art colour) 0 0 nil)
+ :else (.drawImage g art 0 0 nil))))))
+ (finally (.dispose g)))
+ canvas))
+
+(defn- opaque-bounds
+ "[x0 y0 x1 y1] of an asset's opaque pixels, or nil."
+ [^BufferedImage img]
+ (let [src (px img)]
+ (loop [i 0 x0 W y0 H x1 -1 y1 -1]
+ (if (= i (* W H))
+ (when (<= 0 x1) [x0 y0 x1 y1])
+ (if (pos? (bit-and (unsigned-bit-shift-right (aget src i) 24) 0xff))
+ (let [x (rem i W) y (quot i W)]
+ (recur (inc i) (min x0 x) (min y0 y) (max x1 x) (max y1 y)))
+ (recur (inc i) x0 y0 x1 y1))))))
+
+(deftest which-layer-leaves-the-scalp-showing
+ (if-let [pack (pack-dir)]
+ (if-let [manifest (read-manifest pack)]
+ (binding [*iris-floor* 0.55]
+ (doseq [seed ["bust-19" "bust-20"]]
+ (write! (row [(compose-seeded pack manifest seed)
+ (compose-marking pack manifest seed :head)
+ (compose-marking pack manifest seed :hair-front)])
+ (str "scalp-" seed)))
+
+ ;; the numbers behind it: how wide each head is, and how wide the
+ ;; hair-front drawn over it reaches
+ (println "\n widths of the pieces involved:")
+ (doseq [layer-key [:head :hair-front :bangs :ears]]
+ (doseq [^File f (assets-in pack layer-key)]
+ (when-let [[x0 y0 x1 y1] (opaque-bounds (scaled f))]
+ (println (format " %-12s %-20s x %3d..%3d (%3d wide) y %3d..%3d"
+ (name layer-key) (.getName f) x0 x1 (- x1 x0) y0 y1)))))
+ (println)
+ (is true))
+ (println "\n no manifest -- skipping\n"))
+ (println "\n ORCPUB_PACK not set -- skipping.\n")))
+
+;; ---------------------------------------------------------------------------
+;; Exposed scalp.
+;;
+;; One gallery bust showed an island of skin between the bangs and the side
+;; lock. It is not a tinting or a z-order fault: hair-front 01 is a crown cap
+;; 142px wide and hair-front 02 is a side lock 231px wide that does not cover
+;; the crown, so some combinations leave the top of the head bare.
+;;
+;; Only five layers can cover the skull, so the space is 3 heads x 2 hair-bits
+;; x 2 hair-back x 3 hair-front x 3 bangs = 108, small enough to check ALL of
+;; it rather than the one bust that happened to be looked at.
+;; ---------------------------------------------------------------------------
+
+(def ^:private skull-bottom
+ "Below this the head is a face. Bare skin there is a forehead, not a hole."
+ (int (* H 0.30)))
+
+(defn- exposed-head-mask
+ "Head pixels, above the brow, that no hair layer covers."
+ ^booleans [^File head ^File bits ^File back ^File front ^File bangs]
+ (let [h (px (scaled head))
+ covers (mapv #(px (scaled %)) (remove nil? [bits back front bangs]))
+ out (boolean-array (* W H))]
+ (dotimes [y skull-bottom]
+ (dotimes [x W]
+ (let [i (+ x (* y W))]
+ (when (and (pos? (bit-and (unsigned-bit-shift-right (aget h i) 24) 0xff))
+ (not-any? (fn [^ints c]
+ (pos? (bit-and (unsigned-bit-shift-right (aget c i) 24) 0xff)))
+ covers))
+ (aset out i true)))))
+ out))
+
+(defn- island-px
+ "Exposed head that does NOT reach the face -- skin surrounded by hair.
+
+ Counting exposed pixels outright was the wrong measure and said so loudly:
+ it called a short fringe the worst case in the pack at 3899px, because a
+ high fringe shows more FOREHEAD, and a forehead is supposed to be skin.
+ What reads as a fault is an ISLAND -- a patch with hair on every side and no
+ path down to the face. So the exposed region is flooded from the brow line,
+ and whatever the flood cannot reach is the defect."
+ [^booleans exposed]
+ (let [seen (boolean-array (* W H))
+ stack (java.util.ArrayDeque.)]
+ ;; every exposed pixel on the brow line is forehead, and so is anything
+ ;; connected to one
+ (dotimes [x W]
+ (let [i (+ x (* (dec skull-bottom) W))]
+ (when (and (aget exposed i) (not (aget seen i)))
+ (aset seen i true)
+ (.push stack (int i)))))
+ (while (not (.isEmpty stack))
+ (let [i (int (.pop stack))
+ x (rem i W) y (quot i W)]
+ (doseq [[dx dy] [[-1 0] [1 0] [0 -1] [0 1]]]
+ (let [nx (+ x dx) ny (+ y dy)]
+ (when (and (< -1 nx W) (< -1 ny skull-bottom))
+ (let [ni (+ nx (* ny W))]
+ (when (and (aget exposed ni) (not (aget seen ni)))
+ (aset seen ni true)
+ (.push stack (int ni)))))))))
+ (count (for [i (range (* W H))
+ :when (and (aget exposed i) (not (aget seen i)))]
+ 1))))
+
+(defn- exposed-scalp-px
+ [^File head ^File bits ^File back ^File front ^File bangs]
+ (island-px (exposed-head-mask head bits back front bangs)))
+
+(deftest look-at-the-worst-scalp-combination
+ (when-let [pack (pack-dir)]
+ (let [pick (fn [layer-key nm] (first (filter #(= nm (.getName ^File %))
+ (assets-in pack layer-key))))
+ render (fn [bangs-name front-name]
+ (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB)
+ g (.createGraphics canvas)]
+ (try
+ (doseq [[lk ^File f] [[:hair-bits (first (assets-in pack :hair-bits))]
+ [:hair-back (first (assets-in pack :hair-back))]
+ [:head (pick :head "L2_head_01.png")]
+ [:hair-front (pick :hair-front front-name)]
+ [:bangs (pick :bangs bangs-name)]]]
+ (when-let [art (scaled f)]
+ (.drawImage g ^BufferedImage
+ (multiplied art (if (= lk :head)
+ (Color. 0xff 0x00 0xff)
+ (Color. 0xdd 0xdd 0xdd)))
+ 0 0 nil)))
+ (finally (.dispose g)))
+ canvas))]
+ ;; left: a SHORT FRINGE, which shows more forehead and is not a fault --
+ ;; counting bare pixels called this the worst case in the pack at 3899px,
+ ;; which is how the area metric announced it was measuring the wrong
+ ;; thing. middle: the real one, an island with hair all round it.
+ ;; right: a fringe that meets the lock, and no gap at all.
+ (write! (row [(render "L9_bangs_01.png" "L4_hair_front_02.png")
+ (render "L9_bangs_03.png" "L4_hair_front_02.png")
+ (render "L9_bangs_02.png" "L4_hair_front_02.png")])
+ "scalp-forehead-vs-island")
+ (is true))))
+
+;; ---------------------------------------------------------------------------
+;; The generated scalp that once covered these islands is retired (October
+;; 2026): hair front 02 was patched in the art to meet bangs 03, and the
+;; slivers left between bangs 01's strand tips read as a fringe. The code that
+;; carved and wrote it went with it; the ledger above keeps the art honest.
+;; ---------------------------------------------------------------------------
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 2d4992e1a..78a6bc9da 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -58,6 +58,16 @@ services:
# accept unverified accounts; this is how you opt into the weaker mode.
ALLOW_UNVERIFIED_REGISTRATION: ${ALLOW_UNVERIFIED_REGISTRATION:-}
LOAD_HOMEBREW_URL: ${LOAD_HOMEBREW_URL:-}
+ # Portrait artists' credits and accounts, the key that encrypts preferred names, and the
+ # site address artist emails link to. See .env.example.
+ PORTRAIT_ARTISTS: ${PORTRAIT_ARTISTS:-}
+ PROFILE_ENCRYPTION_KEYS: ${PROFILE_ENCRYPTION_KEYS:-}
+ APP_URL: ${APP_URL:-}
+ EMAIL_ADMIN_TO: ${EMAIL_ADMIN_TO:-}
+ # Where drawn shared-link portraits are kept. Mount a shared volume here to draw each once
+ # for every container (a volume at /var/lib/orcpub/portraits, with this set to that path);
+ # without one, each container keeps its own under /tmp.
+ PORTRAIT_STORE_DIR: ${PORTRAIT_STORE_DIR:-}
depends_on:
datomic:
condition: service_healthy
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 149930a38..6e96b78a5 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -93,6 +93,10 @@ ENV TZ=${TZ}
RUN lein run -m figwheel.main -- --build-once prod && \
test -f resources/public/js/compiled/orcpub.js
+# Step 1b: strand fields for any hair art without one, written beside the art so
+# the jar carries them (orcpub.portrait-pack.strands). A deploy needs only the art.
+RUN timeout 300 lein run -m orcpub.portrait-pack.strands
+
# Step 2: AOT compile (compilation finishes, subprocess hangs, timeout kills)
RUN timeout 300 lein with-profile uberjar,uberjar-package compile || true; \
test -f target/classes/orcpub/server__init.class || exit 1
@@ -114,6 +118,9 @@ COPY --from=app-builder /orcpub/target/orcpub.jar /orcpub.jar
# Run as non-root. App only writes to /tmp (world-writable by default).
RUN addgroup -S app && adduser -S -G app app
+# A home for kept portrait drawings (PORTRAIT_STORE_DIR), owned by the app's user, so a volume
+# mounted here inherits that ownership and the app can write to it.
+RUN mkdir -p /var/lib/orcpub/portraits && chown -R app:app /var/lib/orcpub
USER app
ENTRYPOINT ["java", "-jar"]
diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md
index dcba517e8..070a4a2f0 100644
--- a/docs/ENVIRONMENT.md
+++ b/docs/ENVIRONMENT.md
@@ -48,6 +48,8 @@ See [PDF-EXPORT-CAPACITY.md](PDF-EXPORT-CAPACITY.md) for what these cost and how
| `ORCPUB_PDF_MAX_RETRIES` | `3` | How many times the busy page retries itself before it stops and waits for the person to click. |
| `ORCPUB_PDF_MAX_CASTER_SECTIONS` | `13` | Most spellcasting sections one sheet may be grown to. Thirteen is every class in the game, which no character can exceed. |
| `ORCPUB_PDF_MAX_CARDS` | `198` | Most **cards** — not pages — of each kind a single export prints. Nine to a sheet, so 198 is exactly 22 sheets. Counted per kind, so spells, items and features are bounded separately. A level 20 wizard's spellbook is about 44. |
+| `ORCPUB_PORTRAIT_CONCURRENCY` | `max(2, cores / 2)` | How many shared-link portrait images are drawn at once. Each draw is about half a second of one core; repeat requests come from a cache, so this only bounds new pictures. See [PORTRAIT-SERVING.md](PORTRAIT-SERVING.md). |
+| `ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS` | `5000` | How long a portrait image waits to be drawn before the server answers 503 with a `Retry-After`. Short, because link-preview fetchers give up quickly. |
A value that is present but not a positive integer is reported at boot and the
default is used, so a typo does not take the server down or silently mean zero.
@@ -149,6 +151,15 @@ the table in half. Plain ASCII, no colour: it is read in aggregators as often as
|----------|---------|-------------|
| `LOAD_HOMEBREW_URL` | — | URL to fetch server-hosted `.orcbrew` plugins on first load (e.g. `/homebrew.orcbrew`). Unset = no fetch. |
+### Portrait artists
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `PORTRAIT_ARTISTS` | — | JSON of artist credits and accounts (see `ARTISTS.md`). An `account` email gets an artist account and a welcome email at startup. |
+| `APP_URL` | — | The site's address, used for links in artist emails (sent at startup, with no request to read it from). |
+| `EMAIL_ADMIN_TO` | `EMAIL_ERRORS_TO` | Who hears about artist account and credit changes. |
+| `PORTRAIT_STORE_DIR` | `orcpub-portraits` in the system temp directory | Where the server keeps its drawings of shared-link portraits, one file per character. A shared volume lets several containers draw each portrait once; without one each container draws it on first request and keeps the file. In Docker, mount a shared volume at `/var/lib/orcpub/portraits` (the image creates it for the app's user) and set this to that path. A failed write is logged and the drawing is still served. Derived files: losing them only means drawing again. |
+
### Security
| Variable | Default | Description |
@@ -156,6 +167,7 @@ the table in half. Plain ASCII, no colour: it is read in aggregators as often as
| `CSP_POLICY` | `strict` | Content Security Policy mode: `strict`, `permissive`, or `none` |
| `DEV_MODE` | `"true"` (in :dev profile) | When true, **no CSP header is sent at all** — that is what lets Figwheel's `ws://localhost:3449` through. Must be the string `"true"` (case-insensitive); any other value, including `"1"`, `"yes"` or empty, is false. |
| `ORCPUB_PWNED_CHECK` | `on` | Screen new passwords against the Have I Been Pwned corpus. Accepts `on`/`off`, `true`/`false`, `1`/`0`, `yes`/`no`. |
+| `PROFILE_ENCRYPTION_KEYS` | — | Encrypts preferred names at rest: comma-separated `id:base64` keys of 32 bytes; the first encrypts, all decrypt. Also read from the Docker secret `profile_encryption_keys`. Unset = preferred names cannot be saved. Losing it makes stored names unreadable. `./run` generates one. |
**About the breach check.** Signup and password reset send the first five characters of
the password's SHA-1 to `api.pwnedpasswords.com` and score the reply locally, so the
diff --git a/docs/PORTRAIT-PACK.md b/docs/PORTRAIT-PACK.md
new file mode 100644
index 000000000..fdd6aef04
--- /dev/null
+++ b/docs/PORTRAIT-PACK.md
@@ -0,0 +1,108 @@
+# Portrait pack calibration
+
+Each piece of portrait art can carry settings beyond its name: where its
+irises are, whether it casts a shadow, whether it's lips. Those settings live
+in two files, not in code, so tweaking a piece is a small edit to one file.
+
+| File | Holds | Edited by |
+|---|---|---|
+| `resources/portrait-pack/loom.edn` | What the Loom places: iris regions, pupils, upper lids, lower lids and whites | The import command, never by hand |
+| `resources/portrait-pack/pieces.edn` | Our choices: which mouth is lips, which bangs cast shadows, iris gamma | Hand, with comments saying why |
+
+Both are keyed by layer, then by the art's file name in lower case. Where they
+overlap, `pieces.edn` wins. They are read at build time and compiled into both
+the server (share card) and the browser bundle (builder, PDF), so a change
+needs a rebuild.
+
+The list of pieces itself (ids and labels) stays in `portrait_assets.cljc`,
+because saved portraits refer to those ids and they must never shift.
+
+## After a Loom session
+
+Export the manifest from the Loom, then:
+
+```bash
+lein run -m orcpub.portrait-pack.import manifest-v1.6.json --dry-run # see what would change
+lein run -m orcpub.portrait-pack.import manifest-v1.6.json # write loom.edn
+```
+
+It prints exactly which pieces were added, changed or removed, with the old
+and new values for each change. It **stops** if the manifest lists one piece
+twice with different values. Decide which is right and re-run with
+`--prefer first` or `--prefer last`. It also lists pieces the manifest has
+and the registry doesn't; add those to `asset-inventory` before they can be
+chosen.
+
+## After new or changed hair art
+
+Hair streaks follow the strands drawn in the art. Which way they run is
+worked out from the linework once, ahead of time, and stored beside each
+hair piece as `.strands.png` (a small greyscale image, 5–15KB):
+
+```bash
+lein run -m orcpub.portrait-pack.strands # resources/public/image/portraits
+lein run -m orcpub.portrait-pack.strands /path/to/portraits # a pack kept elsewhere
+```
+
+The image build runs it (`docker/Dockerfile`, after the JavaScript build), so
+a deploy needs only the art: it writes the files that are missing or older
+than their art, and `--all` rewrites every one. They are derived from the art,
+so they are gitignored and never committed. A piece without one (running from
+source without the step) still streaks: the server works the field out the
+first time it is asked for (about 0.1–0.5s), then keeps it.
+
+## Tweaking by hand
+
+Edit `pieces.edn`. The fields a piece can carry are listed at the top of the
+file, and in `calibration-fields` in `portrait_assets.cljc`. A piece name or
+field that doesn't exist fails `lein test` (`portrait-pack-test`), so a typo
+can't quietly do nothing.
+
+The art itself is never committed. These files are geometry and settings
+only.
+
+## The licensed art: private branch only
+
+This repository carries only the anonymised silhouettes. The licensed art goes in one commit on
+the private deployment branch (Gitea), which is built and deployed like any other branch; the
+image build makes the strand files, so the art PNGs are all it needs.
+
+**Laying in a pack** (first time, or a new Loom export), on the private branch, with a clean tree:
+
+```bash
+scripts/private-art.sh update portrait-pack-v1.775.zip
+git push # to the private remote
+```
+
+It copies each PNG over the silhouette of the same layer and name, writes
+`resources/public/image/portraits/PRIVATE-ART`, and commits. It refuses on a branch whose remote
+is GitHub (fetch or push address), or that tracks no remote.
+
+**Taking in new code:** merge the GitHub branch into the private branch, as usual. The art commit
+stays on top. Never merge or push the private branch the other way.
+
+**The guards:**
+
+- `orcpub.portrait-art-guard-test` checks every portrait PNG against the digest of its silhouette
+ (`test/portrait-silhouettes.edn`), so art committed here fails the suite. The marker turns it off
+ on the private branch. A new or redrawn silhouette is recorded with
+ `PORTRAIT_SILHOUETTES=record lein test :only orcpub.portrait-art-guard-test`.
+- `.github/workflows/portrait-art-guard.yml` fails any push or pull request on GitHub that carries
+ the marker, which is the private branch arriving here.
+- The licensed-art mark: the Loom (v2.41 on) writes a PNG text chunk, keyword `orcpub-art`, value
+ `licensed; pack ; artist `, into every piece of the real art it exports, pixels
+ untouched; `scripts/private-art.sh` stamps any piece laid in without one (older packs, such as
+ v1.775). Everything drawn from marked pieces carries it too: the server's drawings and their
+ stored files, strand files, the browser's bake, the exported PDF (in its metadata) and the dev
+ gallery. Silhouettes have none. The guard (`portrait-art-guard-test`, `scripts/check-images.sh`)
+ refuses any marked file anywhere in the repository before its other checks.
+ Limits: the mark is lost by a screenshot or by any tool outside this code, so the reviewed list
+ below and the owner's contact-sheet review stay as the other layers.
+- Every image in the repository, docs/ included, must be a recorded silhouette or listed in
+ `test/reviewed-images.edn` by its SHA-256, with what it is (`scripts/check-images.sh` in the
+ same workflow, and `orcpub.portrait-art-guard-test`). A new or changed image fails until someone
+ has looked at it and recorded it; the failure shows its linework score as a hint, but no score
+ tells the art from a logo reliably (a real-art composite scores 2.5%, the site logo 17.9%).
+- The server answers only the pieces registered in `portrait_assets.cljc` (and a strand file
+ beside each hair piece), so the marker, any manifest and any piece a pack adds before it is
+ registered are never public. Each piece carries `X-Robots-Tag: noai, noimageai`.
diff --git a/docs/PORTRAIT-ROADMAP.md b/docs/PORTRAIT-ROADMAP.md
new file mode 100644
index 000000000..7616fde54
--- /dev/null
+++ b/docs/PORTRAIT-ROADMAP.md
@@ -0,0 +1,86 @@
+# Portrait roadmap
+
+Where the portrait system stands, what a deployment needs, and what comes in later updates.
+
+**Working rule for every visual item:** judge it on whole portraits rendered by the app, on
+light to dark skin, at the size the builder shows them, beside the version already
+approved. A new method replaces a working one only after it wins there. Single-piece views
+and amplified checks find problems; they do not decide them.
+
+---
+
+## Shipping in this branch
+
+- The portrait builder: layered pieces, Randomize, per-character colours with per-piece
+ overrides, and the credit on every surface the picture reaches.
+- Hair: ombré with streaks along the drawn strands, shine, dyed bangs, shadows on the face.
+- Eyes: pupils, a second colour, brightness, glowing eyes, painted-in whites for styles drawn
+ without them.
+- Skin: blush, freckles, shading under the hair.
+- Mood light: torch, moon and arcane; edge, cast or rim; from any side.
+- Artist accounts, preferred names, and artist-editable credit.
+- Art prepared in the Silhouette Loom (`agents/develop`, `agents/loom/`): eye placements,
+ the see-through fill, patches for gaps between pieces.
+
+The generated scalp piece is retired: the art now meets (hair front 02 was patched to reach
+bangs 03 in pack v1.775).
+
+## Deploying
+
+1. Unpack the art pack (currently **v1.775**, exported from the Loom with "Fill see-through
+ insides" ticked) over `resources/public/image/portraits/`, or the volume that serves it.
+2. Nothing else for the art: the image build writes the strand files beside it (the Dockerfile
+ runs `orcpub.portrait-pack.strands`, which only fills in missing or stale ones), so the image
+ carries them. Running from source without that step still works: the server then works a
+ missing one out on first request.
+3. Configure, as needed: `PORTRAIT_ARTISTS` (credits and artist accounts), `PROFILE_ENCRYPTION_KEYS`
+ (preferred names), `APP_URL` (links in artist emails), `EMAIL_ADMIN_TO`.
+4. A What's New entry for the builder, in whichever release ships it. Draft:
+ - **Group:** Characters · **Icon:** `fa-user-circle`
+ - **Headline:** Build a portrait for your character
+ - **Detail:** Pick or randomize layered art by a real illustrator, colour hair, skin, eyes and
+ clothing, and add an ombré, freckles, glowing eyes or a torch-lit edge. The portrait shows
+ on the sheet, the summary and shared links, credited to the artist.
+
+## Rolling enhancements
+
+Each of these works today or is hidden; none blocks shipping.
+
+### Art and look
+
+- **Mood light details.** Torch edge, moonlight and arcane each want small tweaks, judged on
+ dark skin first: the reflected-light base with a long soft taper is the direction chosen.
+- **Skin-tone rendering.** Warm and cool variation, flush where blood sits, light on the planes
+ that catch it. Needs its own mock-up rounds.
+- **Split dye** (Developer mode only). Every automatic split tried so far regressed somewhere.
+ The open route is the artist marking each piece's parting in the Loom.
+- **Ombré under-layer** and the **alternative far iris** (Developer mode): decisions pending.
+- **Bangs 01 hairline slivers.** Skin shows between its strand tips at the hairline; it reads as
+ a fringe. If wanted closed, it is a Patch gaps job in the Loom.
+- **Further healing of the art** (closing holes the fill does not reach). Parked with what was
+ learned in `agents/loom/README.md`: the artist draws some marks with transparency, and no
+ automatic rule tried so far tells them from holes.
+
+### Strand files
+
+- **One file per layer, per artist** instead of one per piece: people click through every option
+ in a category, so one request per category serves them all, and an artist's files never change
+ when another artist's pack is added (it also suits filtering by artist later). Today's files
+ are internal (saved portraits never store their addresses), so switching costs no data.
+
+### Loom
+
+- Rebuilding the tint picker, bundled placements ignoring the layer, a separate message area,
+ remembering commission-kit fields, zip dates and UTF-8 names, keyboard access, memory use on
+ large packs.
+
+### Code
+
+- **Comment debt.** 84 long docstrings and comments from this branch are recorded in
+ `test/comment-baseline.edn` (the house check fails on new ones). Shorten each to spec, move
+ its history to `docs/kb/`, then prune the baseline.
+
+### Before or at release
+
+- **Art licence:** `ARTISTS.md`, a `LICENSE` in the portraits directory, and a carve-out from the
+ root EPL-2.0, once the commission paperwork is checked and the artist agrees.
diff --git a/docs/PORTRAIT-SERVING.md b/docs/PORTRAIT-SERVING.md
new file mode 100644
index 000000000..1f7b9b54f
--- /dev/null
+++ b/docs/PORTRAIT-SERVING.md
@@ -0,0 +1,116 @@
+# Serving portraits: work, caches and limits
+
+How the server spends CPU and memory on images, and how the pieces fit. Read this before adding a
+cache, a semaphore or another image route: there is one way to do each.
+
+## The two shared tools
+
+| Tool | What it is | Used by |
+|---|---|---|
+| `orcpub.work-pool` | A fair permit pool for one kind of heavy work, with a waiting count and a running mean of how long the work takes. `run` answers `{:result x}` or `{:busy retry-seconds}`; the Retry-After is the queue ahead over how fast permits drain, 1..30 s. | PDF export, the image probe, portrait draws |
+| `orcpub.bounded-cache` | An LRU cache bounded by count and optionally age, checked on every read. `fetch!` is single-flight: concurrent misses for one key share one computation; a failure is not kept. | probed pictures, drawn portraits (in front of their files), strand fields (parsed and worked out) |
+
+Each pool is separate from the HTTP worker pool (`ORCPUB_HTTP_MAX_THREADS`), so a rush of one kind
+of work waits for its own permits while pages, logins and saves keep their workers.
+
+## The pools
+
+| Pool | Size | Wait | When full |
+|---|---|---|---|
+| export | `ORCPUB_PDF_CONCURRENCY` (`max(8, 2 x cores)`) | `ORCPUB_PDF_QUEUE_TIMEOUT_MS` (30 s) | 503 + the self-retrying busy page |
+| probe | 8 | none | `rate-limited` (the endpoint needs no login, so it never queues) |
+| portrait | `ORCPUB_PORTRAIT_CONCURRENCY` (`max(2, cores / 2)`) | `ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS` (5 s) | 503 + Retry-After |
+
+The portrait pool is small because a draw is CPU, not I/O: about half a second of one core at
+600 x 750 (measured on the silhouettes: ~20 ms reading and decoding ten layers, ~35 ms encoding,
+the rest compositing, most of it the per-pixel ombre over the hair layers). The wait is short
+because link-preview fetchers give up quickly.
+
+## Stored drawings
+
+The server draws each character's portrait itself and keeps the drawing as a file,
+`/-.png` (`orcpub.portrait-store`). The default is
+`orcpub-portraits` in the system temp directory, because the Docker image runs from `/` as a user
+that can write only `/tmp`; for a shared volume, mount it at `/var/lib/orcpub/portraits` (created
+for that user in the image) and set the variable to it. A write that fails is logged and the
+drawing is served anyway.
+
+- **Fingerprint.** The saved selection, the credit line, the site mark, and a digest of the
+ registered art files and the drawing code (read from the classpath). It does not depend on the
+ process, so a kept file is good across restarts, and any change to the art or the drawing code
+ draws every portrait again.
+- **On save.** The store compares the fingerprint with the kept file. Autosave fires every few
+ seconds for edits like hit points; those find the file already there and do nothing. A changed
+ portrait is drawn once, 3 s after the last save of a burst, with what was saved last. A removed
+ portrait's files are deleted.
+- **On request.** Memory (64 drawings), then the file, then a draw. Characters saved before this
+ existed are drawn on their first request and kept.
+- **Limits.** Every draw takes a portrait-pool permit; concurrent requests for one drawing share
+ it. A save never waits for a draw.
+
+**Decision: files, not the database.** Datomic keeps every past value forever and is not meant
+for large values, and these are derived, so a lost file is only drawn again. A shared volume lets
+several containers draw each portrait once; without one, each container draws on first request
+and keeps its own copy. (Decided in an earlier session and stated only in chat then; recorded here.)
+
+The browser's own picture is used only for the PDF the owner exports, which goes back to them
+alone. The server never stores or serves a picture a browser made: it cannot tell one from any
+image someone uploads, and the shared picture carries the artist's name.
+
+## A shared link, end to end
+
+1. The character page names the picture by version: `/pages/dnd/5e/characters/:id/portrait.png?v=<16 hex>`, the
+ start of the portrait's fingerprint. It refreshes the artist credits first, so the version and
+ the credit agree with what the image route will compute.
+2. The image route pulls the stored portrait and computes the same fingerprint. No drawing yet:
+ - `If-None-Match` covering it: 304.
+ - `?v=` matching it: the response is `public, max-age=31536000, immutable`, because that address
+ never changes content. Bare or stale: `public, max-age=300`.
+3. Then the store, as above: memory, file, draw. The crawlers a new link sends at once (Discord,
+ Slack, Mastodon instances...) share one draw. Nothing drawable is answered 404.
+
+When a portrait changes, its fingerprint and so its address change: chat apps that cached the old
+picture under the old address fetch the new one, and nothing has to be purged.
+
+## Strand fields
+
+The build step writes `.strands.png` beside each hair piece. Without it (running from
+source, or a deploy that skipped the step), `orcpub.portrait-pack.strands/worked-out-png` works
+the same PNG out from the art once and keeps it. Both consumers read those bytes, the builder over
+HTTP (`/image/portraits/...strands.png`) and the server renderer directly, so a deploy without the
+files draws exactly what one with them draws (`portrait-render-test` checks it pixel for pixel).
+The renderer keeps the parsed fields in their own cache.
+
+## Seeded randomness
+
+Everything that must come out the same in the browser and on the server goes through
+`orcpub.dnd.e5.portrait-random`: Randomize's seed hash (FNV-1a) and generator (mulberry32), and
+the grid hash behind hair streaks and freckles. Plain `*` and `(int c)` differ in ClojureScript,
+which once made every browser Randomize return the same portrait. One-off UI randomness
+(`rand-int`, `shuffle` in the character randomizer) and secrets (`SecureRandom`, UUIDs) are
+different jobs and stay where they are.
+
+## Artist credits in the browser
+
+Artists can edit their credit, so it never goes into the page's HTML or a script. The browser
+fetches `/dnd/5e/portrait-artists` (JSON, cached a minute) at start, keeps it in app-db under
+`:portrait/artist-overrides`, and every view that shows a credit subscribes to it, so a credit
+drawn before the fetch updates when it lands. Until then the views, and a PDF exported in that
+moment, show the registry's public default. Server-side pictures and share cards read the same
+merge (`artist-credit/refresh!`) directly.
+
+## What the browser does instead
+
+The builder draws on a canvas; the PDF export posts the portrait the browser baked (it goes back
+only to the person who asked, so the browser's pixels are trusted there and nowhere else). The
+server never stores a browser-made image: the saved portrait is the selection, and every server
+picture is drawn from registered art (see the roadmap for why).
+
+## Open, measured on real art only
+
+The real art is larger than the silhouettes, so decoding is a bigger share of a draw there. Two
+changes wait on that measurement on the private deployment:
+
+- decode each layer once per draw (today some layers are decoded by the drawing step and again for
+ the crown, skin and light passes);
+- keep decoded, placed layers across draws (bounded: one placed layer is 1.8 MB at 600 x 750).
diff --git a/docs/PORTRAIT-STYLES.md b/docs/PORTRAIT-STYLES.md
new file mode 100644
index 000000000..7c8960cf1
--- /dev/null
+++ b/docs/PORTRAIT-STYLES.md
@@ -0,0 +1,71 @@
+# Portrait compositor styles
+
+The drawer, the builder's Portrait tab, the summary thumbnail and the artist credit are styled in
+Garden, in `src/clj/orcpub/styles/portrait.clj`. They are compiled into the app's stylesheet
+(`orcpub.styles.core/app`, last, so they win ties with the rules before them) by `lein garden once`.
+`portrait.cljs` holds no CSS. `orcpub.styles.portrait-test` checks this.
+
+## Values from data are CSS variables
+
+A view sets only custom properties inline. The rules that use them live in Garden:
+
+| Variable | Set on | Used for |
+|---|---|---|
+| `--pl-z` | each `.portrait-layer` | its place in the stack |
+| `--pl-art` | `.portrait-layer-art`, `.portrait-layer-tinted` | the piece's image, as `url(...)` |
+| `--pl-tint` | `.portrait-layer-tinted` | the colour multiplied under it |
+| `--pl-fill` | anything with `.pl-fill` (swatches, presets, chips, dots) | its background |
+| `--pl-logo` | `.pl-empty-hint`, `.pl-thumb-empty` | the debossed site logo, from `branding/logo-path` |
+| `--lk-mark-color` | `.lk-mark` | a link mark's own colour (falls back to `--lk-dim`) |
+| `--lk-icon` | `.lk-ico` | the service's icon, used as a mask |
+
+`.pl-fill` comes last in the sheet so a swatch's colour wins over its own class's rules.
+
+In Garden, a rule with more than eight properties is split over several maps. A larger map literal
+is a hash map and would not keep its declarations in order, and the order matters wherever a
+shorthand (`background`, `font`, `border`) is followed by one of its longhands.
+
+## Why some rules are the way they are
+
+**The credit face.** The baked credit is drawn on a canvas, and a canvas can only use faces the
+document has loaded. Vollkorn is the face the PDF is set in, so the caption inside the exported
+picture matches the sheet around it. The `@font-face` points at a subset first: the whole face is
+357 KB to draw one short line, and the subset is Latin-1 plus the separators and quotes a name may
+carry (197 glyphs, 26 KB, kerning kept). The full TTF is the fallback: it is on disk anyway for
+PDFBox, and it covers a name the subset does not. The browser fetches it only when something draws
+with it, which is an export. Regenerate the subset with fonttools and brotli:
+
+```bash
+pyftsubset resources/public/fonts/Vollkorn-Italic.ttf --flavor=woff2 \
+ --unicodes='U+0020-007E,U+00A0-00FF,U+2013-2014,U+2018-2019,U+201C-201D' \
+ --output-file=resources/public/fonts/Vollkorn-Italic-credit.woff2
+```
+
+**The light theme.** The drawer is mounted as a sibling of `content-page`, outside `.app`, so it
+cannot inherit `.app.light-theme`; its rules hang off `.pl-root.light-theme`, and the drawer puts
+the theme class on its own root. The launcher and the thumbnail pencil sit inside the builder, so
+theirs hang off `.app.light-theme`. The light theme uses the app's own light colours (`#363636`
+text, the `#33658A` of its `.form-button`) because it turns `.orange` near-black, and an amber
+drawer would look pasted on. The portrait frame stays dark in both themes: what it holds is
+character colours, pale skin and blonde hair among them, which a light frame would swallow.
+
+**The empty slot.** The empty frame and thumbnail use a shallow top-down gradient, like the app's
+other panels, and carry the site logo debossed: masked to about 5% white with a one-pixel shadow
+under it. Only the empty states carry it, so a portrait never shows a logo through its transparent
+parts. The logo comes from `branding/logo-path`, so a fork shows its own. In the big frame the logo
+sits high and the "pick a layer" hint low, so neither muddies the other.
+
+**The art column.** The frame fills the column, like every button and row under it, with the
+frame's 4:5 ratio setting its height. Its border is inside the width (`box-sizing`), so it lines up
+with them.
+
+**The credit.** A small label, then each artist's name on a broken rule with their link marks as
+its end caps, or centred below when they cannot balance (`credit-mark-layout`). A linked name has a
+dotted underline at rest, drawn as a text decoration so it does not grow the box. When someone
+reaches for it (mouse, keyboard or tap) the letters turn prismatic: a spectrum clipped to the
+glyphs, drifting slowly, under a two-colour halo. Nothing moves at rest, and anyone who asked for
+less motion gets the spectrum standing still. The halo is a `drop-shadow` filter, because with the
+letters transparent a `text-shadow` would show through them.
+
+**A planned piece.** A piece the illustrator has planned but not drawn is dashed, with no hover and
+no pointer, so it does not look like a swatch you can press.
diff --git a/docs/PORTRAIT-TINTING.md b/docs/PORTRAIT-TINTING.md
new file mode 100644
index 000000000..6ba898046
--- /dev/null
+++ b/docs/PORTRAIT-TINTING.md
@@ -0,0 +1,210 @@
+# Tinting portrait line art
+
+How the character's colours get onto the illustrator's drawing, and why each
+step is the way it is. Everything here was settled by rendering the real art and
+looking, not by argument; the tests named at the end are what keep it settled.
+
+Nothing in here is AI image generation. Every asset is drawn by hand. The
+compositor's whole job is to recolour authored line art without damaging it.
+
+## Multiply, not mask
+
+The original compositor tinted by masking: the browser laid a CSS
+`mask-image` over a background colour, the server used `AlphaComposite/SrcIn`
+over a `fillRect`. Both read the asset's **alpha** and discard its **RGB
+entirely**.
+
+That is invisible while the assets are silhouettes, because a silhouette is
+nothing but alpha. It stops being invisible the moment line art arrives: a
+drawing with black outlines over a white fill comes out as one flat colour --
+the same output as its own outline. The pipeline could not tell a detailed
+drawing from its silhouette.
+
+The replacement is multiply: `out.rgb = tint.rgb * art.rgb`, `out.a = art.a`.
+White fill takes the tint exactly, black lines stay black, a grey darkens the
+tint by however grey it is. Java2D has no multiply -- `AlphaComposite` is
+Porter-Duff only -- so on the server it is a pixel pass.
+
+**The catch:** a black silhouette multiplies to black, not to the tint. Any
+silhouette that has to survive this pipeline must be drawn **white**, not black.
+
+## The mouth is not tinted
+
+Teeth are not lips. An early pass ran the mouth through the skin slot and every
+smiling character got pink teeth. The mouth layer carries its own colours and is
+drawn untinted; assets that need a colour carry a per-asset default rather than
+borrowing a character slot.
+
+## Eyes: colorize inside an authored region
+
+Eyes cannot be multiplied with the rest. Multiplying the whole eye layer tints
+the sclera and the lashes along with the iris.
+
+Two things were tried and rejected:
+
+- **Additive blending.** The artwork shows through, so shading and the
+ highlight wash toward white. The result looks *transparent* -- this is what
+ "the iris colours are see-through" was describing, and it is not a property of
+ colouring the iris, only of adding to it.
+- **Runtime iris detection.** Fragile on stylised art, and it has to run on
+ every render for a fact that never changes.
+
+What ships instead: the iris region is **authored once per asset** in the Loom
+and stored in the registry as `:asset/iris` / `:asset/pupil` on each eye style
+(`portrait_assets.cljc`) -- an ellipse, a pupil radius, a pupil offset, and a lid
+curve per eye -- and the renderer maps the drawing's own luminance through the
+chosen colour inside it.
+
+All three renderers do this, from one module, `portrait_colorize.cljc`: the maths
+and the region geometry live there, and only the pixel loop and the drawing of
+the region are per renderer. The share card fills the region with Java2D; the
+builder's drawer and the PDF bake fill it on a canvas, and the drawer shows those
+layers as `