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.
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.
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 |
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.
| 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 |
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 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.
| 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. |
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.
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.
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.
| 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'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
justjust 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-runUse a rootless Podman setup and a writable bind mount for output/ if you want to keep generated files on the host.
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| 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. |
- Keep secrets in GitHub Actions secrets / local
.envfiles only; never print them in logs. - The production workflow uses
R2_*andUNSPLASH_ACCESS_KEYfrom secret storage, not hard-coded values. - For local Podman runs, pass env vars via
--env-file .envor a secret manager rather than embedding them in shell history.
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
| 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) |