# Images — preview safety, slot verification, optimisation, brand logo

## Image preview safety — never `Read` an image >2000px

**Hard rule: never `Read` an image whose longest edge exceeds 2000px.** The harness loads images into context as multimodal inputs and large ones break the session — the encoding budget overruns and the conversation can drop. Property photographs from DSLRs (`DSC*`, 6000–8000px) and drones (`DJI_*`, 4000–8000px) routinely exceed the limit; AI renders (`ChatGPT-Image-*`, 1024 or 2048px) and screenshots are borderline.

Before any `Read` against an image:

1. **Measure first.** `sips -g pixelWidth -g pixelHeight <file>` (macOS) or `identify -format "%wx%h\n" <file>` (ImageMagick).
2. If the longest edge ≤ 2000px, reading the original is safe.
3. If it exceeds 2000px, generate a downscaled preview into `/tmp/`, read the preview, then delete it: `sips -Z 2000 "<src>" --out "/tmp/preview-$(basename "<src>")"`.
4. Often you don't need pixels at all — filename, byte-size, dimensions, and EXIF answer most layout-decision questions ("is this the kitchen wide shot?", "landscape or portrait?") without a multimodal read.

Generated brochure print snapshots (`cover-print.png`, `pageN-print.png`) are A4 @ 96dpi — 1123×794px in landscape, 794×1123px in portrait. Either way they are safe to read directly.

The constraint is non-negotiable; the failure mode is loss of the entire session, not a graceful error.

## Image content verification — never assign a slot from filename alone

A filename like `Sparrows Farm 22.jpg` or `DSC01525_1727361503...jpg` tells you the index and the timestamp. It does not tell you whether the photo is the inglenook, a vanity bathroom, or the principal bedroom. Slot assignment from filename alone routinely puts a downstairs WC where the inglenook should be, or a family bathroom in the principal-bedroom hero cell. The error is silent — the brochure still renders, but the editorial reads as broken because every chapter title misaligns with its image.

Before referencing any image in the HTML by slot, **verify by reading the actual pixels** (subject to the 2000px safety rule above). Build a labelled montage of all 49 images at thumbnail size and read it once, or read each candidate slot directly. Trust the photo, not the filename.

A useful contact-sheet recipe (49 thumbnails, 4 sheets):

```bash
mkdir -p /tmp/thumbs
for n in $(seq 1 49); do
  src="<property_dir>/images/<Property> $n.jpg"
  printf -v idx "%02d" "$n"
  magick "$src" -resize 500x375^ -gravity center -extent 500x375 \
    -fill yellow -gravity north -pointsize 28 -annotate +0+8 "$idx" \
    "/tmp/thumbs/${idx}.png"
done
montage /tmp/thumbs/*.png -tile 4x4 -geometry 500x375+4+4 \
  -background '#1a2426' /tmp/sheet1.jpg  # repeat for sheets 2–4
sips -Z 2000 /tmp/sheet1.jpg --out /tmp/sheet1.jpg
```

Each sheet is 2000×1500px (safe to read), with each thumb labelled by its source index. Read the sheet, write down the slot map, then assign in the HTML.

Particularly easy mis-assignments to watch for:

| Looks similar at thumb size | Different in reality |
|---|---|
| Outdoor kitchen, summerhouse, orangery | Three distinct buildings with three distinct uses |
| Principal bedroom vs guest bedroom | Principal usually has dressing area + chapel windows |
| Walk-in wardrobe vs bathroom | Both white-cabinetry; check for plumbing |
| Family bathroom vs en-suite | En-suite is typically attached to a bedroom shot pair |

## Image requirements — slot map

Rename all photos to `{property-slug}-NN.webp` in an `images/` subfolder. The folio template references images by index, not by descriptive name — index 01 is the cover hero, index 02 is the principal elevation, etc. Slot up to 17 distinct images; missing slots can be replaced by re-using a strong photo from elsewhere, or the spread can be reflowed to omit the cell.

| Index | Page | Role |
|------|------|------|
| 01 | Cover | Hero — golden-hour exterior or atmospheric front shot |
| 02 | Opener (p3) | Principal front elevation |
| 04 | Contents (p2), House at ease (p4) | Wide open-plan reception or kitchen-dining hero |
| 05 | Hall, hearth & sun (p5) | Open reception or hero room |
| 06 | House at ease (p4) — strip | Kitchen / breakfast room |
| 07 | House at ease (p4) — strip | Kitchen wide or patio doors |
| 08 | House at ease (p4) — strip | Separate living / sitting room |
| 09 | Hall, hearth & sun (p5) | Entrance hallway |
| 10 | Hall, hearth & sun (p5) | Garden room / sun room / second reception |
| 13 | Bedrooms & baths (p6) | Principal bedroom (tall feature cell) |
| 14 | Bedrooms & baths (p6) | Shower room or secondary bathroom |
| 15 | Bedrooms & baths (p6), Annexe (p7) | Bedroom two — and the feature page if the feature is annexe-like |
| 18 | Bedrooms & baths (p6) | Bedroom three |
| 19 | Bedrooms & baths (p6) | Family bathroom |
| 24 | Garden & ground (p8) | Aerial / drone / wide garden hero |
| 27 | Lifestyle break (p9) | Full-bleed wide-aspect aerial / twilight exterior with single-line italic caption |
| 25 | Plan & particulars (p10) | Composite floor plan |
| 26 | Back page | Atmospheric rear or aerial at golden hour |

Indexes 03, 11–12, 16–17, 20–23, 28+ are intentionally unused — leaving gaps means a property with more rooms can use them without renumbering. Adapt the page layout when images are missing — remove the slot, don't leave a placeholder.

## `images.json` sidecar — emit alongside the brochure

After slot verification is complete (every WebP under `output/images/` has been read and assigned a slot by pixel content per the rule above), write `output/images.json` as the deterministic record of what the brochure resolved per image. The data is already in hand at this point — the slot table, the alt-text token values, and the figcaption token values are the same strings the brochure HTML carries. The downstream `:Listing` curator consumes this sidecar to populate `:ImageObject.description` and `:ImageObject.tags` without re-running vision against the same bytes. Without the sidecar the curator falls back to a per-image vision call, paying a second time for metadata the brochure has already produced.

Write a second copy at `output/web/images.json` immediately after copying the brochure into `output/web/`. The web bundle is the canonical Cloudflare Pages source tree. It is emitted to `<accountDir>/pages/<slug>/` and deployed to Cloudflare Pages by `cloudflare:site-deploy`, so the landing page stays up when the install device is offline. The curator reads the sidecar at `<accountDir>/pages/<slug>/images.json`. The canonical copy at `output/images.json` is the archival twin and never gets re-derived from the bundle copy. Both copies must be byte-identical at write time; never write one without the other.

### Schema

```jsonc
{
  "schemaVersion": 1,
  "images": [
    {
      "filename": "griffin-house-01.webp",
      "slot": 1,
      "room": "exterior-front",
      "altText": "Principal elevation of Griffin House at golden hour.",
      "caption": "The house, returning to itself at dusk.",
      "isHero": true,
      "isFloorplan": false,
      "isEpc": false
    }
  ]
}
```

One entry per WebP referenced by `brochure.html`, plus one entry for the floorplan PNG and one for the EPC asset. Brand logos (`<brand>-logo-*.png`) and QR PNGs (`qr-*.png`) are not images of the property — exclude them.

| Field | Source |
|---|---|
| `filename` | the file's basename inside `images/` (e.g. `griffin-house-01.webp`, `griffin-house-floorplan.png`, `epc.png`) — no leading directory |
| `slot` | the index from the slot table (1, 2, 4, 5, 6, 7, 8, 9, 10, 13, 14, 15, 18, 19, 24, 25, 26, 27). Floorplan is slot 25; EPC has no slot — set to `null` |
| `room` | one value from the closed set below — the brochure's per-slot semantic |
| `altText` | the `<img alt="…">` factual sentence the brochure assigned to this image (`opener_image_alt`, `landing_grid_N_alt`, `hero_N_alt`, etc.) |
| `caption` | the `<figcaption>` editorial sentence the brochure assigned (`chapter2_strip_N_caption_text`, `intermission_05_caption`, etc.). Omit when the slot carries no figcaption (e.g. cover, gallery thumbs without captions); the curator falls back to `altText` |
| `isHero` | `true` only for slot 1 (the cover hero). Every other entry is `false` |
| `isFloorplan` | `true` only for the floorplan PNG (slot 25) |
| `isEpc` | `true` only for the EPC asset |

