Theatre Register

Pictures

The catalogue holds 11,336 images. They are the identification mechanism, not decoration — brief.md reversed the original "no images" position on 2026-08-12 for exactly that reason, and cover-art-brief.md holds the argument.

What we hold

By tier: 4,993 against shows, 4,635 against people, 732 against albums, 495 against venues, and 53 against a specific production.

That last number is the one to notice. 53 pictures document a staging. The production tier is the spine of the whole catalogue under decisions.md §11, and it is almost entirely unillustrated. Everything else pictures a work, a performer or a building.

By type, the largest classes are publicity portraits (3,396), front sleeves (1,700), back sleeves (1,157) and press candids (1,125).

Coverage: of 939 shows, the number holding at least one picture of their own is

  1. Of 7,139 people, the number holding a portrait is

1,629.

Where they come from, and under what

Stated licencen
Cover Art Archive, no per-item statement3,991
Public domain3,685
CC BY-SA 4.0825
CC BY 2.0556
CC BY-SA 3.0555
CC BY-SA 2.0446
CC BY 3.0221
CC BY 4.0166

Every row carries a licence string, and the count of blank ones is 0. That is not the same as every row being cleared. 3,991 of them are Cover Art Archive files carrying no per-item statement at all, and START-HERE.md lists the cover art question as open, with counsel, unanswered. The site names the position rather than hiding it: the credit filter in web/app.py renders those as "Cover Art Archive. No licence stated." instead of truncating the site-wide boilerplate into NO PER-ITEM LICENCE STATEMENT. Site-wide sta.

Images are self-hosted, never hotlinked, and takedowns are honoured immediately.

Blurring, and what it is for

254 images are shown out of focus.

decisions.md §11 was amended on 14 August after the image programme filtered on Wikimedia's Restrictions field, which an earlier sweep had silently dropped. It found 281 files carrying rights that survive a free photographic licence, 240 of them personality. A freely licensed photograph of a performer in costume is freely licensed as a photograph. The performer's right in their own likeness and the designer's right in the costume were never the photographer's to give away.

So a production has at least three classes of claimant: the photographer, the designers, and the performers. 281 is a floor, because it counts only what Commons editors happened to mark, and every production still by definition shows identifiable people in designed clothes on a designed set.

tools/blur-restricted.py implements the owner's decision, which is to show these blurred rather than drop them, so the page still records that the document exists. Three details matter:

  • Originals are never touched. A blurred file is written beside its original as <name>.blur.jpg.
  • The blur is not reversible. The image is reduced to 64 pixels wide, resampled up, then Gaussian-blurred, which throws the detail away before blurring rather than smearing detail that is still present.
  • It always comes with a sentence. The manifest row gains a display block quoting the restriction as the source states it. web/app.py's blurnote filter carries the long form and credit carries the short one, because a picture out of focus with no word about why looks like a file that failed to load rather than a decision.

Restriction values other than personality and costume are left alone deliberately, and the run reports them.

The gate that stops a restricted image reaching a page

.venv/bin/python tools/check-blur.py     # exit 1 if anything is unblurred

It runs as step 2 of deploy/build.sh, before the derivatives are built. That placement is the point. An earlier draft of this page said "run it before any deploy that follows a harvest", which is a habit written down — still a habit. The script runs under set -euo pipefail and calls the gate bare, so a failure stops the build rather than being reported and ignored.

blur-restricted.py runs once; the library does not hold still. tools/gap-harvest.py appends to it continuously — it grew from 6,697 to over 6,900 rows in a single afternoon.

What the leak actually was, which is worse than it first looked. deploy/make-derivatives.py resolves every row through web/assetpaths.py:image_path(), which returns the blurred derivative only when the row carries display.use == 'blurred'. A restricted row the blur pass has never seen has no display block at all, so the original is returned, built into the deploy directory, uploaded to the bucket, and cached by Cloudflare for a year. Not "renders unblurred on a page" — published, and cached beyond easy recall.

The gate catches two failure modes, not one. A missing derivative, and a stale one: tools/trim-borders.py rewrites a harvested original in place and tells nothing, which would leave a blurred derivative that exists, passes an existence test, and shows a photograph that is no longer the photograph the row describes — credited as if it were. Existence was the wrong test. It compares mtimes with a two-second grace for filesystem jitter.

The check is a set difference and makes no judgement: every row whose restriction value is one this project blurs, minus every row with a derivative on disk. It walks all three manifests, including album-images.ndjson, which carries no restriction field today and is included so that the day it does, this notices rather than skips it.

The rule is imported from tools/blur-restricted.py rather than restated, for the reason web/assetpaths.py and web/cropping.py exist. That applies to a check as much as to a renderer.

A row whose file is not on disk is counted and skipped rather than failed — an unfetched row cannot be displayed either — and the count is printed on the first line so the number is never silently smaller than it looks.

Both failure modes have been made to fail on purpose and clear again, and no original was touched to prove it. A check nobody has watched fail is a hypothesis.

Including a failure in the test of the test. The first attempt to prove the gate halts a build used ( set -e; cmd; echo ok ) || echo failed, which suppresses errexit inside the subshell and reported the gate letting a bad build through when it had not. The real test is a script carrying the same set -euo pipefail as deploy/build.sh.

Which picture leads a page

web/app.py:hero_for() picks it, ranking candidates by type against HERO_TYPES and breaking ties on pixel area. That list is imported from tools/site.py rather than copied, and the docstring says what happened when it was copied: sleeve-unspecified got added to the generator and silently not to the server.

Pairing confidence is deliberately not required at show tier. unresolved there means we cannot say which production an image documents, and the entity id has already settled that it belongs to the show. A show page claims nothing about a staging, so there is nothing for the confidence flag to protect.

A person's picture comes from web/app.py:portrait_for() instead, because a portrait is typed publicity-portrait, which is not on the hero list and should not be.

May we crop it, or print over it

A separate question from whether we may show it, and it has one owner: web/cropping.py, added 2026-08-16. Every tool that alters an image asks it first, and the browser path is being brought under the same rule.

The default is refuse. Where the terms say nothing about cropping, the image is reproduced whole. An unstated permission is not a permission, which is the same reasoning tools/broken-refs.py applies to a Wikipedia file carrying no licence statement. Reproducing a frame uncropped costs nothing; assuming a permission nobody granted is a derivative work.

No count is given here on purpose. assets/library.ndjson is appended to and partly rewritten while the image programme runs — it moved through 6,697, 6,774 and 6,887 rows in a single afternoon — so any figure would be stale within hours and would make tools/doccheck.py cry wolf. The rule is stable; the tallies are not. Query the file if you need a number.

The file also carries two schemas for the same fact, which is worth knowing before reading it: cropAllowed / maxCropPercent / overprintAllowed on most rows, and crop_allowed / stated / source on a few dozen. Two writers, two conventions, one meaning.

The second rule, and it is not a duplicate

tools/classify.py holds crop_policy() and mark_fit(), and an earlier version of this page said mark_fit was a stray copy of web/cropping.py that should import it instead. That was wrong on both counts, and the collage agent corrected it.

They are different rules on different inputs. crop_policy() is a lookup keyed by archive — the Museum of the City of New York is limited at 10% with overprinting refused, Commons and en.wikipedia are yes, Cover Art Archive is unknown, and anything unlisted falls through to unknown and is refused. web/cropping.py judges per row. mark_fit defaults to refuse and states MCNY's cap correctly in structured form.

The real gap is granularity rather than duplication: an archive-level gate cannot see a per-row restriction, so a Commons file carrying an estate clause would pass on Commons' blanket yes. It does not fire today, and that was checked rather than assumed — the types mark_fit accepts are sleeve fronts, posters, window cards, sheet-music covers and title-treatment sources, the rows carrying integrity clauses are portraits and fail the type test anyway, and the library holds no NoDerivs licences at all. Latent, worth reconciling, not urgent.

tools/doccheck.py has a cropping check that holds the weaker but mechanical line: any file that crops an image must either import web/cropping.py or appear in a short exemption list with a written reason. It cannot tell whether two implementations agree. It can tell whether the function with the authority is the one being called.

Plates — removed

Composed plates were built on 2026-08-16 and removed the same day on the owner's instruction. The generator, the reader module, the template partial, the stylesheet and the asset directory — make-plates.py, plates.py, _plate.html, plate.css and assets/plates — are all deleted. Do not revive the idea.

Their names are written here without backticks on purpose. tools/doccheck.py treats a backticked path as a claim that the file exists, and a removal note is the one place a document names something it should not find.

A show with no picture of its own keeps the labelled stand-in built by web/app.py:related_image() — a portrait of someone in the cast, or the theatre it played, shown smaller than a hero and captioned with what it actually is.

The crop research done for the plates outlived them and governs live code; it is in findings/image-cropping.md.

What we do not do

No hotlinking. No image is served from somebody else's host.

No picture stands in for evidence it is not. web/app.py:related_image() returns the image and the sentence, and the template gives it a smaller, plainly different treatment from a real hero. The production page does the same with a work-level picture: 48 productions hold a picture of their own and 1,268 belong to a work that holds one, and the second documents no particular staging.

No image is published because its licence field is merely non-empty. The broken-refs:non-free band — 708 of 831 broken references — is held unshipped for precisely this reason, and it is the open question in contrib/README.md: is images a list of pictures you can publish, or a list of pictures that exist?