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)
  1. Fetch CC0 landscape from Met/AIC (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. Generate AVIF + WebP variants for smaller files and faster loads
  5. 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/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/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

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 model weights (~94 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 --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.

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)
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