### Closed `room` set

The room vocabulary is the same closed 15-element set the `listing-curator` writes onto `:ImageObject.tags`. Never invent a value outside it.

```
kitchen, bathroom, bedroom-master, bedroom-secondary,
living-room, dining-room, garden-rear, garden-front,
exterior-front, exterior-rear, hallway, utility,
floorplan, epc, view
```

The slot table is the canonical mapping. The natural slot → room translation is:

| Slot | Role | `room` |
|---|---|---|
| 1 | Cover hero (golden-hour exterior) | `exterior-front` |
| 2 | Principal front elevation | `exterior-front` |
| 4 | Open-plan reception or kitchen-dining hero | `kitchen` (when kitchen-dominant) or `living-room` |
| 5 | Open reception or hero room | `living-room` |
| 6 | Kitchen / breakfast room | `kitchen` |
| 7 | Kitchen wide or patio doors | `kitchen` |
| 8 | Separate living / sitting room | `living-room` |
| 9 | Entrance hallway | `hallway` |
| 10 | Garden room / sun room / second reception | `living-room` |
| 13 | Principal bedroom | `bedroom-master` |
| 14 | Shower room or secondary bathroom | `bathroom` |
| 15 | Bedroom two | `bedroom-secondary` |
| 18 | Bedroom three | `bedroom-secondary` |
| 19 | Family bathroom | `bathroom` |
| 24 | Aerial / drone / wide garden hero | `garden-rear` |
| 25 | Composite floor plan | `floorplan` |
| 26 | Back page — atmospheric rear or aerial | `exterior-rear` |
| 27 | Lifestyle break — full-bleed wide-aspect aerial | `view` |

The disambiguation at slots 4 and 10 is the brochure's call — read the assigned image and pick the dominant subject. The closed set never grows mid-run; if a slot genuinely depicts something outside the vocabulary (e.g. a utility room), use the nearest closed-set value (`utility`) and explain in `altText`. The closed set is the curator's tag vocabulary; widening it on one side and not the other breaks the contract.

### Emission

Write both files in the same step:

```bash
# After slot verification, alt-text token resolution, and figcaption token resolution.
# Use the python helper or inline JSON write — the data is already in the substitution dict.
python3 - <<'PY'
import json, pathlib
images = [ … one dict per image, populated from the substitution dict above … ]
payload = {"schemaVersion": 1, "images": images}
for path in ("output/images.json", "output/web/images.json"):
    pathlib.Path(path).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
PY
```

The `output/web/` copy is written **after** the bundle's `images/` is populated and the brochure HTML is copied in — not before, so the file survives any earlier cleanup steps that wipe `output/web/`. Place the emission step in the build flow immediately before the strip-unreferenced-images pass; the strip works against `output/web/images/` only and never touches `images.json` at the bundle root.

### Observability

On successful sidecar emission, log:

```
[property-brochure] images-sidecar-written count=<N> path=<output/images.json>
[property-brochure] images-sidecar-written count=<N> path=<output/web/images.json>
```

A `count=0` line surfaces a brochure that referenced zero property images — already a defect by other rules, now visible at sidecar time too.

## Output folder structure

All final artefacts go in **exactly one** location: `<property_dir>/output/`, where `<property_dir>` is `<brand_dir>/properties/<property_slug>-<id>/`. The folder is the deliverable; no nested `brochure/` wrapper between `<property_dir>/` and `output/`, no flat HTML/PDF at the `<property_dir>/` level.

```
<brand_dir>/properties/<property_slug>-<id>/output/
  brochure.html                              # literal filename — NOT <property_slug>-brochure.html
  <property_slug>-brochure-print.pdf         # print master (300 dpi, ~65–100 MB) — canonical archive artefact
  <property_slug>-brochure-web.pdf           # web/digital deliverable (192 dpi, ~25–45 MB) — emailed/attached
  cover-print.png                            # canonical 300 dpi snapshot (used for print PDF + .print-img swap)
  page2-print.png … page15-print.png         # 14 inner-page snapshots
  backpage-print.png
  .snapshots-web/                            # intermediate 192 dpi PNGs for the web-PDF build (deletable after)
    cover-print.png … backpage-print.png
  images/
    <property_slug>-01.webp                  # cover hero (optimised)
    <property_slug>-02.webp                  # front elevation
    <property_slug>-NN.webp …                # remaining photos by index
  web/                                       # self-contained web bundle — see references/build.md
```

The brochure folder is **self-contained**: every image the HTML references must live under `output/images/`. Never reference `../images/`, `../../floorplans/`, `../../epc/`, or any other path that escapes `output/`. Those upstream `property-extract` directories are inputs to the brochure, not dependencies of the deliverable. A brochure that breaks if its source property assets move is wrong.

If any prior run left a flat brochure or stray PDF at `<property_dir>/` (e.g. from the legacy `brochure/output/` layout), move it to `<property_dir>/_archive/` before writing the canonical layout — never overwrite, never leave it adjacent to `output/`.

## Image renaming and optimisation

Source photos are oversized for the brochure's render slots. Optimise as you copy.

Rename **and re-encode** every photo to `{property-slug}-NN.webp` per the index table above. Use zero-padded two-digit numbers. The brochure HTML must reference `images/{property-slug}-NN.webp` — not the original camera/WhatsApp filenames, not the source path under `<property_dir>/images/`, not a path that escapes `output/`.

### Render-slot sizing

The brochure is a **print-first deliverable**. Source images target the print-master floors below; the digital PDF that gets emailed/embedded is generated by **downsampling the print PDF** (see `build.md → PDF deliverable`), not by re-encoding a smaller WebP. There is therefore no separate "digital-grade source" target — every WebP is encoded once, at print resolution.

| Slot type | Folio positions | Source-px floor | Output long edge | WebP quality |
|---|---|---|---|---|
| **Cover hero** | page 01 cover, full-bleed | **2800 px** | 2800–4000 | 90 |
| **Page banner** | aerial garden hero (p8), lifestyle break (p9), feature image (p7), back-page bg | **2800 px** | 2800–4000 | 90 |
| **Story photo** | hero spread images (p4 main, p5 main) | **2200 px** | 2200–3200 | 88 |
| **Gallery thumb** | strip cells (p4), gallery cells (p6), stacked images (p5) | **1600 px** | 1024–1600 | 84 |
| **Floor plan** | page 10 — line/text content, q80 produces visible mosquito noise on rules | **2000 px** | source size, no resize | 92 |
| **Map / screenshot** | screenshot with text labels | **2000 px** | source size, no resize | 88 |
| **EPC chart** | small, already <100KB — copy through unchanged | n/a | as-is | as-is |

**Floor plan exception — keep the source file as-is.** When the seller (or the agency's floor-plan provider) supplies a high-resolution PNG, **use it directly** — do not convert to WebP, do not downscale, do not re-encode. Floor plans are line art with thin strokes and crisp text where any quality loss is visibly worse than the same quality loss on photographic content. The same file (`<slug>-floorplan.png`) is referenced in the canonical brochure HTML, in the index.html landing page, and copied verbatim into `output/web/images/`. Because the new PDF pipeline rasterises whole pages from the rendered HTML (rather than passing the floor plan through a separate compression stage), the floor plan's quality at the PDF stage is determined by the snapshot DPI — 300 dpi for the print master, 192 dpi for the web PDF. A typical 2000 px floor plan slotted into a ~150 mm-wide brochure cell renders crisply at 300 dpi without any per-image processing. The **web bundle** also references the same `.png` (not a `.webp` substitute) — line art doesn't compress well as WebP and the file size is already modest.

The **source-px floor** is the floor on the input photo's longest edge **before optimisation** — see the `Image resolution floor` section in the **a4-print-documents** SKILL for the rationale and full thresholds.

**File-size targets.** The digital PDF derivative ships at ~2–5 MB (because users care about email/embed size). The print master ships at ~5–10 MB. Both are produced from the same source WebPs; the size split happens at the PDF stage.

**Loop CRM caveat.** Many UK estate agents export listing photos at a 1600 px ceiling (Beacons, Muvin, etc.). That sits below the cover, page-banner, and story-photo floors. When the source photos cap below the floor for an assigned slot and no higher-resolution substitute exists, raise this with the user — `DONE_WITH_CONCERNS` per the a4-print-documents rule.

**Do not upscale.** `cwebp -resize 3200 0 <1600px-src>` and `sips -Z 3200` produce a larger file with no extra detail — Lanczos-upscaled blur encoded into a heavier payload.

The print snapshot pipeline renders at 2× DPI (192 dpi → 2247×1588px landscape, 1588×2247px portrait) — see `build.md → Print snapshot capture`. 2× DPI is plenty for the screen-side reviewer to judge quality; on-page print quality is verified from the PDF directly, not from the review snapshots.

### Encoder

Use `cwebp -q <quality> -m 6 -mt -resize <width> 0 <src> -o <dst>` with the print-master widths from the table above (2800 / 2200 / 1600 px) and the matching qualities (90 / 88 / 84). Floor plans and maps use `-q 92` and `-q 88` respectively, no resize. For source images already encoded at the same quality and dimensions (a previously-optimised WebP), re-encoding rarely saves more than 1–2% — verify by comparing output size to source size, and if it grew or stayed flat, copy the source through instead.

### What to optimise

Optimise every image referenced by the HTML, including those that started as `.png` or `.jpeg`. PNG photography is the highest-leverage target. Discard the originals from `output/images/` after the WebP is in place; the source images under `<property_dir>/images/`, `<property_dir>/floorplans/`, and `<property_dir>/epc/` remain as the canonical untouched copies.

After optimisation, sanity-check: open the brochure in a browser, watch for 404s in the console, visually inspect cover and floorplan pages at 1× zoom. A typical 17-photo folio lands in the 3–6 MB range total; anything over 10 MB suggests an over-sized slot.

## Brand logo

The cover masthead, back-page masthead, and TOC credit line use the brand's logo image — never a text-only "BRAND · Property Folio" string. Two variants are required, named by the **colour of the art itself, not the surface where it goes** (matching `brand-design`'s convention):

| Filename | Art colour | Place it on |
|---|---|---|
| `<brand>-logo-light.png` | light/white pixels on transparent | Cover masthead, back-page masthead — both sit over dark golden-hour photography |
| `<brand>-logo-dark.png` | dark/brand-colour pixels on transparent | TOC credit line — sits on warm paper background |

Copy both into `output/images/` alongside the property photos. Reference them in the template at:

- `.cover-mast img` — `<img src="images/{brand}-logo-light.png" alt="{Brand}">`
- `.back-mast img` — same as cover
- `.toc-credit img` — `<img src="images/{brand}-logo-dark.png" alt="{Brand}">`

**Sanity-check before referencing.** Open the file in your IDE preview. `<brand>-logo-light.png` should look almost invisible against a white background (it's light-coloured art designed to disappear without a dark surface behind it); if you can read it crisply against white, the file is misnamed and should be `-logo-dark.png` instead. The reverse for `-logo-dark.png`. A run that wires a misnamed file into the template ships a brochure with an invisible masthead.

The masthead lockup is `[logo] | [PROPERTY FOLIO]` — the `<span class="pipe">` is a hairline divider; the `<span class="label">` is the small-caps "Property Folio" tag. Don't drop either; the lockup gives the logo air and signals what the document is.

If only **one** logo variant exists (e.g. a white-on-transparent only), use it everywhere — but on the warm-paper TOC background, either wrap it in a dark-teal pill or skip the TOC instance rather than ship an invisible logo. If **no** logo asset exists, fall back to the original text masthead (`<div class="cover-mast">BRAND · Property Folio</div>`) and remove the `<img>` and `<span>` children.

Logo height: 26px (cover), 28px (back page), 22px (TOC credit). Don't let it exceed 30px — the masthead is a piece of trim, not a billboard.
