Playground
A web UI to try every URL dialect, option and source against a running server, and to copy the result as a URL or as code for any official client. It ships inside the server binary and is off by default. Turn it on and open it on the admin listener:
docker run --rm -p 127.0.0.1:8080:8080 -p 127.0.0.1:9090:9090 \ -e XI_DEV_MODE=true -e XI_ADMIN_ADDR=:9090 -e XI_PLAYGROUND_ENABLED=true \ ghcr.io/xantiagoma/xtandard-image:0.1.1What it does:
- Sources: a sample gallery (built-in fixtures that work offline, plus about 300 curated public files covering every input format and its variants: colour types, bit depths, animation, HDR and gain maps, camera RAW per brand, and the formats not decoded yet; each with its licence, docs/research/format-gap.md), drag-and-drop uploads kept in memory for 10 minutes (
XI_PLAYGROUND_UPLOADS), a browser forfssources, or any URL /xi://reference. - Option builder for every dialect: imgproxy, the query form, imgix, Cloudinary, ImageKit, Cloudflare, wsrv, thumbor, Bunny and AWS SIH, from each dialect’s catalogue, grouped by category (resize, gravity incl. face and object classes, format and quality, colour, effects, watermarks, detection, pages/frames/video, metadata, delivery, presets). Typed inputs (bounded numbers with sliders, enums, colour pickers, booleans, tuples and repeatable groups), inline docs, aliases, a search box (
/) and chained pipelines (imgproxy/-/, ImageKit steps, Cloudinary components, so overlays such asl_…+fl_layer_applyandl-image/l-textcan be built). “Write the path and query by hand” covers what the builder cannot express (imaginary endpoints such as/watermark). - URL panel: the live, colour-coded URL (signed on the server), copy as URL, code for curl and every official client (below), and the same transform in every other dialect (
POST api/translate): each URL is parsed back by its dialect and what it cannot express is listed; a dialect that cannot address the source at all says why (ImageKit, Bunny and AWS SIH take keys on their mount’s source: an http(s) URL under that source’sbase_urlis written as a key). A mount with several prefixes (a simple provider’s native prefixes and/simple/<name>/) is listed once, with a prefix picker. - Code: the URL as a
curlcommand or as a complete program for any of the twelve official clients (TypeScript, Python, Go, PHP, Ruby, Rust, Java, Kotlin, C#, Elixir, Swift, Dart): imports, the builder with its settings, the call in the form each client’s README recommends (option mappings, fluent chains, builders, DSL blocks) and the URL printed. It follows the builder live, for images (chained pipelines, extension,plain/base64 sources, anenc/source on request, a PDF password), the Info tab’s/info,/video, HLS and DASH streams (renditions, segment duration and format) and the query dialect (/q,/q/video, imaginary endpoints); other dialects show the URL and curl with a note (andsignXiUrlfor mounts signed withxi_sig). Keys never appear: the code readsXI_SIGNING_KEY,XI_SIGNING_SALT,XI_ENCRYPTION_KEYandXI_PDF_PASSWORDfrom the environment. Syntax highlighting (light and dark), copy, line wrap, the language remembered in this browser. The generator builds a code model once and lets each language refine and print it (internal/playground/assets/snippets/: request → language-neutral model → per-language refiner → Doc pretty-printer);task playground:snippetsruns every language’s snippets with the real client (scripts/playground-snippets-check.ts) and checks they print the URL the TypeScript client builds. - Preview (the result): before/after slider or side by side in a box with the result’s aspect ratio (portrait, panoramas and icons are never cropped; a source of another shape is letterboxed), zoom 1×/2×/fit with pan (drag or arrow keys), a checkerboard for alpha, inline animations and video results, a loading placeholder that never shows a stale result (only the latest request paints), a Source vs result table (format and MIME type, dimensions, megapixels, bytes, colour space, alpha, pages, orientation; the source’s values from its own
/info, the change in a third column), the result response headers named in words (X-Xi-*debug headers too, raw name in the tooltip) and the server’s per-stageServer-Timingas a bar chart; errors show the server’s error kind and message. Multi-page sources get a page picker with thumbnails and “all pages stacked”, animations a still-frame scrubber, videos a time scrubber, keyframes, best frame, sprite and animation buttons, all written as the current dialect’s own options (disabled with a tooltip where the dialect cannot express them). - Info (the source file): the source’s
/infoas a collapsible tree, detections drawn over the preview (full image), video metadata. - Global tools (header navigation,
#tools/<name>routes that keep the session as it is): Gallery (every sample as a grid), History (the sessions rendered in this browser, with search, thumbnails, open and copy link), Format matrix (every sample probed with/infoand a thumbnail, to check a file of every format works; filters, run all, sortable by format, sample, variant, time and status) and Examples. - Examples: ready-made sessions (
internal/playground/recipes.json) as cards with a live thumbnail of each result, filter chips and search, plus a quick “Examples” menu in the Options panel. One click loads the source, dialect and options into the builder, renders them and leaves everything editable (it is an ordinary session: share link, history, Back). Watermarks in every form the server supports, each named in the URL: the repository logo as a per-request image (wmu:base64 ofxi://local/watermark/logo.png) in each gravity with offsets, opacity and scale, every corner at once through chained pipelines, a size box (wms), rotation (wmr), a drop shadow (wmsh), tiles (re, rotated tiles) and a chessboard (ch), other per-request images (wmu, a badge fixture or a public SVG), Pango text (wmt: a semi-transparent “© 2026 Studio” footer, two lines in two fonts, a tiled “DRAFT” stamp), an animated GIF, and resize → watermark → WebP as chained pipelines; the same ideas in imgix (txt,mark,mark-tile), Cloudinary (l_text:,l_fetch:withfl_layer_apply,fl_relative,fl_tiled), ImageKit (l-image,l-text) and imaginary (/watermark,/watermarkimage). Beyond watermarks: smart and face crops, blurred faces, BlurHash/ThumbHash in the Info tab, GIF → MP4, a video sprite sheet, a PDF page, a camera RAW preview and an UltraHDR gain map as HDR AVIF. Only one recipe, “Default watermark (imgproxy compatibility)”, uses the server’s default image (XI_PROCESSING_WATERMARK_SOURCE, empty by default, reported aswatermarkinapi/metawithout the source): a watermark that appears from nowhere in the URL is confusing, so the demo (compose.playground.yml) leaves it unset and that recipe is disabled with the reason, like the recipes that need the detection models or FFmpeg on a server without them. In the builder,wmwithoutwmu/wmtshows a notice (default watermark configured on this server or not) with a one-click “Use an explicit image (wmu)”;wmuand imgixmarkshow the image URL in plain text with a thumbnail, and the preview and the Info tab say where the source and each watermark image come from, with a link to the original. A Go test builds every recipe as the page does and parses it with the dialect’s strict parser. - Video and audio (with
XI_VIDEO_TRANSFORM_ENABLEDon thefullorgplimage): a video or audio source (the gallery has a clip and an MP3; uploads take audio too) gets a Video and audio panel that switches the session between an image of the source (thumbnails, frames, sprites) and a video transform (/videoon imgproxy mounts,/q/videoon query mounts). The panel has a trim timeline with start and end handles over the source’s duration, the containers and codecs this server’s build writes (MP4 is H.264 ongpl, AV1 onfull; options the image lacks are disabled and the panel says which ones this browser plays), size and frame rate, audio (mute, volume in dB, EBU R128 loudness, fades, sample rate, channels), a one-click logo overlay with a window and fades, a running timecode and waveform pictures; the builder below switches to the/videooption catalogue for everything else (crop, gravity, rotation, CRF, bit-rate cap, keyframe interval, text overlays). An audio file opens in video mode with its waveform as the source side. The preview plays the result in<video>or<audio>, counts the seconds of a transcode with a Cancel button that aborts the request, and the Source vs result table compares container, codecs, duration, size, frame rate, bit rate, sample rate and channels (api/probeandapi/probe/resultread them with FFmpeg, audio files included, which/infodoes not describe). Copy the URL, or take it ascurlor client code (videoUrl,streamUrl), and “Every dialect” writes the transform for Cloudinary/video/upload/, ImageKit video files and Cloudflare/cdn-cgi/media/, each parsed back to list what it cannot express. Video recipes in the Examples: trim and resize to WebM, MP3 with loudness normalisation, a logo faded in and out on a clip, a timecode, a waveform PNG,/q/video, Cloudinary/video/uploadtrim, Cloudflaremode=audioandmode=spritesheet, an ImageKit video resize. There are 55 media examples (filter chips Video and audio and Audio) on public, permissively licensed sources listed in the gallery with their attribution (the Sintel and Big Buck Bunny trailers and 10 s Big Buck Bunny clips in 1080p60 H.264 and HEVC, CC-BY 3.0 Blender Foundation; NASA vertical, talking-head and timelapse clips, public domain; a LibriVox reading, public domain; a Musopen Chopin waltz, CC0) plus a generated stereo pan sweep: head, middle and tail trims; 9:16 (padded and cropped with gravity), 1:1 and 4:5 social formats; rotating, mirroring and pillarboxing a vertical clip; 60 → 30 and 24 fps; a CRF 20/30/40 ladder; H.264 vs AV1 vs VP9 at the same quality setting; HEVC to WebM; muting a silent track; M4A, MP3, Opus and FLAC extraction; loudness normalisation up (quiet speech to -16 LUFS) and down (a loud trailer to -23 LUFS); volume, fades, mono downmix, 48 → 22.05 kHz resampling; three waveform styles; a corner logo, a lower third in a time window, a seconds counter and a chessboard watermark; a 3 s silent WebM loop; HLS and DASH ladders;raw:1sources;/q/video; and the same ideas in Cloudinary (/video/fetch/withf_auto,ac_none,e_volume:mute, anl_textlayer with a time window and fade,sp_sd), ImageKit (ik-master.m3u8withsr-, anl-textwindow) and Cloudflare (mode=video,mode=audiofrom 5.1,mode=frame,mode=spritesheet). Each description says what to look or listen for in the comparison. Recipes that need the gpl image’s H.264 or HEVC encoder (needs: h264/hevc) are disabled with the reason on the full image and hidden on images without FFmpeg. The demo raisesXI_FETCH_TIMEOUTto 30 s for these downloads (up to 25 MB). Before/after for media: the Source side plays the original file too, not a still:<video>or<audio>streamed same-origin from/video/<sig>/raw:1/plain/<source>(signed by the playground like any video URL, so the server’s signing, unsigned policy, allowlists, SSRF rules and input-size limit apply) throughapi/renderwith byte ranges, with the first frame (or the waveform) as its poster and a loader of its own, separate from the transcode’s. One control bar under the stage drives both players: play/pause (k), seek, speed, sound and an A/B switch (x) that moves what you hear between the source and the result at the same position, for loudness, volume and fade comparisons. The result’s time t plays against the source’s trim start + t (scaled when the durations differ, as after a speed change); the trim window is highlighted on a source timeline in every view. Videos keep the image views: slider (a wipe over both videos in one letterboxed box), side by side and result only; audio stacks the two players with their waveforms (the source’s with the trim window), each with a playhead, click to seek. While both play, the source follows the result: a drift over one frame (100 ms for audio) nudges its playback rate by up to 10 %, over max(0.25 s, four frames) seeks it, and repeated hard re-syncs (a source that seeks slowly here) unlink them with a note; “linked playback” off gives each player its own controls. A source the browser cannot play falls back to its still and the result plays alone. The Source vs result table gives duration, size and bit-rate changes as a percentage and a factor (×). An HLS or DASH result (the Examples have an HLS ladder) plays natively where the browser has HLS (Safari) and otherwise lists and links its playlists; the playground serves a stream’s parts through its ownapi/stream(no player library is loaded). The demo (compose.playground.yml) turns video transformations on withXI_VIDEO_TRANSFORM_UNSIGNED_POLICY=allow, for the demo only: keep the default (deny, signed only) anywhere public, and run heavy video work on its own instances withXI_ROLE=video(the image fleet withXI_ROLE=image), routing/video/*,/q/video,/_stream/*(the parts of HLS and DASH streams) and the dialects’ video paths to them. - The whole session lives in the URL hash (share link), keyboard shortcuts, light and dark themes in the brand palette (BRAND.md); the favicon and the header symbol are served by the playground itself, like its scripts.
URLs are signed on the server (POST /_playground/api/sign), so keys never reach the browser, but anyone who can open the playground can sign URLs: keep it on the private admin listener. XI_PLAYGROUND_PUBLIC=true also serves it on the public listener, for demos and trusted networks only. It is plain HTML, CSS and JavaScript with no build step, loads nothing from other origins (CSP default-src 'self'), and works air-gapped. See docs/CONFIG.md.