Skip to content
cascadiacollectionsPublic

About

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

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

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 prefer the CC0 museum sources, so output is normally CC0 and no API key sits on the critical path. If both museums are unavailable, the scheduled emergency fallback may publish an Unsplash image instead; this requires UNSPLASH_ACCESS_KEY. Unsplash remains available for manual runs (--source unsplash, or the source input on the Generate workflow); its licence is permissive but not CC0. Check each image's metadata.json for its licence.

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. Publish original + stylized (JPEG/AVIF/WebP) + metadata as a GitHub
     release (tag art-YYYY-MM-DD): built as a draft, then published
     (immutable). Each day's release carries dates.json, the full date list
         |
  CF Worker API <-- GitHub release assets (edge-cached)
    GET /api/today      -> stylized image (AVIF/WebP/JPEG via content negotiation)
    GET /api/today.json -> metadata
    GET /api/:date      -> archive
    cron 11:30 UTC      -> commit new metadata + manifests to a Git history

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

Component Monthly cost
GitHub Releases (art storage; R2 was turned off in November 2026) $0
Cloudflare Workers (100k req/day free) $0
Cloudflare Artifacts (10k ops + 1 GB/month included with Workers Paid) $0 — about 8 ops a day, ~2 KB a day
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/feed.atom Atom feed of the newest 30 days, for feed readers
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/feed.atom public, max-age=3600 with a strong ETag and Last-Modified; conditional requests get a 304 from the dates.json index alone
/api/YYYY-MM-DD* public, max-age=31536000, s-maxage=31536000, immutable — safe because publishing is write-once: the repo uses GitHub immutable releases, so a published date can never be rewritten (--overwrite fails with an explanation). Each day is assembled as a draft and published as the last step; the check runs before the fetch, before the draft, and again just before publishing

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.

Git history

A daily cron in the Worker commits each newly published day's metadata.json, manifest.json and (when signing is on) metadata.json.sig to the bauhaus-history repo in Cloudflare Artifacts, under YYYY/MM/DD/. Images stay in the GitHub releases. Clone it with any Git client and a repo token, then git log -p shows exactly what was published and when. Each commit message names the newest date it records, so a run only reads one log entry plus the days after it, and a missed day is caught up by the next run (up to HISTORY_DAYS, default 7, back). See worker/src/history.ts.

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.

Python 3.15 compatibility

Python 3.14 remains the supported generation runtime. CI probes Python 3.15 on Linux and macOS ARM64 using the checked-in lockfile, published wheels only, import checks, and the full generation test suite. These experimental jobs are non-blocking: a green overall CI run does not establish Python 3.15 support. Each job's summary reports interpreter/tool availability, installation, import, and test outcomes. Failed probes produce a warning and retain their failure logs; missing PyTorch/torchvision wheels are reported as installation failures. A green compatibility-report job means the report completed, not that its probes passed.

Before promoting 3.15, require both compatibility jobs to pass and compare a full dry-run generation against 3.14 on the deployment platforms, including output quality, total runtime, style-transfer runtime, and peak memory. Update the Docker, mise, generation workflow, and tool target pins together only after that validation.

Development commands

# 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 (nothing published)
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         # no-op guard: published dates are immutable
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
GITHUB_TOKEN Token with contents: write on the repo, used to publish each day as a GitHub release (GH_TOKEN also works). The workflow passes its own GITHUB_TOKEN.
BAUHAUS_REPO Repository to publish releases to (default: GITHUB_REPOSITORY, else cascadiacollections/bauhaus)
STYLE_MODE curated (rotate shipped styles) or random (fetch second CC0 painting)
UNSPLASH_ACCESS_KEY Unsplash API access key. Needed for --source unsplash and for the scheduled emergency fallback when both CC0 museum sources fail; 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 GITHUB_TOKEN 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 when a museum source succeeds. If both museums fail, the scheduled emergency fallback publishes an image under the Unsplash License instead; runs explicitly pointed at Unsplash carry that license too. Check each image's metadata.json to verify which license applies.

About

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

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages