Skip to content

Repository files navigation

Today's Bauhaus daily art

Bauhaus

Generate Daily Art License: MIT Output: Source-dependent Python 3.14 uv

Daily stylized art from public domain museum collections.

Fetches CC0 landscapes from the Metropolitan Museum of Art and Art Institute of Chicago — or landscape photos from Unsplash — applies AdaIN neural style transfer with curated style references, and serves the results via a free Cloudflare Worker API.

Scheduled runs use the CC0 museum sources, so everything published to the API is CC0 and no API key sits on the critical path. Unsplash remains available for manual runs (--source unsplash, or the source input on the Generate workflow); its licence is permissive but not CC0.

Set as your wallpaper

A new landscape is generated every day overnight (4 AM UTC). Grab it and set it in one line:

macOS

curl -sfo /tmp/bauhaus.jpg https://bauhaus.cascadiacollections.workers.dev/api/today
osascript -e 'tell application "System Events" to tell every desktop to set picture to POSIX file "/tmp/bauhaus.jpg"'

Windows (PowerShell)

Invoke-WebRequest https://bauhaus.cascadiacollections.workers.dev/api/today -OutFile "$env:TEMP\bauhaus.jpg"
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class W { [DllImport("user32.dll")] public static extern int SystemParametersInfo(int a,int b,string c,int d); }'
[W]::SystemParametersInfo(0x0014,0,"$env:TEMP\bauhaus.jpg",0x01)

Linux (KDE Plasma)

curl -sfo /tmp/bauhaus.jpg https://bauhaus.cascadiacollections.workers.dev/api/today
dbus-send --session --dest=org.kde.plasmashell --type=method_call /PlasmaShell org.kde.PlasmaShell.evaluateScript "string:
var d = desktops(); for (var i = 0; i < d.length; i++) { d[i].wallpaperPlugin = 'org.kde.image';
d[i].currentConfigGroup = ['Wallpaper','org.kde.image','General']; d[i].writeConfig('Image','file:///tmp/bauhaus.jpg'); }"

Linux (GNOME)

curl -sfo /tmp/bauhaus.jpg https://bauhaus.cascadiacollections.workers.dev/api/today
gsettings set org.gnome.desktop.background picture-uri "file:///tmp/bauhaus.jpg"
gsettings set org.gnome.desktop.background picture-uri-dark "file:///tmp/bauhaus.jpg"

Automate it with a cron job, Task Scheduler, or systemd timer to get fresh art on your desktop every morning.

How it works

GitHub Actions (daily, 4 AM UTC / 8 PM PT; retried at 10 AM UTC)
  1. Fetch CC0 landscape from Met/AIC (falling back to the other collection
     if one comes up empty; or a photo from Unsplash on demand)
  2. Pick curated style ref (Monet, Hokusai, Cezanne, Turner, ...)
  3. AdaIN style transfer (CPU, ~5s at native resolution)
  4. Score the result (heuristics + NIMA) and record it in the metadata
  5. Generate AVIF + WebP variants for smaller files and faster loads
  6. Upload original + stylized (JPEG/AVIF/WebP) + metadata to Cloudflare R2
         |
  CF Worker API <-- R2 bucket
    GET /api/today      -> stylized image (AVIF/WebP/JPEG via content negotiation)
    GET /api/today.json -> metadata
    GET /api/:date      -> archive

Runs daily via GitHub Actions. Total cost: $0/month.

Component Monthly cost
Cloudflare R2 (10 GB free) $0
Cloudflare Workers (100k req/day free) $0
GitHub Actions (public repo) $0

API

Base URL: https://bauhaus.cascadiacollections.workers.dev

Endpoint Returns
GET /api/today Today's stylized image (content-negotiated: AVIF → WebP → JPEG)
GET /api/today.json Today's metadata (title, artist, source, license, variants)
GET /api/today.manifest.json Variant manifest (srcset / responsive helper)
GET /api/YYYY-MM-DD Stylized image for a specific date (content-negotiated)
GET /api/YYYY-MM-DD/original Original unstylized image
GET /api/YYYY-MM-DD.json Metadata for a specific date
GET /api/YYYY-MM-DD.manifest.json Variant manifest for a specific date
GET /api/YYYY-MM-DD.json.sig Detached PGP signature over that date's metadata JSON (404 when signing is off)
GET /api/archive The dates that have published artwork, newest first (paginated)
GET /api/health Publish freshness for uptime monitoring — 200 when current, 503 when stale
POST /api/vitals Ingest Web Vitals RUM (Analytics Engine)
POST /api/err Ingest JS error RUM (Analytics Engine)

All GET endpoints also support HEAD — returns the same response headers (including Content-Type, ETag, and Cache-Control) with no body. This enables browser <link rel="preload"> validation and CDN cache priming.

Cache-Control

Endpoint pattern Cache-Control
/api/today* public, max-age=300, s-maxage=3600, stale-while-revalidate=604800 — short browser TTL since "today" rolls over daily; the edge TTL stays below the 24h publish interval so a PoP cannot serve yesterday's artwork past the next run
/api/archive Same as /api/today* — the index gains an entry every morning, so it cannot be immutable
/api/YYYY-MM-DD* public, max-age=31536000, s-maxage=31536000, immutable — safe because publishing is write-once: the pipeline refuses to rewrite a date unless --overwrite is passed. The check runs twice, once before the fetch so a collision costs nothing and once immediately before the first upload, which is the one that closes the race

Archive index

Every other date endpoint requires knowing the date already. /api/archive answers which dates exist, so a gallery or a "random past day" consumer has something to enumerate:

curl -s https://bauhaus.cascadiacollections.workers.dev/api/archive?limit=3
{
  "dates": ["2026-08-30", "2026-08-29", "2026-08-28"],
  "count": 3,
  "total": 184,
  "next": "/api/archive?limit=3&before=2026-08-28"
}
Query parameter Values Description
limit 1–1000 (default 100) Dates per page
before YYYY-MM-DD Return only dates earlier than this one

Paging is by date rather than an opaque cursor: dates are immutable, so a next link means the same thing tomorrow as it does today and can be constructed by hand. next is absent on the last page. An invalid limit or before is a 400 rather than a silently corrected page. A truncated: true field, which should not occur for decades, means the listing hit its internal round-trip cap and total counts only what was walked.

Fetch each date's metadata or image with the regular endpoints:

BASE=https://bauhaus.cascadiacollections.workers.dev
for date in $(curl -s "$BASE/api/archive?limit=5" | jq -r '.dates[]'); do
  curl -s "$BASE/api/$date.json" | jq -r '"\(.date)  \(.title) — \(.artist)"'
done

Responsive image consumer snippet

Use the manifest endpoint or content-negotiation directly with a <picture> element for optimal LCP performance:

<picture>
  <source type="image/avif" srcset="https://bauhaus.cascadiacollections.workers.dev/api/today">
  <source type="image/webp" srcset="https://bauhaus.cascadiacollections.workers.dev/api/today">
  <img
    src="https://bauhaus.cascadiacollections.workers.dev/api/today"
    alt="Daily stylized art"
    fetchpriority="high"
    loading="eager"
  >
</picture>

For preload hints in <head>:

<link
  rel="preload"
  as="image"
  href="https://bauhaus.cascadiacollections.workers.dev/api/today"
  imagesrcset="https://bauhaus.cascadiacollections.workers.dev/api/today"
  type="image/avif"
>

Image format negotiation

Image endpoints (/api/today, /api/YYYY-MM-DD) support automatic format selection. The Worker inspects the Accept header and serves the best available pre-generated variant (AVIF → WebP → JPEG). If the preferred format is missing, it falls back to JPEG.

Query parameter Values Description
format auto (default), jpeg, avif, webp Explicit format override
progressive true Serve progressive JPEG variant
strip true Serve EXIF-stripped (privacy-safe) variant

All image responses include a Vary: Accept header for correct caching.

The Worker uses Accept header content negotiation for the base image endpoints. If the client sends Accept: image/avif, the AVIF variant is returned (falling back to JPEG if unavailable). Use the format query parameter above to override it explicitly; an unrecognised value is rejected with 400 rather than silently negotiated.

Telemetry

Two first-party RUM endpoints persist to Workers Analytics Engine. Only requests from allowed origins (configured via ALLOWED_ORIGINS) are accepted. The Beacon API is used on the client side — both endpoints only accept POST.

POST /api/vitals — Web Vitals

Wire format:

{
  "name": "LCP",
  "value": 1234.5,
  "id": "v3-1234567890-1234",
  "rating": "good",
  "navigationType": "navigate",
  "url": "https://kevintcoughlin.com/"
}

name accepts LCP, INP, CLS, FCP, or TTFB. rating is good, needs-improvement, or poor. Persisted to the web_vitals Analytics Engine dataset.

POST /api/err — JS Errors

Wire format:

{
  "message": "Uncaught TypeError: ...",
  "source": "https://kevintcoughlin.com/bauhaus.js",
  "lineno": 42,
  "colno": 7,
  "stack": "..."
}

stack is optional and truncated to 1 KB by the client before sending. Persisted to the web_errors Analytics Engine dataset.

Behavior

Condition Response
Allowed origin, valid body 204 No Content
Disallowed or missing Origin 403 Forbidden
Non-POST method 405 Method Not Allowed
Body > 4 KB 413 Payload Too Large
OPTIONS preflight (allowed origin) 204 with CORS headers

CORS response includes Access-Control-Allow-Origin: <echoed>, Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: content-type.

Query stored data via the Cloudflare dashboard SQL editor or:

wrangler analytics-engine sql 'SELECT * FROM web_vitals LIMIT 10'
wrangler analytics-engine sql 'SELECT * FROM web_errors LIMIT 10'

Local development

Requires mise (or manually install uv, Python 3.14+, Node.js 24+, and just).

For a ready-to-use dev environment, open this repo in VS Code and choose "Reopen in Container" — the included .devcontainer/ setup provisions Node, Python 3.14, uv, and just.

# Install dependencies
just setup

# Download AdaIN + NIMA model weights (~107 MB)
just download-models

# Re-fetch the curated CC0 style references (already checked in)
just download-styles

# Run tests
just test

# Generate locally (no R2 upload)
just generate

# Generate benchmark metrics for parity tracking
just benchmark-generate --max-size 1536

# Enforce local benchmark thresholds
just benchmark-gate

# Options (extra args forwarded to src/main.py)
just generate --source met        # Metropolitan Museum (default)
just generate --source artic      # Art Institute of Chicago
just generate --source unsplash   # Unsplash — needs UNSPLASH_ACCESS_KEY
just generate --alpha 0.5         # subtle style (0.0-1.0)
just generate --any-subject       # disable landscape filter
just generate --max-size 1536     # higher processing resolution
just generate --no-score          # skip NIMA scoring (heuristics still run)
just generate --overwrite         # republish a date already in R2
just generate --skip-if-published # exit 0 instead of failing if the date exists

# List all available recipes
just

Docker / Podman

just docker-build
just docker-run

# Podman-compatible equivalents
podman build -t bauhaus .
podman run --rm -v "$PWD/output:/app/output" --env-file .env bauhaus --dry-run

Use a rootless Podman setup and a writable bind mount for output/ if you want to keep generated files on the host.

Worker

mise install          # provision Python, Node, uv, just
just setup-all        # install project deps (uv sync + npm ci)
just worker-dev       # start local dev server
just worker-check     # typecheck

Configuration

Variable Description
R2_ENDPOINT Cloudflare R2 S3-compatible endpoint
R2_ACCESS_KEY_ID R2 access key
R2_SECRET_ACCESS_KEY R2 secret key
R2_BUCKET Bucket name (default: bauhaus)
STYLE_MODE curated (rotate shipped styles) or random (fetch second CC0 painting)
UNSPLASH_ACCESS_KEY Unsplash API access key. Only needed for --source unsplash; the CC0 museum sources require no credentials.
LANDSCAPES_ONLY true (default) bias toward landscapes/seascapes, false for any subject
MEMORY_PROFILE balanced (default) or low-memory. low-memory caps MAX_SIZE at 1024 and disables variant generation by default to fit constrained CPU/RAM runners.
GENERATE_VARIANTS Generate AVIF and WebP variants alongside JPEG (default: true, or false in low-memory)
MAX_SIZE Max processing resolution in pixels (default: 1280). Lower values are better for the current CPU-only/free-tier runner; low-memory caps this at 1024.
METRICS_OUT Write run timing/resource metrics JSON to this path (also --metrics-out). Used by the benchmark workflow.
METRICS_LABEL Optional label recorded in the metrics JSON to annotate benchmark runs (also --metrics-label).
NTFY_TOPIC ntfy.sh topic for success/failure notifications. Set as a repository secret; notification steps are skipped when unset.

Secrets and deployment hygiene

  • Keep secrets in GitHub Actions secrets / local .env files only; never print them in logs.
  • The production workflow uses R2_* and UNSPLASH_ACCESS_KEY from secret storage, not hard-coded values.
  • For local Podman runs, pass env vars via --env-file .env or a secret manager rather than embedding them in shell history.

Metadata signing (PGP)

Each day's metadata/<date>.json can be published alongside a detached signature at metadata/<date>.json.sig, letting consumers verify the metadata came from this pipeline. Signing is currently dormant — the workflow skips the Import GPG key step whenever GPG_PRIVATE_KEY is unset, and sign_metadata() returns None rather than failing, so runs succeed silently without a signature.

To turn it on, set three repository secrets:

Secret What it is
GPG_PRIVATE_KEY ASCII-armoured private key. Gates the workflow's import step, and on its own is enough to enable signing.
GPG_KEY_ID Key ID or fingerprint, passed to gpg --local-user. Optional when the imported key is the only secret key present.
GPG_PASSPHRASE Passphrase, if the key has one. Omit for a passphrase-less key.

Setting any one of the three turns signing on. The signature covers the canonical JSON bytes that are uploaded, byte for byte.

Verify a published day:

BASE=https://bauhaus.cascadiacollections.workers.dev
curl -sf "$BASE/api/2026-01-02.json"     -o metadata.json
curl -sf "$BASE/api/2026-01-02.json.sig" -o metadata.json.sig
gpg --verify metadata.json.sig metadata.json

Generating and exporting a signing key:

# Create a signing-only key (no expiry prompt in batch mode)
gpg --quick-generate-key "Bauhaus <you@example.com>" ed25519 sign never

# Fingerprint → GPG_KEY_ID
gpg --list-secret-keys --keyid-format=long

# Private key → GPG_PRIVATE_KEY (paste the whole armoured block)
gpg --armor --export-secret-keys <KEY_ID>

# Public key — publish this so consumers can verify
gpg --armor --export <KEY_ID>

The signature covers the metadata JSON only, not the image bytes.

Aesthetic scoring

Every published image is scored and the result is recorded under aesthetic in that date's metadata JSON. Two independent signals are stored side by side:

"aesthetic": {
  "score": 7.57,
  "sharpness": 3312.24,
  "colorfulness": 42.2,
  "contrast": 47.67,
  "method": "heuristic-v1+nima-mobilenet-v1",
  "nima_mean": 5.267,
  "nima_std": 1.601
}
Field Meaning
score 0–10 heuristic signal combining sharpness, colorfulness, and contrast. Cheap, and good at catching degenerate output (flat, blurred, washed out)
sharpness, colorfulness, contrast The raw heuristic components, in their own units
nima_mean NIMA aesthetic score on the 1–10 AVA scale, from a MobileNet-v1 model trained on human ratings
nima_std Spread of NIMA's predicted rating distribution — a confidence signal, not a quality one
method Which scorers ran: heuristic-v1, or heuristic-v1+nima-mobilenet-v1 when NIMA was included

The two are deliberately not merged. score keeps the same meaning in every record ever published, and NIMA is a learned judgement rather than a measurement — treat nima_mean as arbitrary units useful for ranking bauhaus's own outputs against each other, not as an absolute verdict. NIMA was trained on photographs, so stylized artwork sits outside its training distribution; it still reliably ranks blur, mud, and lost detail below a clean result.

Scoring adds roughly 0.5 s to a run (weights load, then ~60 ms of inference) and never fails it: if the model is missing or errors, the run logs a warning and publishes with the heuristics alone. Pass --no-score to skip NIMA entirely.

The weights are the MIT-licensed MobileNet-v1 NIMA model from titu1994/neural-image-assessment (0.0804 EMD on the AVA validation split), published in Keras format. models/download_models.py fetches them under a pinned SHA-256 and converts them once into models/weights/nima_mobilenet.npz, so generation needs only torch and numpy. The PyTorch port matches the original Keras graph to within float32 rounding (2.4e-07 max absolute difference on the output distribution).

Style references

10 curated CC0 paintings shipped in styles/, spanning Impressionism, Post-Impressionism, Japonisme, and Pointillism:

Monet, Hokusai, Cezanne, Turner, Hiroshige, Seurat, Degas, Klimt, Van Gogh, Gauguin

Licensing

Component License
Code MIT
Input art (Unsplash) Unsplash License (allows derivatives and commercial use)
Input art (Met/AIC) CC0 (public domain collections)
Style references CC0 (same museum sources)
AdaIN model MIT (naoto0804/pytorch-AdaIN)
NIMA scoring model MIT (titu1994/neural-image-assessment)
VGG-19 encoder BSD-like (torchvision)
Output images CC0-1.0 for scheduled runs, which use the museum sources. Runs explicitly pointed at Unsplash carry the Unsplash License instead — each image's metadata.json records which.

About

Daily stylized art from CC0 museum collections — AdaIN style transfer on Cloudflare Workers API

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages