Skip to content

cascadiacollections/bauhaus

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 Unsplash landscapes and public domain museum collections.

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

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 landscape photo from Unsplash (or CC0 landscape from Met/AIC)
  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.avif Stylized image (AVIF) for a specific date
GET /api/YYYY-MM-DD.webp Stylized image (WebP) for a specific date
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
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=86400, stale-while-revalidate=604800 — short browser TTL since "today" rolls over daily; CDN edge holds it for up to one day
/api/YYYY-MM-DD* public, max-age=31536000, s-maxage=31536000, immutable — date-specific content never changes

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). Explicit .avif and .webp extensions are also supported.

Query parameters

Parameter Description
progressive=true Serve the progressive JPEG variant for faster perceived load on slow networks. Falls back to the baseline image if the progressive variant is not available.

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+, Bun, 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 Bun, Node, Python 3.14, uv, and just.

# Install dependencies
just setup

# Download AdaIN model weights (~94 MB)
just download-models

# 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 unsplash   # Unsplash landscape (default)
just generate --source met        # Metropolitan Museum
just generate --source artic      # Art Institute of Chicago
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

# 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
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.

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.

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 Source-dependent (CC0-1.0 for museum sources, Unsplash License for Unsplash)

About

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

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors