Skip to content

elixir-image/image_playground

Repository files navigation

image_playground

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_componentsimage_plugimage (libvips) → image_vision (face detection) and back to a real transformed image. There is no mock backend.

What you get

  • Drag-and-drop upload. Files are content-addressed (SHA-256 + extension) under priv/uploads/ (or whatever UPLOAD_DIR points at), deduped on re-upload, and pruned after 24 h by ImagePlayground.Uploads.Cleaner.

  • Single canonical IR. A %Image.Plug.Pipeline{} is the source of truth. Every slider and dropdown mutates one field on the struct via handle_event("update_control", …).

  • Five URL projectors. Image.Components.URL.{cloudflare,cloudinary,imgix,imagekit,iiif}/2 emit 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 = face invokes Image.FaceDetection via image_plug's Image.Plug.FaceAware seam. 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.

Quick start (local Elixir)

Requires Erlang/OTP 27 and Elixir 1.18, plus libvips with HEIF/AVIF support. On macOS:

brew install vips

Then:

git clone https://github.com/elixir-image/image_playground.git
cd image_playground
mix setup       # deps + assets
mix phx.server
open http://localhost:4000

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

Self-hosted Docker

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.

Prerequisites

  • 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) plus color. The path overrides in mix.exs resolve against __DIR__/.., so the in-container layout has to mirror the host. A .dockerignore at the build-context root is shipped to keep the context to the source files only.

Build

From the directory that holds all sibling repos (the parent of image_playground):

docker build --progress=plain -f image_playground/Dockerfile -t image_playground .

Run

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:4000

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

Docker environment variables

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.

Deploy to fly.io

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 deploy

After deploy, the app is reachable at https://<your-app>.fly.dev/.

What the fly.toml sets

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.

Cost notes

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

Layout

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

Routes

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)

Architecture notes

The two design decisions that shape everything else:

  1. One canonical IR, projected five ways. A single %Image.Plug.Pipeline{} lives in socket.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 same pipeline_to_image_attrs/1 derivation, so the snippet you see is literally the call rendering the preview above it.

  2. Real image_plug mounts, no mock. Each provider is forwarded at the outermost path that needs to remain in path_info (e.g. forward "/img", not forward "/cdn-cgi/image"). The provider URL parsers expect to see their natural path prefix in path_info; forwarding at the sub-prefix would strip those segments and every request would fail with malformed_url. The forwards go through ImagePlaygroundWeb.RuntimeImagePlug, a thin wrapper that resolves :source_resolver from ImagePlayground.Uploads.dir/0 at request time — necessary because forward evaluates 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.

License

Apache-2.0 — matches the rest of the elixir-image ecosystem.

About

Deployable playground for images using image_plug and image_components

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors