A Phoenix LiveView app that exercises the four-library elixir-image ecosystem end-to-end: drop an image, tweak transforms with sliders, and watch five URL grammars (Cloudflare Images, Cloudinary, imgix, ImageKit, and IIIF Image API 3.0) plus the equivalent <.image> / <.picture> HEEx update live next to a rendered preview.
The same image_plug server that powers the five endpoints in production runs in-process, so every URL the playground emits actually resolves through image_components → image_plug → image (libvips) → image_vision (face detection) and back to a real transformed image. There is no mock backend.
-
Drag-and-drop upload. Files are content-addressed (SHA-256 + extension) under
priv/uploads/(or whateverUPLOAD_DIRpoints at), deduped on re-upload, and pruned after 24 h byImagePlayground.Uploads.Cleaner. -
Single canonical IR. A
%Image.Plug.Pipeline{}is the source of truth. Every slider and dropdown mutates one field on the struct viahandle_event("update_control", …). -
Five URL projectors.
Image.Components.URL.{cloudflare,cloudinary,imgix,imagekit,iiif}/2emit one URL each from the same IR. The URL panel shows all five side by side with byte counts and copy buttons; clicking a row swaps the live preview to that provider. -
Live HEEx snippet. The same IR is serialised into a faithful
<Image.Components.image>(or<Image.Components.picture>) call. The snippet you copy is literally what's painting the preview above it — they cannot drift. -
Real face detection. Selecting
gravity = faceinvokesImage.FaceDetectionviaimage_plug'sImage.Plug.FaceAwareseam. The YuNet ONNX model is downloaded lazily on first use and cached. -
Vignette + tint with honest provider gaps — vignette only lights up the Cloudinary URL row, tint only the imgix row, because those are the only CDNs whose URL grammar can carry them.
Requires Erlang/OTP 27 and Elixir 1.18, plus libvips with HEIF/AVIF support. On macOS:
brew install vipsThen:
git clone https://github.com/elixir-image/image_playground.git
cd image_playground
mix setup # deps + assets
mix phx.server
open http://localhost:4000mix setup fetches image, image_plug, image_vision, image_components, and color. If you have those checked out as siblings (the common dev layout — ../image, ../image_plug, etc.), mix.exs automatically prefers the on-disk path over the Hex version via image_dep_with_path/2, so unpublished work flows through transparently.
The Dockerfile is a multi-stage build that compiles libvips from source against libheif + libaom (so AVIF encode works on Debian bookworm), installs the Rust toolchain so Ortex's NIF can build, fetches and compiles all Hex deps including image_vision's ML stack (Ortex / Nx / EXLA / Bumblebee), produces a Mix release, and copies the release plus the runtime libvips into a debian:bookworm-slim final image.
The image is approximately 745 MB. Cold-cache builds take 15-25 minutes (libvips compile is the long pole); cached rebuilds finish in under a minute.
-
Docker Desktop ≥ 27, with at least 6 GB of memory allocated (Settings → Resources → Memory). 8 GB is comfortable. Older versions or smaller allocations OOM-kill the daemon during the libvips link or the Ortex NIF build.
-
The build context is the parent directory containing all five sibling libraries (
image,image_plug,image_vision,image_components,image_playground) pluscolor. The path overrides inmix.exsresolve against__DIR__/.., so the in-container layout has to mirror the host. A.dockerignoreat the build-context root is shipped to keep the context to the source files only.
From the directory that holds all sibling repos (the parent of image_playground):
docker build --progress=plain -f image_playground/Dockerfile -t image_playground .docker run -d --rm --name image_playground \
-p 4000:4000 \
-e SECRET_KEY_BASE="$(openssl rand -base64 64)" \
-e PHX_HOST=localhost \
-v "$(pwd)/uploads:/app/priv/uploads" \
-v "$(pwd)/models:/app/priv/models" \
image_playground
open http://localhost:4000The first request that needs face detection downloads the YuNet ONNX model into /app/priv/models/. Mount that volume to a host path to keep the model between container restarts.
| Variable | Default | Purpose |
|---|---|---|
SECRET_KEY_BASE |
(required) | Cookie / session signing key. Generate with openssl rand -base64 64. |
PHX_HOST |
localhost |
The host part of generated absolute URLs. |
PORT |
4000 |
Listen port inside the container. |
URL_PORT |
4000 |
Port part of generated absolute URLs (set to 443 behind a TLS proxy). |
URL_SCHEME |
http |
URL scheme for generated absolute URLs (set to https behind TLS). |
UPLOAD_DIR |
/app/priv/uploads |
Absolute path of the upload directory; mount your volume here. |
IMAGE_VISION_CACHE_DIR |
(unset) | Absolute path for the YuNet face-detection model cache. Mount a volume here to keep the model between container restarts. |
A second Dockerfile (Dockerfile.fly) plus a fly.toml ship a slimmer variant — same playground, smaller image, configured for fly.io's machines runtime. The slim image drops the libvips formats the playground never emits (PDF, FITS, MatLab, OpenSlide), trimming ~150 MB. It also pulls the four image-org libs from Hex rather than from sibling checkouts, so the build context is just the image_playground/ directory itself — no parent-dir wrangling.
The slim variant assumes image, image_plug, image_vision, and image_components are all published to Hex. Until they are, deploy via the full Dockerfile from a parent-dir build context as described in the previous section.
The default target is shared-cpu-2x with 2 GB RAM and a 3 GB persistent volume, which holds both uploads (/data/uploads) and the face-detection model cache (/data/models). Auto-stop is on, so a personal/demo deployment with light traffic costs ~$3/mo all-in (machine + volume) at fly.io's late-2025 prices; always-on adds ~$5/mo. Cold start from suspended is ~5–10 s.
# From inside image_playground/ (where fly.toml lives):
# Edit fly.toml first to set `app` and `primary_region` to your own.
fly launch --no-deploy --copy-config
# A 64-byte secret signed key for cookies / sessions.
fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64)"
# 3 GB volume holding /data (uploads + model cache).
fly volumes create image_playground_data --size 3 --region iad
# First deploy — pulls deps from Hex, compiles libvips + Rust + the
# release. Cold build takes ~15-25 min (the libvips compile is the
# long pole); subsequent deploys finish in 1-2 min thanks to layer
# caching.
fly deployAfter deploy, the app is reachable at https://<your-app>.fly.dev/.
| Setting | Value | Why |
|---|---|---|
| VM size | shared-cpu-2x, 2 GB |
Two shared cores comfortably handle one user's libvips bursts plus the YuNet face-detection model loaded into RAM. Bump to 4 GB for heavier traffic. |
| Auto-stop | suspend |
Suspends the machine on idle, resumes on next request. Suspend is faster than stop (RAM is preserved). |
min_machines_running |
0 |
Lets the machine fully suspend; set to 1 to keep one always-on at the cost of ~$5/mo. |
Volume /data |
3 GB | Uploads + face-detection model. Bump if your users upload a lot. |
| Concurrency | soft 30 / hard 50 | Bound concurrent libvips encodes so a 2-core shared machine doesn't thrash. |
- Machine (
shared-cpu-2x, 2 GB, with auto-stop): ~$1–3/mo for a demo (mostly suspended), ~$8/mo always-on. - Volume (3 GB at $0.15/GB/mo): ~$0.45/mo.
- Bandwidth: free up to 100 GB outbound per region per month — a demo will not exceed this.
- TLS / IPv4: shared IPv4 + automatic Let's Encrypt cert via fly.io's load balancer, no extra cost.
Verify current pricing at fly.io/docs/about/pricing — values change.
lib/
image_playground/
application.ex # supervisor: endpoint + Uploads.Cleaner
uploads.ex # content-addressed disk store; reads :upload_dir
uploads/cleaner.ex # 24h-old upload pruner (GenServer)
image_playground_web/
router.ex # /, /img, /cloudinary, /imgix, /imagekit, /iiif/3
runtime_image_plug.ex # wraps Image.Plug to resolve :source_resolver at request time
endpoint.ex # plug stack incl. /uploads static
live/playground_live.ex # the single LV
components/layouts/* # root + app layouts (data-theme="dark")
assets/
css/app.css # Tailwind + ip-* dark palette tokens
js/app.js # CopyToClipboard hook + LiveSocket boot
config/
*.exs # standard Phoenix config; runtime.exs honours UPLOAD_DIR + IMAGE_VISION_CACHE_DIR
Dockerfile # full local build: every libvips format, sibling-checkout deps, kitchen-sink
Dockerfile.fly # slim deploy build: lean libvips formats, hex deps, ~150 MB smaller
fly.toml # fly.io machines config: shared-cpu-2x, auto-stop, 3 GB volume
.dockerignore
GET / ImagePlaygroundWeb.PlaygroundLive (the playground UI)
GET /uploads/<sha>.<ext> Plug.Static — original uploads (read from UPLOAD_DIR)
GET /img/cdn-cgi/image/... Image.Plug — Cloudflare grammar
GET /cloudinary/<account>/... Image.Plug — Cloudinary grammar
GET /imgix/<source>?<query> Image.Plug — imgix grammar
GET /imagekit/<endpoint>/tr:... Image.Plug — ImageKit grammar (endpoint stripped via :endpoint option)
GET /iiif/3/<id>/<region>/... Image.Plug — IIIF Image API 3.0 grammar (incl. /info.json)
The two design decisions that shape everything else:
-
One canonical IR, projected five ways. A single
%Image.Plug.Pipeline{}lives insocket.assigns.pipeline. Every control event mutates one field on it. The URL panel calls five projectors —Image.Components.URL.{cloudflare,cloudinary,imgix,imagekit,iiif}/2— over the same IR; the five URLs always describe the same intent (modulo per-grammar gaps — IIIF can't carry effects, gravity, or numeric quality, so its row stays static when those sliders move). The HEEx snippet panel and the live<.image>call both consume the samepipeline_to_image_attrs/1derivation, so the snippet you see is literally the call rendering the preview above it. -
Real
image_plugmounts, no mock. Each provider isforwarded at the outermost path that needs to remain inpath_info(e.g.forward "/img", notforward "/cdn-cgi/image"). The provider URL parsers expect to see their natural path prefix inpath_info; forwarding at the sub-prefix would strip those segments and every request would fail withmalformed_url. The forwards go throughImagePlaygroundWeb.RuntimeImagePlug, a thin wrapper that resolves:source_resolverfromImagePlayground.Uploads.dir/0at request time — necessary becauseforwardevaluates its options at compile time, which would otherwise bake in whatever the upload dir happened to be when the release was built (e.g. the builder stage's/build/...).
The host app is the source of truth for the upload directory (ImagePlayground.Uploads.dir/0, configurable via UPLOAD_DIR); both Plug.Static (for original uploads) and the four image_plug source resolvers read from it on every request.
Apache-2.0 — matches the rest of the elixir-image ecosystem.