Roadmap
Each work package (WP) becomes one GitHub issue and one PR. WPs are sized for one coding agent (roughly ≤ 1,500 lines including tests), own a directory, and talk to other WPs only through the interfaces in ARCHITECTURE.md. A WP may start as soon as its dependencies’ interfaces are merged (every foundational WP lands its interface file first as a tiny PR), so most of a milestone runs in parallel.
Status (as of 2026-09-28, checked against the code and the pull requests): done (#N) merged into main in pull request N (M2 landed as one merge, #79; the closed per-package PRs #50–#78 describe each package; M3 landed as the m3 stack #80–#92, merge 893b191); in PR (#N) open pull request N, not on main yet; in progress assigned and being worked on; planned not started; partial / dropped carry a note. Update the column in the pull request that changes it.
Where things stand: M1, M2 and M3 are complete on main (PR #93 closed M3). M5 (video transformations, ADR 0012) started on 2026-10-04; its foundation (WP-120 FFmpeg base, WP-121 video subsystem core) is done on m5/foundation-integration; the URL syntax and options (WP-122) and audio (WP-123) on m5/wp-122-123-video-options-audio. The full and gpl images are built and verified locally; their multi-arch publish waits for GitHub Actions to be re-enabled.
IDs are stable. A WP keeps its ID forever. A split adds a letter suffix (WP-21b); new work takes an unused number; a WP moved between milestones keeps its number. Never renumber.
Universal done criteria (not repeated below): code follows docs/go-standards.md; go test -race ./... and every CI gate in CLAUDE.md pass; pure-Go packages pass with CGO_ENABLED=0; exported API documented; fuzz target for every parser/decoder; docs (CONFIG.md, URL-DIALECTS.md, FORMATS.md) updated when behaviour or config changes; integration tests use //go:build integration against docker compose services (go-standards §8), never testcontainers.
M1: core
Section titled “M1: core”Goal: a runnable, multi-arch slim image on GHCR that serves the imgproxy dialect end to end (plus the query dialect) from all source types, with the core processing set, all still formats except RAW/PDF/PSD (M2), signing, encryption, mandatory allowlist, SSRF protection, admission control, metrics and health endpoints.
Critical path: WP-01 → WP-05 → WP-00 (walking skeleton) → WP-06 → WP-26a → WP-34a → WP-37 → WP-39. WP-00 retires the integration risk (cgo + libvips + Docker + multi-arch) before anything else is built on top of it.
| ID | Status | Package | Scope | Done when | Depends |
|---|---|---|---|---|---|
| WP-01 | done (#1) | Scaffold | go.mod (go 1.27.0, toolchain go1.27.1), cmd/xtandard-image (run(ctx, args, getenv, stdout) error, serve/version/health stubs), .golangci.yml with depguard rules from ARCHITECTURE §3 and go-standards §10, tools/go.mod, CI workflow (lint, vet, go fix -diff, tidy, unit, CGO_ENABLED=0 job, nightly fuzz + govulncheck). |
CI is green on an empty internal/; a deliberate depguard violation fails CI. |
— |
| WP-05 | done (#12) | Base image (slim) | docker/base: fork of imgproxy-docker-base (MIT, attribution kept) at libvips 8.18.x, plus libspng, OpenJPEG, libultrahdr, jemalloc; versions.sh with SHA256s; license gate that fails on GPL/AGPL; amd64 + arm64 on native runners; publish xtandard-image-base:<vips>-r<N>-slim to GHCR. LibRaw/PDFium/ImageMagick are not here (WP-66). |
vips --vips-config in both arch images lists jpeg, png (spng), gif, webp, heif (aom+dav1d+de265+kvazaar), jxl, tiff, svg, jp2k, uhdr; license gate rejects an injected GPL tarball in a test run. |
— |
| WP-00 | done (#19) | Walking skeleton | Thinnest end-to-end slice: internal/app composition root, internal/server on http.ServeMux with all http.Server timeouts and signal.NotifyContext shutdown, GET /healthz, a minimal internal/vips (init with concurrency 1 + cache off, load from C-owned buffer, thumbnail, JPEG save to blob, locked thread + vips_thread_shutdown), a hard-coded parser for /unsafe/rs:fit:W:H/plain/<http-url> only, a plain net/http fetch from one allowlisted host, and docker/Dockerfile (build on WP-05 base, run on ubuntu:noble, UID 65532). Later WPs replace each piece; nothing here is throwaway-by-design except the hard-coded parser. |
CI builds the image for amd64 and arm64, starts it with compose next to a static origin, and GET /unsafe/rs:fit:300:300/plain/http://origin/a.jpg returns a ≤ 300 px image/jpeg; /healthz returns 200; SIGTERM exits 0. |
01, 05 |
| WP-02 | done (#2) | errs |
Kind enum, wrap helpers, Status(kind) table from ARCHITECTURE §12, HTTPStatus()/PublicMessage() so server.writeError resolves via errors.AsType (go-standards §6). |
Table test covers every row of ARCHITECTURE §12; wrapped errors map correctly through fmt.Errorf("%w"). |
01 |
| WP-03 | done (#9) | transform model |
All types of ARCHITECTURE §4, ApplyDefaults, Validate, CanonicalKey. |
Golden-hash test pins CanonicalKey for 30+ requests; fields excluded from the key (Dialect, Ignored, Presets) proven not to change it. |
01 |
| WP-04 | done (#15) | config |
One Config struct: optional YAML (goccy/go-yaml strict) then XI_* env via caarlos0/env/v11 (env wins), _FILE secrets, Validate() with errors.Join, config print redaction, unknown-key/unknown-XI_ errors. Covers every M1 variable in CONFIG.md; later WPs add their own. |
Precedence, _FILE, list-replacement and redaction are table-tested; a typo in XI_* fails startup; no sources + no dev mode fails Validate. |
02 |
| WP-04b | done (#27) | IMGPROXY_* compat |
Map IMGPROXY_* variables to XI_* when XI_COMPAT_IMGPROXY_ENV=true. |
Mapping table in CONFIG.md; test per mapped variable; unmapped IMGPROXY_* logged once at startup. |
04 |
| WP-06 | done (#31) | vips core |
Grows WP-00’s package: vipsgen pinned, block-untrusted + re-enable list, refcounted C-owned Buffer, Image handle + per-request arena, locked-thread job runner, vips_error_buffer_copy capture at failure site with op-based error kind, kill helper bound to ctx, vips_tracked_* stats. The only package importing "C". |
Tests (in the base image) prove: no leak over 10k load/save cycles (vips_tracked_get_mem returns to baseline), kill aborts a long save within one tile, a load failure maps to Corrupt and a save failure to Internal; cgocheck2 race job green. |
00 |
| WP-07 | done (#32) | codec sniff/probe |
Magic sniffing for every format in FORMATS.md, header-only probe → ImageMeta (dims, frames, pages, alpha, orientation, bit depth), limit enforcement before decode. Format/ImageMeta types in a cgo-free file set. |
Fixture per format sniffs correctly; oversize pixel/frame fixtures rejected with LimitExceeded without full decode; sniffer fuzzed. |
03, 06 |
| WP-08 | done (#30) | BMP/ICO | Pure-Go loaders/savers in codec/bmp, codec/ico, reimplemented from imgproxy’s C loaders (vips/bmpload.c, icoload.c; Apache-2.0): keep the upstream copyright header in each derived file and add the imgproxy notice to NOTICE. |
1/4/8/16/24/32-bit, RLE4/8, bitfields and PNG-in-ICO fixtures decode; fuzzers run 60 s clean; NOTICE entry present. |
06 |
| WP-09 | done (#6) | SVG sanitiser | security/svg token-stream sanitiser, pure Go. |
Malicious corpus (script, event handlers, foreignObject, external href, @import, XXE, billion laughs) all neutralised; fuzzer clean. |
01 |
| WP-10 | done (#11) | SSRF client | security/ssrf: net.Dialer.Control on the connected IP, IP classes, port allowlist, redirect policy, no HTTPS→HTTP downgrade. |
Rebinding test with a fake resolver blocked; every address class in ARCHITECTURE §10 has a test. | 02 |
| WP-11 | done (#14) | Source core | source interface, Key, Object, registry, resolver = allowlist (all schemes of ARCHITECTURE §6), dev-mode behaviour. |
Resolution-order table test for every scheme; unmatched source → Forbidden; dev mode yields implicit http source that still uses SSRF rules. |
02, 03 |
| WP-12 | done (#17) | http source | fetch/httpsrc over the SSRF client, conditional requests, header passthrough allowlist. |
httptest suite: 200/304/404/5xx/redirect/oversize mapped to the right Kind; bodyclose/noctx lint clean. |
10, 11 |
| WP-13 | done (#22) | s3 source | fetch/s3src, aws-sdk-go-v2, custom endpoint, path-style. ADR line for the SDK. |
Integration test against compose MinIO: get, 404, bucket not allowed, prefix jail. | 11 |
| WP-14 | done (#20) | gcs source | fetch/gcssrc. ADR line for the SDK. |
Integration test against compose fake-gcs-server. | 11 |
| WP-15 | done (#23) | azure source | fetch/azuresrc. ADR line for the SDK. |
Integration test against compose Azurite. | 11 |
| WP-16 | done (#18) | fs source | fetch/fssrc on os.Root. |
.., absolute and symlink escapes all rejected; t.TempDir tests. |
11 |
| WP-17 | done (#28) | ftp/sftp sources | fetch/ftpsrc (explicit TLS option), fetch/sftpsrc (pinned host key, no TOFU). |
Integration tests against compose FTP and SFTP servers; unknown host key rejected. | 11 |
| WP-18 | done (#21) | Fetcher | fetch.Fetcher: singleflight, in-memory origin LRU over refcounted Buffers (allocator injected), byte limits, timeouts, one retry. |
Test proves 10 concurrent sizes → 1 origin hit; last Release frees; synctest covers timeout and retry. |
11 |
| WP-19 | done (#8) | Signing core + imgproxy scheme | sign.Verifier, multi-key, signature_size, trusted signatures, xi_sig scheme. |
Vectors from imgproxy docs and .refs/imgproxy/security/signature_test.go pass; constant-time compare; fuzzed. |
02 |
| WP-20 | done (#5) | Encryption | security/crypt AES-CBC, PKCS#7, IV prefix, unpadded base64url (imgproxy spec). |
Round-trips with .refs/imgproxy/examples/encrypted_source_url.go output; bad padding → Unauthorized; fuzzed. |
02 |
| WP-21a | done (#16) | imgproxy dialect: grammar + M1 options | dialect/imgproxy: path grammar, plain/, base64 and enc/ sources, extension, argument separator, - chained-pipeline syntax, every option marked M1 in URL-DIALECTS §3.2. |
150+ table cases URL → TR JSON, including URLs generated by imgproxy-js-core; fuzzed. |
03, 19, 20 |
| WP-21b | done (#29) | imgproxy dialect: later options + /info |
Parse every M2/M3 option in URL-DIALECTS §3.2 into TR (recorded in Ignored until implemented) and the /info option grammar of §3.3. |
Every option row of §3.2/§3.3 has at least one table case; strict mode returns 400 for unknown options. | 21a |
| WP-22 | done (#25) | Presets | preset store, default preset, only-presets, env/file loading, chained-pipeline merge rules. |
imgproxy preset examples reproduce identical TRs; only-presets rejects non-preset URLs with 403. | 21a |
| WP-23 | done (#26) | query dialect | Native keys, o= passthrough, imaginary endpoints and sign. |
Table tests per key and endpoint; imaginary signature vector computed with the algorithm in .refs/imaginary/middleware.go passes. |
03, 19, 21a |
| WP-24 | done (#24) | Router | Host / longest-prefix / default selection, mounts, source binding, unsigned policy enforcement. | Precedence table test; wsrv ?url= at / beats imgproxy fallback; restricted rejects non-allowlisted widths with 403. |
21a |
| WP-25 | done (#35) | Planner | pipeline/plan, pure Go: step order, shrink-on-load math, gravity/crop/extend/padding geometry, DPR, animation, cost estimate. |
Exhaustive table tests (request + meta → plan) including DPR and animated inputs; no cgo. | 03, 07 (types only) |
| WP-26a | done (#41) | Executor: core geometry | pipeline/exec: load with shrink-on-load, resize (premultiplied), pre-crop, gravity crop incl. smart and focus point, EXIF orientation/rotate/flip, background flatten, colour to sRGB. |
Golden cases (WP-36 harness) for each step and for fit/fill/fill-down/force/auto on JPEG, PNG with alpha, WebP. | 06, 25, 36 |
| WP-26b | done (#45) | Executor: extend, trim, animation | trim, extend, extend_aspect_ratio, padding, per-frame ops on animated images (page-height, arrayjoin), disable_animation. |
Golden cases for each; animated GIF/WebP keep frame count, delays and loop count. | 26a |
| WP-27 | done (#43) | Executor: filters | blur, sharpen, pixelate. | Golden cases; sigma conversions match URL-DIALECTS notes. | 26a |
| WP-28 | done (#46) | Watermark (basic) | Config watermark image: opacity, position, offsets, scale, re tiling. |
Golden cases for every position and tiling. | 26a |
| WP-29 | done (#45) | Metadata & colour | Strip metadata, keep copyright, GPS always stripped, ICC → sRGB, strip profile, orientation reset. | Output EXIF/ICC inspected in tests for each flag combination. | 26a |
| WP-30 | done (#33) | Encoders | codec/encode: every M1 output format of FORMATS §5, quality precedence, format_quality, max_bytes loop (≤ 6 saves). |
Each format round-trips through its loader; max_bytes meets the limit or returns the smallest attempt; benchmark committed. |
06, 08 |
| WP-31 | done (#13) | Negotiation | negotiate: Accept q-values, auto/enforce flags, decision matrix (without best), Vary set. |
Table test covering every row of FORMATS §6.2; fuzzed Accept parser. | 03 |
| WP-32 | done (#4) | Limiter | Two semaphores (queue TryAcquire → 429, workers Acquire with timeout → 503, both Retry-After), optional memory-weighted semaphore, metrics hooks. |
synctest tests for 429, 503, release on cancel, and oversize memory request → 422. |
02 |
| WP-33 | done (#10) | Observability | obs: slog JSON handler with request/trace IDs, private Prometheus registry and metric set, opt-in OTel, admin mux with /metrics and gated pprof, /readyz state. |
promlinter/sloglint clean; admin mux never serves pprof unless enabled (test); tracing off without OTEL_EXPORTER_OTLP_ENDPOINT. |
02 |
| WP-34a | done (#34) | Server orchestration | Replace WP-00’s handler: full lifecycle of ARCHITECTURE §2 wired in app, server.writeError single mapping point, request budget via context.WithTimeoutCause, readiness drain, graceful shutdown order of go-standards §2. |
Handler tests with fake executor/fetcher cover every stage failure → status; shutdown test drains in-flight requests. | 02, 11, 18, 19, 20, 22, 24, 25, 30, 31, 32, 33 |
| WP-34b | done (#37) | Response semantics | Cache-Control, ETag, Last-Modified, Vary, Content-DPR, Content-Disposition, expires, 304 before processing, HEAD, SVG CSP. |
Header table test per ARCHITECTURE §9.1 row; 304 path never touches the executor. | 34a |
| WP-34c | done (#39) | Special paths | raw / skip_processing streaming, global fallback image (XI_FALLBACK_SOURCE), filename / return_attachment. |
raw bypasses admission (test); fallback served with XI_FALLBACK_STATUS for each eligible Kind. |
34a |
| WP-35 | done (#40) | Info endpoint (basic) | /info: size, format, dimensions, exif, iptc, xmp; imgproxy JSON field names. |
JSON matches imgproxy field names for fixtures of every M1 format. | 07, 21b, 34a |
| WP-36 | done (#7) | Golden harness | Pure-Go SSIM/DSSIM metric (no AGPL dssim), testdata/golden layout, -update flow, fixture corpus of all M1 formats with licences in testdata/LICENSES.md. Executor WPs add their own cases. |
Metric validated against published SSIM reference values; identical images score 0; a 1 px shift exceeds default tolerance. | 01 |
| WP-37 | done (#38) | Release pipeline | Promote WP-00’s Dockerfile to the full slim app image (runtime lib subset, env from ARCHITECTURE §14, HEALTHCHECK, read-only rootfs), per-arch build by digest + imagetools merge, cosign, SBOM, GHCR tags slim, <ver>-slim, latest. |
Tagged push publishes a signed multi-arch manifest; image ≤ 110 MB compressed; docker run … health exits 0. |
00, 34a |
| WP-39 | done (#48) | M1 acceptance | Compose-based end-to-end suite run in CI against the published slim image on amd64 and arm64: signed and unsigned imgproxy URLs, enc/ source, http + S3 (MinIO) + fs sources, every M1 output format, Accept negotiation, /info, 304, 429 under load, /metrics, /readyz drain on SIGTERM. |
Suite green on both arches; it is the M1 release gate. | 34b, 34c, 35, 37 |
| WP-38 | done (#61) | M1 docs & benchmarks | README quick start verified verbatim, bench/ k6 scenario vs imgproxy OSS, results published. |
Quick start commands run in CI; benchmark report committed with hardware notes. | 39 |
M2: breadth
Section titled “M2: breadth”Goal: every URL dialect (including all 28 unpic providers), RAW/PDF/PSD/HDR inputs, result cache, auto-quality, best format, chained pipelines, colour adjustments and the remaining imgproxy Pro processing options.
| ID | Status | Package | Scope | Done when | Depends |
|---|---|---|---|---|---|
| WP-40 | done (#50); mounted by WP-88 (#87) | imgix dialect | URL-DIALECTS §5 incl. s/expires signing, 64 params. |
Fixtures from @imgix/js-core and unpic imgix parse; blueprint signature vectors pass. |
24 |
| WP-41 | done (#79) | Cloudinary dialect | §6 incl. upload/fetch, private-CDN/custom-domain form, chained components, s--…-- SHA-1/SHA-256, cloud → source map. |
Fixtures from cloudinary-url-gen and unpic cloudinary (both public and private regex shapes) parse; signature vectors pass. |
24 |
| WP-42 | done (#79) | ImageKit dialect | §7 incl. path/query tr, chains, ik-s/ik-t. |
Fixtures from imagekit-javascript and unpic imagekit parse; signature vectors pass. |
24 |
| WP-43 | done (#79) | Cloudflare dialect | §8 /cdn-cgi/image/, onerror=redirect. |
Unpic cloudflare fixtures parse; onerror=redirect returns 302 on origin failure. |
24 |
| WP-79 | done (#79) | Cloudflare Images delivery | /cdn-cgi/imagedelivery/ and imagedelivery.net shapes, variant → preset, sig/exp. (Moved from M3: it is an unpic provider.) |
Unpic cloudflare_images fixtures parse; signature vector passes. |
22, 43 |
| WP-44 | done (#79) | Bunny dialect | §9 incl. token auth; byte layout verified against Bunny’s published SDK code before implementation. | Unpic bunny fixtures parse; token vectors from Bunny docs pass. |
24 |
| WP-45 | done (#79) | Thumbor dialect | §10 grammar, filters parser (reused by Storyblok), HMAC-SHA1, meta. |
URLs from thumbor’s docs and test suite parse; HMAC vectors pass. | 24 |
| WP-46 | done (#79) | wsrv dialect | §11 incl. default redirect, maxage, encoding=base64. |
Unpic wsrv fixtures and wsrv docs examples parse. |
24 |
| WP-47 | done (#79) | Simple dialects engine | Table-driven parser + first 6 providers (nextjs, vercel, netlify, ipx, astro, directus). | Each provider’s unpic fixtures parse; adding a provider needs only a table + test file. | 24 |
| WP-48 | done (#79) | Simple dialects batch 2 | Remaining unpic providers of §12. | All 28 unpic providers have a passing fixture file (tools/compat/). |
45, 47 |
| WP-85 | done (#79) | AWS Serverless Image Handler dialect | §13 “Dynamic Image Transformation for Amazon CloudFront”: base64 JSON requests (bucket → source allowlist, sharp edits, outputFormat, effort, headers), signature HMAC-SHA256 hex and expires, query-param mapper, thumbor and custom-rewrite paths forwarded to the thumbor dialect. |
Golden table ported from the solution’s own source/image-handler/test/ fixtures; signature vectors generated by the solution’s algorithm under Bun; thumbor forwarding hook tested with a fake dialect; fuzz target on the JSON path; benchmark. |
24, 45 (thumbor mapper) |
| WP-66 | done (#49) | Base image extras | Add LibRaw 0.22.x, PDFium (prebuilt), ImageMagick 7 (PSD coder only, restrictive policy.xml), OpenEXR/Imath to docker/base slim. |
vips --vips-config lists dcrawload, pdfload (pdfium), magickload, openexrload; magick refuses every non-PSD coder in a test. |
05 |
| WP-49 | done (#79) | RAW | codec/raw + shim loader: LibRaw embedded preview, dcrawload fallback, RAW orientation, EXIF. |
CR3/NEF/ARW/DNG/RAF fixtures render; preview path used when large enough (asserted via metric); golden cases. | 07, 26a, 66 |
| WP-50 | done (#79) | PDFium page/pages, dpi-at-target rendering, page limit, password → 422. | Golden cases for page 0 and page N; XI_LIMITS_MAX_PDF_PAGES enforced at probe. |
07, 26a, 66 | |
| WP-51 | done (#79) | PSD | magick PSD-only path, composite strategy. | Fixtures with and without “Maximize compatibility” render; policy.xml tests pass. |
07, 26a, 66 |
| WP-67 | done (#79); preserve_hdr for sources that are not HDR (16-bit PNG, 10/12-bit AVIF, UltraHDR) not implemented, UltraHDR gain map ignored (FORMATS §1.4) |
HDR inputs | OpenEXR, Radiance HDR, PPM/PFM sniffing and tone-mapping to 8-bit sRGB unless preserve_hdr. |
Golden cases per format. | 07, 26a, 66 |
| WP-52 | done (#79) | Cache core + memory | cache.Store, tiering, async bounded writes with drop metric, result-key derivation, server integration, bypass_cache. |
Second identical request served from cache without fetch (test); full write queue drops and counts. | 34b |
| WP-53 | done (#79) | Disk cache | Sharded dirs, atomic rename, size-bounded LRU janitor. | Survives restart; janitor keeps usage under XI_CACHE_RESULT_DISK_BYTES (synctest). |
52 |
| WP-54 | done (#79) | S3 cache | S3-compatible store reusing a named s3 source’s credentials. | Integration test against compose MinIO. | 13, 52 |
| WP-55 | done (#79) | Redis cache | SET PX, values over 1 MiB skipped. |
Integration test against compose Redis; oversize values skipped and counted. | 52 |
| WP-56 | done (#79) | Auto-quality | size + dssim methods, binary search, per-format min/max. | For the fixture corpus, chosen quality meets the target within tolerance in ≤ 6 saves. | 30, 36 |
| WP-57 | done (#79) | Best format | Complexity score, candidate encodes, best in all dialects. |
Photo fixtures pick lossy, graphic fixtures pick lossless; skipped above XI_FORMATS_BEST_MAX_PIXELS. |
31, 56 |
| WP-58 | done (#79) | Chained pipelines exec | Materialise between pipelines, preset merge semantics. | imgproxy chained-pipeline doc examples produce expected golden output. | 22, 26a |
| WP-59 | done (#79) | Colour adjustments | brightness, contrast, saturation, gamma, hue, monochrome, duotone, colorize, gradient, tint, sepia, negate, background alpha, color_profile. |
Golden case per option. | 26a |
| WP-60 | done (#79) | Geometry extras | crop_aspect_ratio, border, radius, masks, arbitrary rotate. |
Golden case per option. | 26b |
| WP-61 | done (#79) | Filter extras | unsharp masking, blur areas, progressive blur. | Golden case per option. | 27 |
| WP-62 | done (#79) | Pages & frames | page/pages, frame selection in all dialects. |
Multi-page TIFF and animated fixtures select the requested page/frame. | 26b |
| WP-63 | done (#79); alpha:check_transparency and exif:canonical_names not implemented |
Info Pro options | colorspace, bands, sample_format, pages_number, alpha, crop, palette, average, dominant colours, blurhash, thumbhash, phash, hashsums. | JSON field names match imgproxy; hash outputs match reference implementations on fixtures. | 35 |
| WP-64 | done (#79) | Request extras | hashsum check, per-request fallback_image_url, style, enforce_thumbnail, dpi, preserve_hdr, encoder *_options, cache_tags. |
Table + golden tests per option. | 34c |
| WP-65 | done (#79); dialect auto widths (w_auto, width=auto, imgix/ImageKit auto) resolve into pipeline 0 only |
Client hints | Sec-CH-DPR/Width/Viewport-Width, Save-Data, Accept-CH response, Vary. |
Header table tests; Vary only lists hints that changed the result. |
31, 34b |
| WP-81 | done (#79) | Config schema & formats | ADR 0007 decisions 1–5 and 7, plus the self-serving part of 6. Schema generator: hand-written, JSON Schema draft 2020-12, over fieldsOf, in _test.go files. Descriptions come from CONFIG.md §2 plus the env name (and _FILE for secrets); defaults come from the default Config. patternProperties for sources/presets, a per-type oneOf for sources, and a root $schema key allowed. Secret-typed values are oneOf a string or #/$defs/fileRef. $id is the versioned Pages URL. The output internal/config/config.schema.json is committed and embedded. Serving: the embedded schema is served at admin GET /config.schema.json (application/schema+json, strong ETag, always on, never on the public listener). Secret file references: {file: /abs/path} for any Secret/HexKey value (a field, a list element or a map value), resolved after decode with the same read-trim-parse function as _FILE. Loader: picks YAML/JSON/TOML by extension (TOML via pelletier/go-toml/v2 → map → the same strict goccy decode), strips a top-level $schema, and discovers /etc/xtandard/config.{yaml,yml,json,toml}. CLI: config schema (the same bytes as the admin route) and config validate [--config] [--no-env]. Docs: ADR 0006 register, go-standards §7/§10 and depguard updated. |
TestJSONSchema fails on a stale schema and passes after -update. Admin GET /config.schema.json returns the bytes of config schema with a matching ETag and a 304 on If-None-Match, and the public listener returns 404 for it. A {file:} reference loads like XI_…_FILE (go-cmp on the Config). A relative path, a missing file, an oversize file, or a reference on a non-secret field fails with the key path and without the content. An env value still wins over a reference. Every CONFIG.md §3 example and testdata file validates against the schema (santhosh-tekuri/jsonschema/v6, test only), and an invalid corpus is rejected. The same config in YAML, JSON and TOML loads to an identical Config (go-cmp). A $schema key is accepted in JSON and TOML. config validate exits 1 and lists every violation. WP-04’s precedence tests are unchanged and green. |
04 |
| WP-82 | done (#79) | OpenAPI document | ADR 0008 decisions 1–3. Builder: internal/apidoc builds an OpenAPI 3.1 document with huma v2 (builder only, no adapter, no huma globals assigned). It covers /healthz, /readyz, admin /metrics, /openapi.json, /config.schema.json and /docs, plus the imgproxy image and /info operations with x-xi-* extensions. Export server.InfoResponse and server.ErrorResponse (with the errs.Kind enum). Serving: the committed and embedded internal/apidoc/openapi.json is served at admin GET /openapi.json (strong ETag). Scalar is served at admin /docs behind XI_ADMIN_DOCS (default off; pinned jsdelivr + SRI + CSP). Depguard confines huma to internal/apidoc. |
A golden test fails on a stale spec. A route-coverage test maps every registered public/admin pattern to an operation and back. Contract tests validate real /info bodies (every M1 format), every errs.Kind error body, /healthz and /readyz against the spec schemas. CI runs openapi-typescript and @redocly/cli lint (pinned bunx versions) on the file with no errors. /docs returns 404 unless enabled, and neither route exists on the public listener. |
33, 34a, 35 |
| WP-83 | done (#79) | imgproxy options schema | ADR 0008 decision 4. Catalogue: a declarative option catalogue in internal/dialect/imgproxy covering §3.2 and §3.3: canonical name, aliases, positional args with type/range/enum, milestone, Pro flag. Schemas: a generator for ImgproxyProcessingOptions / ImgproxyInfoOptions (JSON Schema, prefixItems tuples, x-xi-aliases, x-xi-milestone), wired into the WP-82 document. Parser: keeps its apply switch; the catalogue is the documentation and schema source. |
Every catalogue name and alias is accepted by apply, and every apply case is in the catalogue. Every URL-DIALECTS §3.2/§3.3 row is in the catalogue. Every WP-21a/21b table-case URL, decoded into the options object, validates against the schema. |
21b, 82 |
| WP-82b | done (#80): rejected, huma stays builder-only (ADR 0008 “WP-82b outcome”) | huma router spike (small) | ADR 0008 decision 5. Move /healthz, /readyz and one admin JSON route to huma handlers via humago.NewWithPrefix on our ServeMux. Map every huma-generated error (validation, negotiation, panic, unexpected) to server.ErrorResponse and its errs status, using only per-API or per-operation hooks (Config.Transformers, Resolvers returning our StatusError, SkipValidate*), never huma package globals. Record the outcome as an ADR 0008 amendment: huma handlers for JSON endpoints, or builder-only. Image endpoints and /info stay hand-written regardless, because the dialect URL grammar cannot be expressed in OpenAPI. |
The ADR 0008 decision 5 criteria: every error body on the moved routes, huma-generated ones included, is our envelope with the errs-mapped status (400, not 422, for invalid input), and the WP-82 contract tests are unchanged. A forbidigo rule bans assigning huma package variables. go-standards §2 is intact (stdlib ServeMux, every http.Server timeout, shutdown, recover, MaxBytesReader). There is no new third-party module in go list -deps, and nothing is written to stderr. The OpenAPI golden is unchanged or has a reviewed diff. /healthz allocations and latency do not regress (benchstat -count=10). |
82 |
| WP-81b | done (#80); Pages deploy waits for the owner to enable Pages and the workflows | Schema & spec publishing | ADR 0007 decision 6 (amended by its WP-81b implementation notes: the version segment is the schema major v1, and the site follows main). Pages: .github/workflows/pages.yml + scripts/pages-build.sh (task pages:build → dist/pages/) publish config.schema.json and openapi.json at /schema/v1/… and /schema/latest/… with an index and a Scalar page; $id = https://xantiagoma.github.io/xtandard-image/schema/v1/config.schema.json. SchemaStore: entry, samples and PR steps in docs/schemastore/ (PR waits for Pages). config.schema.json and openapi.json to GitHub Pages at /schema/<version>/… and /schema/… (latest release, never main), with $id set to the versioned URL.fileMatch. Docs: README and CONFIG.md link the final URLs. |
A tagged release makes both URLs return that release’s files. yaml-language-server validates a sample YAML with the modeline against the fetched URL in a CI smoke run. The SchemaStore PR is opened and linked. | 37, 81, 82 |
| WP-86a | done (#79) | Pixel focus points | Model gap shared by thumbor focal, Cloudinary g_xy_center + absolute x_/y_, Storyblok and imgix-style tools. transform.Gravity.FocusPx *FocusRect (source pixels as displayed, a point when the corners are equal; FocusX/FocusY must be 0), Validate rules (only on the pipeline and crop gravities, focus-point type, 0 ≤ X1 ≤ X2 ≤ MaxSourceCoord), the dialect helper transform.FocusFromPixels, and the planner conversion to fractions with the source size after the EXIF orientation swap, carried through rotate/flip and taken relative to a pre-crop (thumbor’s focal adjustment). imgix fp-x/fp-y stay fractional. The thumbor (WP-45) and Cloudinary (WP-41) dialects switch their pixel cases to FocusFromPixels (URL-DIALECTS §6, §10). |
Plans with a pixel focus equal plans with the fractional focus at the same place for several source/result sizes, all 8 EXIF orientations, rotate/flip, pre-crops and chained pipelines; Validate table cases; the CanonicalKey golden gains one entry and no existing key changes; exec goldens show the crop at the pixel focus (EXIF 6 included); FuzzFocusFromPixels and FuzzBuild with pixel focus. |
03, 25 |
| WP-86b | done (#79) | Thumbor meta response |
InfoSchemaThumbor in server/info_thumbor.go: thumbor’s JSONEngine shape {"thumbor": {"source": {url, width, height, frameCount}, "operations": [...], "target": {width, height}, "focal_points": [...]}}, target and crop/resize/flip operations from plan.Build on the inspected header (InfoConfig.PlanLimits = the executor’s limits), focal points as thumbor’s adjust_focal_points (explicit pixel focus moved into the manual crop, else one alignment point). Content-Type: application/json, the /info ETag/Cache-Control/304 rules. |
Golden JSON bodies (key order included) for fill, fit-in, focal, manual crop + focal, EXIF 6/8, flips, shrink-on-load and animation; ETag/304 and a limit error like processing. | 35, 45, 86a |
| WP-87 | done (#86) | Stabilisation pass | Flaky tests and M2 leftovers: PDF password detection from the trailer’s /Encrypt (classic and cross-reference-stream trailers, incremental updates) instead of PDFium’s text in libvips’ process-global error buffer, and shim-originated messages written per call (xi_error_new); leak tests wait for libvips’ asynchronous worker-thread cleanup (internal/testutil/leakcheck) with unchanged limits; wall-clock tests rewritten (httpsrc timeouts on synctest + httptest.NewTestServer, RAW preview asserted on Result.RawDecoded); reasonM2 removed from every dialect (options mapped, or “not implemented”); client-hint tests for Cloudinary/Cloudflare auto widths; XI_DIALECTS_IMAGEKIT_ENDPOINT_PATH; the planner’s memory estimate charged through limiter.Reserve (exec.Config.Admit) before decoding; imgproxy’s angle convention for gradient/progressive_blur; PNG-alpha → WebP profiled, jemalloc limited to two arenas (MALLOC_CONF=narenas:2). |
-count=3 -race on the affected packages green in the container; task docker:lint and task docker:test green; FuzzPDFEncrypted; synctest tests for Reserve; bench/RESULTS.md WP-87 rerun (peak RSS, throughput). |
— |
M3: intelligence, video, polish
Section titled “M3: intelligence, video, polish”| ID | Status | Package | Scope | Done when | Depends |
|---|---|---|---|---|---|
| WP-70 | done (#81); multi-arch publish pending Actions | full base image |
FFmpeg 8 LGPL (decoders + libvpx/libaom encoders per the GIF→video decision), ONNX Runtime 1.30, permissively licensed models with licence files; license gate. Done: builder-full/runtime-full targets in docker/base (FFmpeg 8.1.3 LGPL-2.1+ video-only with libvpx 1.17 and the slim dav1d/libaom, prebuilt ONNX Runtime 1.30.0 CPU, YuNet + YOLOX-s ONNX with licences; pins in versions-full.sh, revision r3), gate extended (--ffmpeg, --ldd, --models), task docker:base:full[:check], workflow updated for both flavors. Not done: multi-arch publishing, because GitHub Actions is disabled; verified locally on linux/arm64 only. |
Local arm64: verify-image.sh --flavor full green (licence gate, codec round trips, ONNX inference on both models); ffmpeg -buildconf has --disable-gpl --disable-nonfree and no --enable-gpl; the whole Go suite behaves identically on the full and slim builders. Remaining: full base published multi-arch. |
66 |
| WP-71 | done (#88) | Detection runtime | detect: purego ONNX loading, model registry, pre/post-processing, NMS; disabled cleanly on slim. Done: internal/detect/ort binds the ONNX Runtime C API table with purego dlopen (ADR 0010: no second cgo package, rule 3 unchanged); internal/detect loads YuNet and YOLOX-s once (XI_DETECT_*, shape checks, optional SHA256SUMS), letterboxes with libvips into C memory (vips.Image.ToRGB8, vips.Buffer), decodes and NMSes in Go (OpenCV FaceDetectorYN and the YOLOX ONNXRuntime demo), bounds inferences with a semaphore, returns boxes in source pixels plus the /info JSON shape; xi_detect_duration_seconds{model}; built from internal/app behind XI_DETECT_ENABLED (auto). |
Face and object fixtures detected with expected boxes (IoU ≥ 0.5); slim build returns Unsupported without crashing. Verified: 3 public-domain/CC0 photos, every reference detection matched with IoU 0.98–1.0 (0.92–1.0 through the resize path); slim builder: New → ErrUnavailable (Unsupported), app starts without detection; 200 detections leave vips memory and RSS flat. |
70 |
| WP-70b | done (#89); multi-arch publish pending Actions | gpl base image |
Opt-in third flavor (owner decision 2026-09-27, ADR 0006 amendment): full + FFmpeg rebuilt with --enable-gpl + x264 (H.264) + x265 (HEVC); gate gpl mode; LICENSE-GPL.md; labels GPL-2.0-or-later AND MIT; slim/full unchanged. Done: builder-gpl/runtime-gpl targets on top of full-deps (x264 stable build 165, x265 4.2, FFmpeg with the full configuration + --enable-gpl --enable-libx264 --enable-libx265; pins in versions-gpl.sh, revision r3), check-licenses.sh --allow-gpl/--licenses with self-tests, verify-image.sh --flavor gpl, task docker:base:gpl[:check], workflow matrix, docs (README, ADR 0005/0006, FORMATS 5.1 video output contract, NOTICE). Not done: multi-arch publishing (GitHub Actions disabled); verified locally on linux/arm64 only. |
Local arm64: ffmpeg -buildconf has --enable-gpl --enable-libx264 --enable-libx265 and -L reports the GPL; verify-image.sh --flavor gpl green (gate gpl mode, H.264 and HEVC MP4 encode + decode back); the strict gate still passes slim and full; the Go suite on the gpl builder equals the full builder. Remaining: published multi-arch. |
70 |
| WP-72 | done (#92) | Face/object gravity | g:obj, objw, objects_position, face gravity in all dialects, sm uses faces when available. |
Golden cases for each dialect’s face-crop option. | 26a, 71 |
| WP-73 | done (#92) | Detection filters | blur/draw detections, crop_objects, detect_objects info (classify parsed only; implemented by WP-110). |
Golden and JSON tests per option. | 71 |
| WP-74 | done (#90) | Video thumbnails | video (build tag ffmpeg), vts/vtk, best-frame selection, /info vm. Done: internal/video (cgo-free rules) + internal/video/ffmpeg (own C shim over libavformat/libavcodec/libswscale behind the build tag ffmpeg, ADR 0010: custom AVIO over the C-owned source, single-threaded decoders, whitelists, limits, abort flag, frames into a vips.Buffer wrapped zero-copy by vips.NewFromBuffer); sniffing of MP4/MOV/WebM/MKV/AVI; probe with display size and display matrix as orientation; vts (negative from the end), vtk, best-frame selection over keyframes; shrink-on-load via libswscale; /info video_meta + video_streams; XI_VIDEO_* config; Cloudinary so_ (video asset type), ImageKit ik-thumbnail.jpg + so-, query vts/vtk; -tags ffmpeg plumbing (Dockerfile GO_TAGS, Taskfile XI_IMAGE_FLAVOR/XI_GO_TAGS). |
MP4/MOV/WebM fixtures produce thumbnails; slim returns Unsupported. Verified: goldens for H.264, AV1, VP9, VP8, PNG/MOV, rawvideo/AVI, rotated, keyframe, from-end and best-frame thumbnails, matching the ffmpeg CLI’s frames (DSSIM ≤ 0.002); slim answers 422 for every video fixture. |
70 |
| WP-75 | done (#93) | Video tiles & animation | vtt, vta. |
Golden cases. | 74 |
| WP-76 | done (#93) | GIF → video | Encode animated sources per FORMATS 5.1: webm = VP9, mp4 = H.264 (libx264) in gpl images and AV1 in full, hevc only in gpl, encoder picked at startup via avcodec_find_encoder_by_name; dialect mappings (fm=mp4, f_mp4). |
Animated fixture → playable file (ffprobe check); size smaller than the source GIF. | 70 |
| WP-77 | done (#85) | Watermark URL & size | Per-request wmu, wms, rotate, shadow, chessboard; dialect overlays (imgix mark, cloudinary l_, imagekit l-image). |
Golden case per option and dialect. | 28 |
| WP-78 | done (#85) | Text watermarks | Pango text, bundled fonts, imgix txt*, cloudinary l_text, imagekit l-text. |
Golden cases (perceptual tolerance covers font hinting). | 77 |
| WP-80 | done (#84) | Playground UI | Static page under /_playground (off by default): source picker, option builder per dialect, signed URL generator, before/after. |
Served only when enabled; e2e smoke test loads the page and renders one image. | 34a |
| WP-88 | done (#87) | imgix mount | Finishes WP-40. Mount: internal/app mounts imgix like the other host/prefix dialects: one parser per mount (imgix, imgix[i]) from XI_DIALECTS_IMGIX_* / dialects.imgix.mounts[] (prefix, hosts, bound source, unsigned policy), s verified with the mount’s secure URL tokens (XI_DIALECTS_IMGIX_KEYS, raw, several for rotation; XI_SIGNING_REQUIRED makes s mandatory), expires → 404; the “enabled but not implemented” warning is gone; XI_DIALECTS_DEFAULT=<dialect> resolves unmatched paths on that dialect’s primary mount source and policy. Parser audit against imgix-url-params 11.36: every alias, jpg-progressive, txt-x/y, face-blur/face-pixel (WP-73), `trim=alpha |
colorunlessalpha, rot-type, iptc, mark-if-min-*, fill=generative |
gradient, fm=spritesheet; animation/transparency-grid parameters not implemented, video/AI parameters unsupported; stale M2 reasons dropped. **Surfaces:** playground imgixscheme, grammar and option catalogue; OpenAPIgetImgixImage`. |
| WP-84 | done (#83) | TypeScript URL builder & client | ADR 0009. Workspace: a root Bun workspace (package.json with "workspaces": ["packages/*"], bun.lock, tsconfig.base.json), Bun pinned in .mise.toml, go.mod ignore for node_modules, and .dockerignore entries. Package: @xtandard/image in clients/typescript, ESM only, no runtime dependencies, tested with bun test. Types: generated by openapi-typescript (a pinned devDependency) from internal/apidoc/openapi.json into clients/typescript/src/generated/openapi.ts (committed), plus a typed fetch client for /info and health (@xtandard/image/client). URL builder: typed by ImgproxyProcessingOptions (option names, aliases, argument tuples); it emits imgproxy-dialect paths with base64/plain//enc/ sources and optional server-side signing (imgproxy scheme, xi_sig), in the spirit of imgproxy-js-core (MIT). CI: a ts workflow runs bun install --frozen-lockfile, bun run generate with a drift check, bun run typecheck and bun test whenever packages/**, the lockfile, openapi.json or the shared testdata/ fixtures change. |
Builder output for the WP-21a table cases parses in the Go dialect to the same TR (a cross-language fixture file). Signatures match the WP-19 vectors. tsc --noEmit rejects an unknown option and a wrong argument type (type tests). The package is published from a release tag, in lockstep with the server version and with npm provenance. |
19, 82, 83 |
M4: playground, detection follow-ups
Section titled “M4: playground, detection follow-ups”| ID | Status | Package | Scope | Done when | Depends |
|---|---|---|---|---|---|
| WP-94 | done | Playground v2 | Option builder for every dialect’s catalogue (static tables for Cloudinary, ImageKit, Cloudflare, wsrv, thumbor, Bunny, AWS SIH, checked against each strict parser), POST api/translate (emitters in internal/playground/translate, round-trip checked by parsing each URL back), POST api/upload (playground://, XI_PLAYGROUND_UPLOADS), GET api/sources/<name>/list (fs), sample gallery (built-in fixtures + public samples.json), before/after slider, zoom/pan, headers, per-stage Server-Timing, /info with detections, format matrix, hash state, history, shortcuts, themes. Research in playground/RESEARCH.md. |
Unit, contract, app and acceptance tests; headless Chromium run (render, translate, slider, upload, share link, no console or CSP errors). | 80 |
| WP-95 | done | Named http sources: absolute keys and clearer errors | xi://<http source>/https://… resolves as that URL through the source’s allowlist, headers and limits when the source has no base_url (with one: taken as is only under the base, otherwise joined as before), in the plain/ (literal, percent-encoded, proxy-collapsed) and base64 forms. Source refusals keep 403 KindForbidden but name the failed check (source "web" has no base_url: the key must be an absolute http(s) url, source "web": host not in the source allowlist, unknown source "x") without configuration. Playground: api/meta sources[].syntax / prefix, the URL tab lists each source’s syntax and pre-fills its prefix. |
Registry and dialect table tests (plain and base64), app end-to-end test (200 for both forms, 403 bodies), acceptance, by-hand check of the owner’s URL against the release image. | 94 |
| WP-91 | done | Detection on video frames | Face/object gravity, bd/dd/c_obj, every dialect’s face options and /info do on a single-frame video detect on the frame the request uses (vts/vtk/best frame), upright (display matrix applied): one decode at the display size on the probe’s session serves detection and processing (codec.DecodedFrame, exec.Job.Frame, plan.SourceMeta.FrameDecoded); /info detects on its default frame. The detection cache key has the frame’s timestamp. Composites (vtt/vta) are not detected. Face clips video/faces-vp9.webm and video/faces-av1-rotate90.mp4 (the crew photo, public domain). |
App tests: every dialect’s face options on both clips against the photo’s goldens, /info do:1, one detection per video and frame, 422/sm fallback without the detector, leak test (libvips counters and FFmpeg handles); the real models find the crew’s faces on both clips; by hand against the release image. |
72, 74 |
| WP-92 | done | Detections in chained pipelines | Chained pipelines (imgproxy -/, Cloudinary and ImageKit components) get the source’s boxes mapped through every step before them: crops, resizes, rotate/flip (EXIF included), extend, padding, borders, mask trims, arbitrary rotation, and the rectangles the executor cuts (smart crop via libvips’ offset, trim); a step without a mapping drops them (“detections” unplanned). NeedsDetection reads every pipeline. |
Planner unit tests (8 EXIF orientations, each step), exec goldens (detect-chained-*) and an oracle (rotations, flips, padding + trim and extend + crop undone give the source’s face crop), app test with imgproxy and Cloudinary chains on the photo and the face clip. |
72 |
| WP-93 | done | Base image: MALLOC_CONF=narenas:2 |
The app image sets it (WP-87); the base runtime should too at the next base release. Shipped as base 8.18.6-r3-slim, -r4-full, -r4-gpl (built and verified locally; multi-arch GHCR publish waits for the base-image workflow). |
docker inspect of the base runtime shows it. |
70 |
| WP-96 | done | Playground polish | Owner-reported fixes to WP-94. Preview: the comparison stage takes the result’s aspect ratio (the source’s when there is no result), at most 70vh high and the column’s width, so portrait, panoramic and tiny results are never cropped; before and after share one box with object-fit: contain (a source of another shape is letterboxed on the checkerboard); side by side is two equal boxes; 1×/2× size a viewport of the same bounds over the natural size × zoom with clamped pan (drag or arrow keys); icons upscaled more than 2× are drawn pixelated; works on resize and for video results. Mounts: api/meta lists a mount once with every prefix (prefixes; the router lists simple providers once per native prefix plus /simple/<name>/), api/sign takes an optional prefix, api/translate returns alternates on the other prefixes; the picker groups mounts and shows a prefix select. Translate: ImageKit, Bunny and AWS SIH cards say why they cannot write the source and how to fix it; an http(s) source under the base_url of the mount’s http source is written as a key on it; untranslatable (free-form) mounts answer 400, not 500. CSP: media-src 'self' blob: so video results play (they were blocked). Also: skip link to the preview, Home/End on tabs, the preview toolbar is a plain group, the format matrix sorts by format, sample, time and status. |
Unit tests (meta merge, sign prefix, translate alternates, keyed-dialect reasons and base_url keys, client errors); acceptance (simple:ipx listed once with both prefixes, signed on /simple/ipx/); headless Chromium scripts/playground-check.ts (portrait/wide/tiny boxes, equal before/after boxes, aspect within 1 px, side by side, zoom and clamped pan, resize, one entry per mount, prefix picker, no empty translate card, matrix sort, no console errors, CSP violations or 5xx). |
94 |
| WP-97 | done | Long-tail input formats | Owner report: the playground’s JPEG XR sample (and PBM, QOI, TGA, PCX, DDS, EPS in the WP-94 format matrix) answered 422. PBM/PGM/PPM: codec.Sniff and the shim only took binary P5/P6; ASCII and bitmap P1-P4 now go through ppmload, PAM (P7) through ImageMagick. ImageMagick legacy coders (slim, no size cost: coders are built in): magick-policy.xml now lets TGA, PCX, DDS (DXT1/3/5, BC4-BC7), QOI (libvips 8.18 has no qoiload), SGI, SUN, XBM, XPM and PAM read, every other coder and every delegate still denied; headers parsed in Go (codec.ParseLegacyHeader) so probes never run ImageMagick; the shim checks the same signatures before magickload runs. JPEG XR: jxrlib (BSD-2-Clause, the 4creators commit Debian packages) built in slim by docker/base/build-jxrlib.sh with Debian’s and our patches, and a libvips loader class registered by the shim (XiForeignLoadJxrBuffer, internal/vips/jxr.c: header-only probes, lazy decode from the C-owned blob, 8/16-bit grey/RGB(A)/CMYK kept, float/half/fixed/RGBE converted to 8-bit, container/codestream format mismatch refused). A libFuzzer + ASan/LSan campaign on the loader found NULL and wild writes, heap overflows and over-reads, unbounded allocations and leaks in jxrlib (20 patches, 15 of them ours) plus a container/codestream pixel-format mismatch the loader now refuses; the final rounds (libFuzzer 15 min × 3 jobs, Go 15 min, 8.4 M executions) were clean (docker/base/patches/jxrlib); FuzzJXR keeps the crashes as regression seeds. WBMP, ICNS: pure-Go loaders (codec/wbmp, codec/icns; ICNS PNG/JPEG 2000 entries through libvips, classic RLE entries in Go). EPS/PS/AI: not implemented, by decision (ADR 0006 amendment): Ghostscript is AGPL-3.0 and a PostScript interpreter with a long -dSAFER bypass record; documented in FORMATS §1.6. PICT: skipped (libvips’ magick sniffer takes its 512-byte zero header for TGA). Base revisions: slim r4, full r5, gpl r5; app Dockerfile pins r4. |
Sniff/probe table tests for every fixture, /info goldens, processing goldens per format, JXR probe/decode (DSSIM against the lossless PCX)/thumbnail/errors/leak tests and FuzzJXR, ICNS/WBMP unit and fuzz tests, legacy decode + leak tests; verify-image.sh decodes every legacy fixture through magickload and a JXR fixture through libjxr.pc; slim/full/gpl bases rebuilt and verified; task docker:lint/docker:test on the three builders; acceptance on a release image; owner’s JXR URL through a gpl release image: 200 image/jpeg. |
94 |
| WP-98 | done | Animation pixel limits and limit messages | Owner report: cards.gif (800×600, 176 frames) → @webp answered 422 “image exceeds processing limits”. The probe (codec.Limits.Check) and the server’s re-check (checkMeta) held W×H×frames against XI_LIMITS_MAX_SRC_PIXELS even with the per-frame limit set. Now imgproxy’s CheckDimensions everywhere (transform.SourceLimits, shared by codec, server and planner): for an animation processed as one, XI_LIMITS_MAX_ANIMATION_FRAME_PIXELS replaces the total; 0 turns it off and the frames count against XI_LIMITS_MAX_SRC_PIXELS (IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION=0 now maps to that instead of keeping the default). The probe only rejects a frame no limit admits (it cannot know the output yet); the planner checks the frames it loads. New XI_LIMITS_MAX_ANIMATION_PIXELS (200 MP) keeps the decoded strip bounded once the total no longer applies (the memory budget is off by default). 422 bodies name the limit and the numbers (source has 176 frames of 800×600 (84.5 MP); limit XI_LIMITS_MAX_SRC_PIXELS is 50 MP); /info and the codec path of a frame limit of 1 no longer reject animations. |
Table tests for the shared rule, checkMeta, the codec probe (synthetic GIFs) and the planner (messages included); compat test for =0; app end-to-end test with a generated 60-frame GIF (animated WebP 200, frame limit off, animation cap, frame limit, frame count, compat status); lint and tests on the slim and full builders; acceptance on a release image; owner’s URL by hand: 200 animated image/webp, /info 200. |
97 |
| WP-99 | done (branch m4/wp-99-eps-ghostscript) |
EPS/PS/AI via sandboxed Ghostscript (gpl only, off by default) |
Owner decision 2026-09-27 (final), ADR 0011, ADR 0006 WP-99 amendment. Base (gpl r6): Ghostscript 10.08.0 as the single static /opt/xtandard/bin/gs (pngalpha/png16m/bbox, no X11/CUPS/PDF/JBIG2/JPX/TIFF/OCR, resources and fonts compiled in, libc only); licence gate allows ghostscript=AGPL-3.0-or-later exactly and only as gs (--ghostscript), libgs and any other AGPL still fail, slim/full refuse gs; LICENSE-GPL.md, labels AGPL-3.0-or-later AND GPL-2.0-or-later AND MIT; verify-image.sh --flavor gpl rasterises an EPS and checks -dSAFER. Runner (internal/ghostscript, pure Go): one process per page, stdin → PNG stdout into C memory, -dSAFER, fixed device (-g, -dFIXEDMEDIA), bbox origin via BeginPage, DPI capped by XI_FORMATS_EPS_MAX_DPI and XI_LIMITS_MAX_SRC_PIXELS, process group killed on cancel/timeout/oversize, XI_FORMATS_EPS_MAX_PROCS (429), re-exec sandbox helper (nobody when root, pdeathsig, no-new-privs, RLIMIT_CORE/NOFILE/FSIZE/CPU/NPROC/AS), network namespace attempted (Docker’s seccomp refuses it: logged), server non-dumpable. Codec: FormatEPS, DSC/DOS-EPS header parsed in Go (never gs), vector at 72 DPI; without gs a DOS EPS TIFF preview (every flavour) or 422 “EPS needs the gpl image with XI_FORMATS_EPS_ENABLED=true”. Config: XI_FORMATS_EPS_ENABLED (false), _MAX_PROCS (2), _TIMEOUT (10s), _MAX_DPI (600), _GS_PATH (gs); enabled without gs fails startup. |
Header table + fuzz tests (FuzzParseEPSHeader 8 M execs clean); runner tests with a fake gs (timeout, cancel, overload, fork refusal, oversized output, exit codes, no leftover process/temp file) and the real gs (render, AI, page selection, offset boxes; hostile: file read, %pipe%, loop, recursion, memory, huge page → 422/504/clipped, nothing leaked); app tests on and off; gate self-tests; slim/full/gpl verify-image; lint and -race -shuffle on the three builders; acceptance on gpl (EPS on) and slim release images; owner’s ffc.eps by hand: gpl+on 200 image/webp 251×251, gpl off and slim 422 with the message. Size: gpl runtime +11.1 MB compressed (116.6 MB). |
97 |
| WP-100 | done (#103) | Format gap research + variant sample corpus | Owner request: which image and camera RAW formats are missing (Wikipedia’s image-format and RAW pages; “do we have .cr3?”), with samples of each format’s variants as photographers and designers produce them. research/format-gap.md: inventory (raster, vector, designer, camera RAW), LibRaw 0.22 build flags vs the market, rawspeed/RawTherapee/dcraw/DNG SDK/GPR SDK/ExifTool comparison, measured status per format and flavour, ranked gaps, proposed WP-101…108. Gallery: samples.json grows to 290 permissively licensed public URLs with variant and tags (colour type, depth, alpha, animated, HDR/gain map, camera brand/container; large over 20 MB, gap for expected failures), 72 camera RAWs from raw.pixls.us (CC0); the gallery shows the variant and loads no thumbnail for large or gap files; the Format matrix gets a Variant column, a format filter and an opt-in for large files. Findings: CR3 (RAW and C-RAW) decodes; Nikon HE demosaic returns wrong pixels on a Z50 II; 12-bit JPEG answers 500; X3F/GPR/JPEG XL DNG not built; sensors over 50 MP hit the default pixel limit. |
TestGallery (licences, variant, tags, large flag, unique URLs, coverage tags); -tags network link check; matrix on slim/full/gpl release images (full: 247 ok, 39 expected failures, 4 by design); lint and tests on the builder. |
97, 99 |
| WP-101 | done (#105) | Decode correctness: Nikon HE guard, RAW preview fallback, lazy-decode error mapping | RAW preview-only files (FORMATS §2): the shim’s probe reports why LibRaw cannot demosaic a file (RawHeader.Undecodable): Nikon High Efficiency by the makernote NEFCompression 13/14 on any non-DNG Nikon (LibRaw 0.22 flags only Z 9/Z 8/Z f/Z6III and demosaics the Z50II into garbage), a placeholder decoder (LIBRAW_DECODER_UNSUPPORTED_FORMAT: GPR, JPEG XL DNG), or a camera libraw_open_buffer refuses (A7 V) whose previews it still lists inside a sniffed RAW container. Such a file is its full-size embedded JPEG (/info size = preview, every request from it, X-Xi-Raw-Path: preview), or 422 camera RAW not supported (<camera>: <reason>) and the file has no usable embedded preview; xi_raw_load refuses to demosaic it too. Fallback: a RAW whose unpack/demosaic fails (unsupported/corrupt) is planned again on its preview. Lazy decode errors: every xi_load/xi_thumbnail output goes through a pass-through guard image that marks the source failed when the loader cannot compute a region (libvips does not signal “invalidate” from a generate error), so such a saver failure is 422, not 500. 12/16-bit JPEG: refused at probe, 422 “12-bit JPEG is not supported yet” (WP-106 decodes). Fixtures dng/jxl-undecodable.dng, dng/deflate-corrupt.dng (gen dng … <compression>). |
Z50II/Z8/Z f HE and A7 V render from their previews at 160 px, 2000 px and full size, DSSIM ≤ 0.005 against the same request on the camera JPEG (TestRawNetworkSamples, -tags network, samples pinned in rawfixtures.NetworkFiles); both 12-bit gallery JPEGs 422 (TestProbeHighPrecisionJPEGSamples); synthetic preview-only, fallback and attribution tests; matrix on slim/full/gpl release images: no 500 (full 252 ok / 38 expected 422). |
100 |
| WP-102 | done (#105) | Limits and base codecs for modern cameras | RAW-aware pixel limit (transform.SourceLimits.CheckRaw/CheckRawDecode/CheckRawSensor, shared by codec probe, server and planner): at probe a RAW passes when its preview or half-size demosaic fits XI_LIMITS_MAX_SRC_PIXELS; the planner checks the path taken (preview at full size, half-size or full demosaic) and bounds a demosaic by XI_LIMITS_MAX_RAW_DEMOSAIC_PIXELS, default raised 60 → 120 MP; path-specific 422 messages. Base: zstd 1.5.7 (BSD-3-Clause) built for libtiff (-Dzstd=ON, build fails if libtiff does not link it; ZSTD/WebP/deflate TIFF round trip in verify-image.sh); JPEG XL-in-TIFF is GDAL-only (not feasible), WebP-in-TIFF already on, LZMA left off; slim r5, full r6, gpl r7, root Dockerfile on r5; +0.3–0.4 MB compressed per flavour. |
GFX100RF (102 MP) 160/2000 px from its preview and 5000 px half-size, X1D II (51 MP) and Pixel 8 Pro (50 MP) render with default limits, full size stays 422; ZSTD TIFF renders; CONFIG and FORMATS §2 updated; verify-image.sh slim/full/gpl OK; lint and tests on the three builders; acceptance 91/91 on the full release image. |
100 |
| WP-103 | done | LibRaw coverage: snapshots, X3F, GPR, DNG SDK | LibRaw pinned to master 0694b5ac18 (2026-09-25, libraw_r.so.26; +4 bodies over 0.22.2: Sony A7 V (ARW 6), A1 II, Canon EOS R50 V, Hasselblad X2D II; diff in docker/base/README.md) and rebuilt in its own layer (build-libraw.sh) with X3F tools (BSD-3-Clause) in every flavour; owner decision 2026-09-28: the DNG SDK ships only in gpl, where build-libraw.sh --with-sdks (gpl-deps stage, SDKs from their own stage download-libraw-deps.sh, pins in versions-gpl.sh) adds the GoPro GPR SDK VC-5 decoder (Apache-2.0 OR MIT) and the Adobe DNG SDK 1.7.1 build 2724 (Adobe DNG SDK licence: permissive with an indemnity clause, LicenseRef-Adobe-DNG-SDK, ADR 0006 amendment) against the base libjxl/libjpeg-turbo, without XMP; hidden-visibility static archives inside libraw_r. Licence gate: dng-sdk=LicenseRef-Adobe-DNG-SDK allowed only through versions-gpl.sh; the strict (slim/full) gate fails on the SDK’s RTTI names or a pin; slim/full keep WP-101 for JPEG XL DNG and GPR (preview, or 422 “… needs the gpl image”). Integrated with WP-110 in one base revision: slim r6, full r7, gpl r8. Patches: C API handles own a dng_host (LibRaw), XMP-less DNG SDK build, GPR reader for DNG SDK 1.7, VC-5 decoder hardening. Shim: RawLibraryInfo; only JPEG XL and VC-5 data reach the SDKs (use_dngsdk none), linear DNGs get the SDK’s opcode stages. Fixtures dng/jxl-landscape.dng (Adobe dng_validate -losslessJXL, gen/jxl-dng.sh), gpr/hero11-crop.gpr (gen/gpr.sh); gen dng writes CFAPlaneColor as 0xC616. |
JPEG XL DNG (fixture vs its uncompressed original, Adobe’s integer/float linear and Bayer samples), GPR (fixture, HERO11) and X3F (SD14) render; A7 V demosaics (TestRawNetworkSamples, -tags network); libFuzzer + ASan/LSan found out-of-bounds reads/writes, NULL/wild dereferences and leaks in the VC-5 decoder, all fixed; final rounds clean (3 × 15 min, 892 k execs; X3F 20 min; Go FuzzRawLoad 10 min, 3.7 M execs); leak test through the DNG SDK; size +0.4–0.6 MB compressed per runtime. |
101 |
| WP-104 | done (branch m4/wp-104-sequences-gainmaps) |
AVIF/HEIF sequences and HDR gain maps | avis sequences via libheif’s sequence API in the shim (first frame, and frames for animated outputs); keep UltraHDR / ISO 21496-1 / tmap gain maps and write HDR with preserve_hdr to AVIF, JPEG XL or UltraHDR JPEG. Delivered (FORMATS §1.1, §1.4, §3): sequences through the shim’s heifseqload_buffer (internal/vips/heif.c, ahead of heifload for files with a sequence brand, a moov box and a visual track): frames 8-bit sRGB(A), page/n, page-height, delays and count from stts/stsz (bounded box walk), loop from the edit list, media timeline decoded once, libheif’s max_sequence_frames capped to the file’s samples (FuzzHEIFSequence found a 30 s hang on a broken stream with an indefinite edit list); frame limits as for GIF; pages stacks load as one strip. Gain maps: codec/gainmap (pure Go, fuzzed) reads ISO 21496-1 (tmap payload, JPEG APP2), Adobe hdrgm XMP and libultrahdr’s metadata and finds the MPF gain map of JPEGs libultrahdr refuses; the shim’s xi_heif_gainmap decodes a tmap gain map; ImageMeta.GainMap, /info gain_map/hdr. With preserve_hdr (new gain_map option: auto, hdr, sdr) and a float-exact pipeline, the colour step applies the map at full weight and the result is written as HDR: JPEG XL float, AVIF BT.2100 PQ (12-bit, nclx 9/16/9, clli; the shim’s xi_avif_hdr_save, since libvips 8.18 cannot write CICP) and UltraHDR JPEG (uhdrsave); OpenEXR/Radiance/PFM/BC6H preserve_hdr now writes the same (AVIF was 16-bit sRGB clipped at 1). Otherwise, or for HDR bases, unsupported metadata and other outputs, the SDR base (preserve_hdr/gain_map reported skipped). Not done: HDR AVIF with a gain map (libheif 1.23 has no tmap writer), sequence output, Apple’s pre-iOS 18 auxiliary HEIC gain maps. |
TestProbeHEIFSequence, TestLoadHEIFSequence, TestHEIFGainMap, TestSaveAVIFHDR, TestSaveUltraHDR, TestNoLeakHEIF, FuzzHEIFSequence, FuzzHEIFGainMap (5 min each), FuzzParseISO/XMP, FuzzFindJPEG, FuzzHasTmap; exec goldens avif-sequence-*, heic-sequence-webp, avif-colors-animated-webp, gainmap-* (SDR and HDR renderings of UltraHDR, Adobe and tmap sources); /info goldens; all WP-100 sequence and gain-map gallery samples by hand on the release image. task docker:lint, task docker:test (slim, full, gpl). |
100 |
| WP-105 | done | Colour-model and texture fixes | CIE Lab TIFF, CMYK JPEG XL (black extra channel), DDS with DX10 headers and BC6H through a pure-Go, fuzzed BCn decoder. Delivered (FORMATS §1.4, §1.6): Lab TIFF read through a copy relabelled RGB (codec.ParseLabTIFF) and converted as D50 L*a*b* (or the file’s L*a*b* profile) by lcms, 8/16-bit, alpha kept; CMYK JPEG XL through the shim’s jxlcmykload_buffer (libjxl, ahead of jxlload only for files with a black channel); DDS decoded in Go (codec/dds: BC1–BC7, BC6H UF16/SF16 tone-mapped like OpenEXR, uncompressed, palette and float formats; bit-exact with bcdec on every BC6H/BC7 mode), replacing ImageMagick. |
Lab TIFF, CMYK JXL and the DDS gallery samples render; fuzz and leak tests. | 100 |
| WP-106 | done (branch m4/wp-106-highprec-jpeg) |
High-precision JPEG family | Shim loader jpeghpload_buffer (XiForeignLoadJpegHpBuffer, internal/vips/jpeghp.c) over libjpeg-turbo 3.2’s 12/16-bit APIs (already built in every base, no base change), priority above jpegload, claiming only 12-bit DCT frames (SOF0/1/2/9/10) and Huffman lossless frames of 2–16 bits (SOF3; 8-bit lossless included, since jpegload gets 2–7-bit samples wrong and cannot convert lossless YCbCr); every 8-bit DCT JPEG keeps jpegload. Sequential decode (jpeg12/16_read_scanlines, vips_sequential), decoder owned by the generated image; 9–16 bits → ushort scaled to 0–65535 (RGB16/GREY16, bits-per-sample = precision, /info rgb16), ≤ 8 bits → uchar; grey, RGB, DCT YCbCr/YCCK (libjpeg-turbo), lossless YCbCr/YCCK (converted in the shim), CMYK inverted like jpegload; EXIF (orientation parsed by libvips), XMP, IPTC, ICC, JFIF resolution; fail_on as jpegload. Probe: WP-101’s 422 removed; codec.JPEGFrame replaces JPEGPrecision and what no loader decodes gets a 422 naming it (other DCT precisions, hierarchical, arithmetic lossless, JPEG-LS). JPEG-LS not done: CharLS would need a base revision in all three flavours (ADR line, licence gate, SBOM) while others work on the base; it answers 422 “JPEG-LS is not supported” (split out). Fixtures jpeg/*12*.jpg, jpeg/lossless*.jpg from libjpeg-turbo’s cjpeg built from the pinned release in the builder plus gen/jpeghp/encode.c (CMYK, lossless YCbCr), djpeg references in testdata/golden/codec. |
Every sample of the 12-bit colour/progressive-grey and 16/10/8/6-bit lossless fixtures equals djpeg’s at the file’s precision (TestLoadJPEGHPReference); CMYK and lossless YCbCr against the master (DSSIM ≤ 0.01), lossless CMYK exact; metadata, errors, TestNoLeakJPEGHP; FuzzJPEG12 (Go, 10 min, 10.3 M execs) and libFuzzer + ASan/UBSan/LSan over the loader and an instrumented libjpeg-turbo (25 min, 325 k runs, seeded with fixtures and gallery samples) clean; gallery samples (imagecodecs 12-bit colour/grey, 16/8-bit lossless colour/grey, libjpeg-turbo monkey12.jpg) render on slim/full/gpl release images at 160 px, 2000 px, full size and AVIF; lint and go test -race on the three builders; acceptance 125/125 on slim and full. |
101 |
| WP-107 | done (branch m4/wp-107-designer-containers) |
Designer containers | Bounded pure-Go ZIP reader (codec/container/zip.go, not archive/zip): classic EOCD (the last one whose comment ends at EOF) and central directory only, ≤ 20 000 entries, declared total ≤ 4 GiB, the entry read ≤ the source byte limit (64 MiB when unlimited) and ≤ 200× its compressed size, exact size and CRC-32, stored/deflate only (encrypted, other methods, ZIP64, multi-disk → 422), every name checked (absolute, drive letter, NUL, backslash, .. → 422) and duplicate target names refused; inflates straight into the allocator buffer. Containers (codec/container, pure Go, unwrapped by the server before the SVG sanitiser and the probe for processing, /info and watermarks; sniffed by content, never extension): OpenRaster/Krita mergedimage.png (fallback Thumbnails/thumbnail.png / preview.png), Procreate QuickLook/Thumbnail.png (thumbnail, documented), Sketch previews/preview.png, SVGZ (gunzip ≤ XI_LIMITS_MAX_SVG_BYTES and 200×, then the sanitiser), Paint.NET header thumbnail (base64 PNG in the XML header; no thumbnail → 422). /info container object (format, mime_type, entry, content: composite/thumbnail/preview/document; OpenAPI and TS types regenerated), debug header X-Xi-Container-Source (cached with the result), span attribute xi.container. GIMP XCF (codec.FormatXCF): header, image properties and layer table parsed in Go (codec.ParseXCFHeader; indexed, non-8-bit, zlib/uncompressed tiles and v4–v10, which ImageMagick misreads, → 422; ≤ 256 layers); codec.FlattenXCF decodes each visible top-level layer with ImageMagick’s XCF coder from a copy whose layer table lists only that layer (linear, not quadratic), composites with offsets, opacity and blend modes in libvips (groups by their projection; masks not applied; sRGB blending, DSSIM ≤ 0.015 against GIMP 2.10), canvas materialised per layer. Base: magick-policy.xml allows the XCF module and coder (read), shim xi_is_magick_input accepts the XCF signature; built and verified as xtandard-base:wp107[-builder] and wp107-full[-builder] (no revision bump; verify-image.sh decodes xcf/alpha.xcf). Fixtures: XCF (GIMP 2.10) and ORA/KRA (Krita 5.2) made by the applications (testdata/gen/designer.sh), Procreate/Sketch/PDN/SVGZ by gen (designer.go); gallery: 11 new public samples (XCF, KRA, Procreate, PDN, SVGZ; MIT/Apache-2.0/Unlicense) and the four WP-100 gap samples now render. |
FuzzOpen, FuzzOpenZip, FuzzParseXCFHeader; zip-bomb, lying-size, CRC, encryption, method and traversal unit tests and TestDesignerContainers through the service (422 and still serving); XCF against GIMP’s renders; TestFlattenXCFNoLeak, TestDesignerNoLeak; exec goldens xcf*; /info goldens; by-hand requests on the release image. task docker:lint, task docker:test (slim and full, wp107 bases). |
100 |
| WP-108 | done (branch m4/wp-108-long-tail) |
Scientific and legacy long tail | Every item delivered (FORMATS §1.8; ADR 0006 amendment WP-108): FITS done, every flavour, in pure Go (codec/fits) instead of cfitsio: first image HDU, BITPIX 8–64/float, BZERO/BSCALE, fpack tile compression (Rice, GZIP 1/2, dithered float; HCOMPRESS/PLIO → 422), PixInsight/Siril auto-STF stretch. DICOM done, every flavour, in pure Go (codec/dicom) instead of DCMTK/GDCM: first frame, native/deflated/RLE in Go, JPEG 8-bit and JPEG 2000/HTJ2K frames through libvips, rescale + VOI window, MONOCHROME1/YBR/palette; 12-bit and lossless JPEG frames through WP-106’s jpeghpload (integration: samples scaled back to stored values, signed frames sign-extended; ct-jpegll*.dcm, jpeg-extended12.dcm), JPEG-LS → 422; patient data never read (values of the pixel-module elements only), stripped from /info and logs, checked by TestPrivacy and TestDICOMPrivacy; synthetic fixtures only. KTX2 done, every flavour: Go container, Basis Universal 2.50 transcoder (Apache-2.0, libxibasisu, +0.6 MB) for ETC1S/UASTC LDR+HDR/ASTC/XUASTC, BCn and uncompressed levels through codec/dds, zlib/zstd. MNG/JNG done, every flavour, unwrapped in Go by the server (first image; JNG alpha re-encoded as PNG) instead of ImageMagick’s MNG coder. PICT and Photo CD done, every flavour: ImageMagick coders named explicitly on a memfd (the PICT/TGA sniffing collision fixed), headers in Go. EMF/WMF done, gpl only: xi-metafile2svg (libwmf LGPL + fonts, patched SVG device; libemf2svg GPL-2.0-only) in the Ghostscript sandbox (moved to internal/sandbox), SVG then sanitiser + librsvg; XI_FORMATS_METAFILE_*; slim/full 422. Leaf MOS sniffing fixed (LeafData 0x8606 tag). Fuzzed: fits FuzzDecode/FuzzDecodeConfig, dicom FuzzParseHeader/FuzzDecode/FuzzFirstFrame, ktx2 FuzzDecode, FuzzBasisKTX2 + libFuzzer/ASan on the transcoder wrapper, FuzzParsePICTHeader, FuzzParsePCDHeader, FuzzMagickPICT, FuzzOpenMNG, FuzzDetectMetafile; leak test TestNoLeakLongTail. |
Each added format’s gallery sample renders (all 11 on gpl; all but WMF/EMF on slim/full); its coder is fuzzed. | 100 |
| WP-109 | done | Playground Info panel: every /info option | Owner request: the Info panel only asked for basic info. Builder over the /info catalogue (api/meta info_options, apidoc.InfoOptions, with defaults and Info groups): typed arguments per option (blurhash x/y, palette size, hashsum algorithm checkboxes, crop forms with gravity, page, vts/vtk, alpha check_transparency, exif canonical_names, security limits…), presets Recommended / Select all / Server defaults, short names, live signed /info URL with URL, curl and @xtandard/image img.info() TypeScript copies, options kept in the share link. Result: summary card, BlurHash and ThumbHash decoded in the browser (placeholder.js, written for the page) next to a real thumbnail, average/dominant/palette swatches with hex and RGBA copy, hashes with copy, searchable EXIF/IPTC/Photoshop/XMP tables, video container and stream tables, crop and detections over the preview and the thumbnail, raw JSON. Badges from the server: refused options are found by asking for each alone after a failed request (security options 403 without XI_SECURITY_ALLOW_SECURITY_OPTIONS, detection without models) and the rest is loaded; ignored ones come from X-Xi-Ignored, which /info now sends to api/render requests without debug headers. |
scripts/playground-placeholder.test.ts (decoders vs the imageinfo reference vectors and PNGs); playground-check.ts §8 on the full release image (JPEG, PNG with alpha, video, detection photo; canvases painted; placeholder means vs server average; share link; 390 px; labels; keyboard; dark; no console errors or CSP violations); TestInfoIgnoredForPlayground, TestInfoOptionsCoverImgproxy; lint and tests on the builder. |
94, 96 |
| WP-111 | done | Playground layout: global tools, Source/Result clarity, loading states, page/frame/time selectors | Owner feedback: History and Format matrix sat as tabs next to Preview and Info, as if they were about the current asset. Layout: the asset workspace keeps Preview (the result) and Info (the source file), each with a caption and a cross-link (“see output details” / “source info”); Gallery, History (search, clear, open, copy share link, timestamps, thumbnails) and Format matrix (full width, filters, run all, sort, variant column) are global tools behind the header’s Views navigation, as #tools/<name> routes that hide the workspace without touching it (Esc or Back returns with the session intact; the share link always copies the session; focus moves to the tool heading and back to the opener; skip links follow the view). Source vs result: a table of the values both sides have (format and MIME, dimensions, megapixels, bytes, colour space, bands, alpha, pages, orientation; source values from a small /info of the source, else X-Xi-Origin-*) with the change (scale, % saved), result-only stats labelled “Result …”, and the result response headers in words (X-Xi-Result-Width → Result width, X-Xi-Origin-Width → Source width, X-Xi-Raw-Path → RAW decode path…, raw name in the tooltip). Loading states: the previous media is hidden as soon as a request starts, a spinner and status line appear after 150 ms (shimmer off under reduced motion), aria-busy and a polite live region, older requests aborted and late answers dropped so only the latest paints; the same for the Info panel and the matrix thumbnails. Selectors on the preview from the source’s /info: page picker with thumbnails and all pages stacked, still-frame scrubber for animations, video time scrubber with keyframes, best frame, sprite (vtt) and animation (vta), and the X-Xi-Video-Frame used; each written as the current dialect’s options (imgproxy, query, imgix, Cloudinary, ImageKit, wsrv) and disabled with a tooltip where the dialect cannot express it. |
scripts/playground-check.ts §7 on the full release image: tools open/close by nav, keyboard and Back with the session intact, 3 matrix rows run, history search/open/copy, gallery pick, captions and cross-links, Source/Result labels (no bare Type/Size/Status), 390 px per tool, throttled render shows the placeholder and hides the old image, overlapping requests paint only the last, PDF page 2, GIF frame 4 as a still PNG, video scrub + keyframes + sprite, disabled selectors on thumbor; no console errors or CSP violations; lint and tests on the builder. |
94, 96, 109 |
| WP-113 | done (branch m5/wp-130-polish) |
Shim float rounding in xi_recomb/xi_mix |
Found by WP-105: xi_recomb and xi_mix in internal/vips/ops.c round and cast float results back to the input format, so float (scRGB) inputs lose precision; this affects the EXR white-point luminance hdrWhite. Keep float results float for float inputs. Fix (WP-130): the shared helper xi_round_cast_to applied vips_rint whenever the source was float, even when the target format was float too; it now rounds only for integer targets, which fixes Recomb, Mix and CastRound to float at once (TestFloatOpsKeepFractions); all goldens unchanged. |
Float inputs keep fractional values through both ops (unit test); EXR/HDR goldens unchanged or re-reviewed. | 105 |
| WP-114 | done (branch m5/wp-130-polish) |
CMYK import intent | Found by WP-105: CMYK import uses the relative colorimetric intent without black-point compensation (mean error 1.9 against 0.95 with BPC on the CMYK JPEG XL conformance sample). Consider BPC or perceptual for CMYK → sRGB. Decision (WP-130): keep relative colorimetric and turn on lcms black point compensation (libvips black_point_compensation) in every ICC call of the shim (xi_icc_import/export/transform), and in Colourspace for CMYK, whose libvips route (CMYK2XYZ, used for CMYK without a profile and by codec.Sample) has no BPC option: the shim imports CMYK itself with the same profile fallback plus BPC. sharp uses perceptual for CMYK and imgproxy plain relative; neither exposes a knob, so there is none. New fixture tiff/cmyk-press.tif (photo through libvips’ built-in press CMYK profile, no embedded profile): shadow error against the photo 19 → 3.9, darkest luma 45 → 23 (photo 11) (TestCMYKBlackPointCompensation, golden exec/tiff-cmyk-press); existing CMYK JPEG/JXL goldens unchanged (their profile’s black is already L* 0). |
Decision recorded; CMYK JPEG/JXL/TIFF goldens re-measured against an lcms reference. | 105 |
| WP-115 | done | Password-protected PDFs: per-request password in the URL, encrypted | Owner decision (2026-09-28, revised: each PDF has its own password). PDFs that need a user (open) password answer 422 today (password-protected documents are not supported); owner-password-only PDFs already render. Add a URL option carrying the password encrypted with the same AES scheme and key as enc/ sources (e.g. imgproxy-style pdf_password:<enc> / pdp:, plus the equivalent in the query dialect), accepted only on signed requests (unsigned → 403), decrypted server-side and passed to PDFium on load. The plaintext password is never logged, never echoed in errors, /info, X-Xi-* headers or api/meta; the encrypted token is redacted in access logs; the result cache key uses a hash of the password, never the password itself. Wrong password → 422 (wrong document password), missing → the current 422. TS builder (@xtandard/image) helper that encrypts the password client-side with the key, and a playground field that sends it through api/sign (server encrypts). Optional per-source default password (XI_SOURCES_<NAME>_PDF_PASSWORD, {file:}) as a fallback. |
A password PDF renders with the right encrypted password on a signed URL; wrong password → 422; unsigned → 403; the password never appears in logs, errors, headers, /info or cache keys (tests grep the log output); TS helper round-trips with the Go decryptor. Done (URL-DIALECTS §3.7): pdf_password/pdp (imgproxy, /info, query pdf_password=/pdp=/o=pdp:), refused with 403 unless the signature was verified (unsigned and trusted-list signatures are refused), checked before decryption and again by the server; crypt.DecryptPassword + PasswordDigest (HMAC-SHA256 under a key derived from the enc key) in transform.DocumentPassword (redacted by every formatter, wiped when served); a per-request vips.Buffer.WithPassword view (C memory, wiped on release) handed to PDFium through a static GValue, libvips’ copy wiped after the build, thumbnails via thumbnail_image; wrong password 422 and never the fallback image (errs.ErrWrongPassword); access log pdp:REDACTED; source default XI_SOURCES_<NAME>_PDF_PASSWORD; playground field → api/sign encrypts; encryptPdfPassword + builder pdfPassword in @xtandard/image, shared vectors in testdata/security/vectors.json. Verified: vips right/wrong/missing on AES-256 and AES-128 (R4, new fixture) fixtures through probe, load and thumbnail; dialect, query, server, playground and transform tests; FuzzPDFPassword; app end-to-end test (right, other token → cache HIT, wrong, missing, unsigned, /info, query, source default) grepping debug-level logs, headers and bodies for the passwords and tokens; task docker:lint, task docker:test, task ts:check (557 TS tests); acceptance on a slim release image, 133 cases pass (new TestPDFPassword). |
60, 19 |
| WP-116 | done | Playground examples: watermark recipes | Owner request: “in the playground panel, add examples with watermarks”. Recipes as data (internal/playground/recipes.json, embedded, api/meta recipes): title, description, dialect, source (a testdata/fixtures file on the demo’s local source or a samples.json URL), builder entries per chained pipeline (or a hand-written path and query), Info options, tags, needs (watermark, detection, video) and the unsigned URL they produce. UI: an Examples global tool (#tools/examples: cards with a lazily rendered result thumbnail, filter chips, search; cards whose needs the server lacks are disabled with the reason) and a quick “Examples” menu in the Options panel; loading a recipe writes it as a session hash, so it renders, stays editable, shares and goes Back to the tool. 37 recipes: 28 watermark ones (imgproxy default image in each gravity with offsets/opacity/scale, every corner via chained pipelines, wms, wmr, wmsh, re, rotated tiles, ch, wmu fixture and public SVG, wmt Pango footer/multi-line/tiled stamp, animated GIF, resize → watermark → WebP; imgix txt/mark/mark-tile; Cloudinary l_text:/l_fetch: + fl_layer_apply, fl_relative, fl_tiled; ImageKit l-image/l-text; imaginary /watermark, /watermarkimage) and 9 showcase ones (smart crop, face crop, blur faces, BlurHash/ThumbHash info, GIF → MP4, video sprite, PDF page, RAW preview, UltraHDR → HDR AVIF). Builder: Cloudinary components and ImageKit steps chain like imgproxy pipelines; catalogue entries l, fl_layer_apply, fl_tiled, fl_relative (Cloudinary) and l-image, l-text (ImageKit, written l-…,<value>,l-end); option values in path segments are percent-encoded; “Write the path and query by hand” (raw mode, r in the share hash) on any mount; base64 arguments no longer carry the encoded-form pattern (plain text is typed) and patterns are made valid for the v flag. api/meta watermark: whether XI_PROCESSING_WATERMARK_SOURCE is set, never the source. Demo (compose.playground.yml): XI_PROCESSING_WATERMARK_SOURCE=local:///watermark/logo.png (removed again by WP-117), ImageKit bound to local (the web source has no base_url, so ImageKit could not address anything). |
TestRecipesParse builds every recipe as grammar.js does, compares it with the recipe’s URL, parses it with the strict imgproxy/query/imgix/Cloudinary/ImageKit parser (nothing ignored but approximations) and checks the source and every watermark image are fixtures or gallery URLs; TestPlaygroundMetaWatermark (flag set, source never in api/meta); catalogue parse tests for the new entries; OpenAPI and TS types regenerated. scripts/playground-check.ts §9 on a gpl release image with the demo environment: the tool lists 37 cards with live thumbnails, chips and search filter, default-watermark cards follow api/meta, loading wm-text-footer, imgix-txt, cloudinary-text (tool), imagekit-image, imaginary-text (raw mode), wm-corner (quick menu) and blurhash-info (Info tab) renders with the recipe’s URL, editing the watermark position rewrites the URL and re-renders, Back returns to the tool; no console errors or CSP violations. |
80, 94, 111 |
| WP-117 | done | Playground: explicit watermarks, no hidden default | Owner feedback: a watermark that appears from nowhere visible in the URL is confusing; in the playground everything must be explicit. Demo: compose.playground.yml no longer sets XI_PROCESSING_WATERMARK_SOURCE (it stays empty by default; it exists for imgproxy compatibility). Recipes: the 12 that relied on the default image (wm-corner, wm-top-left, wm-center, wm-south, wm-four-corners (a wmu per chained pipeline), wm-size, wm-shadow, wm-rotate, wm-tiled, wm-tiled-rotated, wm-chessboard, wm-gif) name the fixture logo with wmu: (base64 of xi://local/watermark/logo.png) and keep their position/opacity/scale/size/rotation/shadow/tiling settings; imgix mark, Cloudinary l_fetch:, ImageKit l-image and imaginary /watermarkimage were already explicit. One new recipe, wm-default “Default watermark (imgproxy compatibility)”, explains the server option and is the only one with needs: ["watermark"]: disabled with the reason when api/meta.watermark is false (the demo), working where a deployment configures it (38 recipes). Builder: wm without wmu/wmt shows a notice under the option (“Uses the server’s default watermark (XI_PROCESSING_WATERMARK_SOURCE) — not configured / configured on this server”) with a one-click “Use an explicit image (wmu)” that adds wmu with the fixture logo; wmu (and imgix mark) show the image URL in plain text with a thumbnail and a link to the original. Preview/Info: a line under the preview says where the source comes from (“file detect/cat-laptop.jpg on the “local” source (fs)“, public URL, built-in sample or upload) and each watermark image of the session, each with a link to the original (a public URL itself, anything else through api/render without options); the Info tab repeats the source line. |
TestRecipesParse fails if any recipe other than wm-default draws the default watermark (a parsed watermark with neither a source nor text) or needs it, and if wm-default does not. scripts/playground-check.ts §9 on a gpl release image with the updated compose.playground.yml environment (no default watermark): api/meta.watermark false, only wm-default needs it and is disabled with its reason (card and quick menu), all 28 explicit watermark recipes enabled; wm-corner, wm-four-corners, wm-tiled-rotated, wm-chessboard, wm-size, wm-gif render with the watermark drawn (0.8–6.8% of the pixels changed against the source); the wmu field shows the decoded URL with a thumbnail; the source and watermark origin lines link to the originals (the watermark link serves image/png); clearing wmu shows the notice and draws nothing (0.000% changed), the one-click action restores wmu and re-renders; no console errors or CSP violations (203 checks). |
116 |
| WP-118 | done | Release follow-ups from the local v0.1.0-rc.1 | Three items from the local prerelease (docs/RELEASE.md “Local release”). Sandbox under emulation: on amd64 under Rosetta every sandboxed program SIGTRAPped (rosetta error: mmap_anonymous_rw mmap failed under the 1 GiB RLIMIT_AS), TestEPS failed and the gpl server could fail to start because XI_FORMATS_METAFILE_ENABLED=auto treated a failed converter self-check as fatal. Now auto logs the warning metafile conversion unavailable (with the reason) and serves WMF/EMF as 422, only true is fatal; XI_SANDBOX_MAX_AS (default 1GiB, 0 = off) sets the base RLIMIT_AS; XI_SANDBOX_DETECT_EMULATION (default true) detects Rosetta (VirtualApple cpuinfo, the rosetta binfmt handler) and QEMU user mode (a foreign cpuinfo) and drops only RLIMIT_AS there, keeping CPU, NPROC, NOFILE, FSIZE, core, timeout and no-new-privs; startup logs rlimit_as, emulation, rlimit_as_off and warns sandbox without an address-space limit (ADR 0011 amendment, CONFIG). release.yml for three flavours: meta resolves the slim/full/gpl bases (base-image-ref.sh --flavor), build is a flavour × arch matrix (XI_FLAVOR, GO_TAGS=ffmpeg for full/gpl) with verify (XI_EXPECT_FLAVOR) and acceptance per digest, SBOM and provenance per manifest, …-<flavour>-linux-<arch>.debug; merge per flavour tags, signs and attests; moving tags only for stable releases; a release job attaches binaries, SBOMs and SHA256SUMS. OCI labels per flavour: Dockerfile stages runtime-slim/-full/-gpl selected by XI_FLAVOR (licences MIT, or GPL-2.0-or-later AND AGPL-3.0-or-later AND MIT AND LicenseRef-Adobe-DNG-SDK for gpl; description naming the flavour; dev.xtandard.flavor); full/gpl without GO_TAGS=ffmpeg fail the build; verify-app-image.sh checks them against the base’s dev.xtandard.base.flavor and defaults the size limit per flavour. |
TestMetafileAutoDegrades (a converter failing its check: the server starts, warns, WMF/EMF 422 without the error text; true fatal), TestSandboxAddressSpace, TestDetectEmulation, TestAddressSpaceBase/Limit, TestAddressSpaceApplied (real helper, ulimit -v). task docker:lint 0 issues (slim, full builders); task docker:test 77/77 packages on slim, full and gpl builders. Native arm64: three app images built with task release:build, verify-app-image.sh OK (slim 75.3 MB, full 144.1 MB, gpl 159.9 MB compressed, flavour labels) and acceptance 133/133 each. Under Rosetta (amd64 gpl builder): internal/ghostscript and internal/metafile fail on the previous main and pass; app TestEPS, TestMetafiles, TestMetafileAutoDegrades pass; an amd64 gpl app image built on the GHCR r9 bases passes acceptance 133/133 (was 132/133) and logs emulation=rosetta, rlimit_as=0; with XI_SANDBOX_DETECT_EMULATION=false it still starts and warns. actionlint clean on release.yml; docker build --check clean. |
99, 108, 37 |
| WP-110 | done | classify info option: ImageNet classifier on CPU |
Owner decision: a permissively licensed ImageNet classifier, CPU only, in the full and gpl images, run only when requested. Model: timm mobilenetv3_large_100 (ra_in1k, 75.8% ImageNet top-1, 1000 classes) as exported by the ONNX Model Zoo, Apache-2.0, 22 MB, pinned by commit + SHA-256 with its licence (CLASSIFIER_* in versions-full.sh, MODELS.md, SHA256SUMS, licence gate --models); chosen over EfficientNet-Lite4 (80.4%, 52 MB, 72 ms), EfficientNet-B0 (77.7%, 35 ms) and MobileNetV2 (70.9%, 15 ms) at 16 ms per inference on one arm64 core (onnxruntime 1.30). Runtime (internal/detect): lazy session (created by the first Classify, none for a deployment that never classifies), timm’s evaluation transform in libvips/Go (cubic resize of the short side to 256, centre crop 224, ImageNet mean/std; full/half/none normalisation and NHWC for mounted models), softmax, embedded ImageNet names (imagenet-simple-labels, public domain) or XI_DETECT_CLASSIFY_LABELS; a missing classifier disables classification only. App: the same upright thumbnail and detection cache as do (one decode for do:1/cl:5; video frames like WP-91); xi_detect_duration_seconds{model="classify"}. /info: cl:<top_k>[:<class>…] → imgproxy Pro’s "classification": [{class_id, name, confidence}], capped by XI_DETECT_CLASSIFY_TOP_K_MAX (20), XI_DETECT_CLASSIFY_THRESHOLD (0), class filter case-insensitive with _ for a space (unknown → 400); slim answers without it and X-Xi-Ignored: classify (classification needs the full image). |
Ten public-domain/CC0 photos (testdata/fixtures/{detect,classify}) against the reference top 5 of timm’s transform in Pillow/onnxruntime (testdata/classify): same top 1, the reference top 1 within 0.1 (observed ≤ 0.09, mostly ≤ 0.01), the subject first on 7 of 8 labelled photos (red fox in the top 5 as grey fox’s runner-up, like the reference); leak test; BenchmarkClassify 18.3 ms ± 14% per image with preprocessing (arm64, benchstat -count=10); first request loads the session in ~0.1 s, a cached result answers in < 1 ms; fuzz for the class names parser and the softmax; plan, server, dialect, app (fake and real model) tests; slim X-Xi-Ignored; task docker:lint/docker:test on slim/full/gpl builders (xtandard-base:wp110*); acceptance on a full release image (all pass, new TestClassification); /info/insecure/cl:5/plain/local:///detect/cat-laptop.jpg by hand: Siamese cat 0.65, notebook computer 0.09. Image size: +20.3 MB compressed on full and gpl. |
71, 91 |
| WP-CLIP | planned | CLIP open-vocabulary classification (future option) | Classify against labels given in the URL or config instead of ImageNet’s 1000 classes: a permissively licensed CLIP-style image encoder plus pre-computed text embeddings (or a text encoder), cosine similarity, same classify shape. Needs a model and licence review (OpenCLIP weights vary), size budget (≥ 100 MB) and a tokenizer in Go. |
Model chosen with accuracy/latency/size numbers; zero-shot results on the classification photos. | 110 |
| WP-CUDA | planned | CUDA image variant (future option) | An opt-in image flavour with ONNX Runtime’s CUDA execution provider for detection and classification on NVIDIA GPUs (the CPU path stays the default and the only one in slim/full/gpl); provider selection and fallback in internal/detect/ort, CUDA/cuDNN licence and size review. |
Same results as CPU within tolerance on a GPU host; latency numbers; image size reported. | 110 |
| DOC-1 | done (#104) | User docs: multiple sources, configuration, API reference | Owner request. SOURCES.md (new): concepts, the resolution order of Registry.Resolve (xi://<name>/, scheme references with the mount’s source first and then alphabetical order of name, http allowlists, relative keys on the mount’s source), a worked resolution table, duplicate bucket names across providers (use xi://), credentials (env, _FILE, {file: …}, provider identity), a key reference per type, provider recipes (AWS S3, R2, B2, Wasabi, Spaces, RustFS/MinIO, GCS, Azure, fs, FTP/FTPS/SFTP, http), complete env / YAML / TOML / Compose / Dokploy-Coolify / Kubernetes examples in docs/examples/, security notes and the public 403/400 messages. CONFIG.md: “Configuration methods” (env, file, secret files, precedence, XI_ALLOW_UNKNOWN_ENV, config validate). OPENAPI.md: “How to view it” (admin listener, XI_ADMIN_DOCS → /docs, /openapi.json, /config.schema.json, CLI, Pages not live yet, TS client, editor setup). README: “Sources” and “API reference”. Fixes found on the way: Validate rejected the https://… allow patterns the registry supports and CONFIG.md documented (now accepted; other schemes still rejected); ARCHITECTURE §6 “configuration order” is the alphabetical order internal/app builds (sourceOrder). |
internal/config: every docs/examples config (loader + JSON Schema; YAML and TOML equal; every source type covered), env file, Compose service (environment, bind-mounted config, only its declared secrets) and Kubernetes Deployment (env, secretKeyRef, ConfigMap and Secret volumes, only the mounted keys) loads; every config/dotenv block of SOURCES.md and the README loads and file-tagged blocks equal their file; the §2 tables match every variable, XI_SOURCES_<NAME>_* included (existing test). internal/app: every row of the SOURCES.md resolution table resolves as written against the registry built in the app’s order. task docker:lint, task docker:test. |
95 |
M5: video transformations
Section titled “M5: video transformations”Goal (owner decision 2026-10-04): video transformations comparable to Cloudinary video and Cloudflare Media Transformations / Stream, including audio-only output, in the same binary and the full/gpl images (slim stays without video), as a separate subsystem with its own pool and limits, off by default, deployable as a dedicated role. Phase A (WP-121 to WP-125, WP-127): short synchronous transforms, materialised, cached and served with Range. Phase B (WP-126): HLS/DASH VOD with just-in-time segments and ABR ladders. Out of scope: live streaming, upload/catalogue, DRM. Design: ADR 0012.
| ID | Status | Package | Scope | Done when | Depends |
|---|---|---|---|---|---|
| WP-120 | done (branch m5/foundation-integration; bases full r9, gpl r10) |
ADR, roadmap and FFmpeg base | ADR 0012 (architecture, alternatives, licences per flavour, DoS posture); this table; base (full and gpl): FFmpeg with libswresample, the native audio decoders (AAC, MP3, MP2, Opus, Vorbis, FLAC, ALAC, AC-3/E-AC-3, PCM), encoders AAC (native), Opus (libopus 1.6.1, BSD-3-Clause, new), MP3 (LAME 3.100, LGPL-2.0+, new), FLAC and PCM; demuxers MPEG-TS, MP3, AAC, AC-3, FLAC, Ogg, WAV; muxers M4A (ipod), MPEG-TS, HLS, DASH, segment, MP3, Ogg, Opus, ADTS, WAV, FLAC; filters for trim/geometry/fades/overlays, drawtext (FreeType, HarfBuzz, FriBidi, Fontconfig from slim), aresample, volume, loudnorm, amix, showwavespic; still only the file/pipe protocols, no concat/HLS/DASH demuxers, no network. gpl keeps reusing full’s configure + x264/x265. ADR 0006 amendment, base README, NOTICE. |
Three bases build; verify-image.sh (A/V MP4/WebM, audio-only MP3/M4A/Opus/FLAC/ADTS, one graph with trim + scale + overlay + drawtext + loudnorm, HLS fMP4 and DASH packaging, protocol/demuxer/encoder allowlists) and the licence gate pass on full and gpl; slim content unchanged; task docker:test green on the full and gpl builders. |
— |
| WP-121 | done (branch m5/foundation-integration) |
Video subsystem core (internal/media, internal/video planner, internal/video/ffmpeg transcode.c/output.c, internal/server/transcode.go) |
The transcoder in the shim (demux → decode → libavfilter chain per stream → encode → mux, video and audio, custom read/seek AVIO over the source in C memory, write/seek AVIO into a growable C buffer with +faststart/cues_to_front, handed to Go as a refcounted buffer; abort flag; demuxer/decoder whitelists, no protocol); the model transform.VideoOp/transform.KindVideo (numbers and enums only); the cgo-free planner video.TranscodeCaps.Resolve (container → muxer + codecs per build: MP4, WebM, M4A, Opus, MP3) and video.PlanTranscode (cut, fit/fill/pad/crop, rotate/flip, fps, CRF or bit-rate cap, audio on/off, chains written from fixed names and numbers); internal/media.Service: its own internal/limiter pool (XI_VIDEO_TRANSFORM_WORKERS, _QUEUE, _QUEUE_TIMEOUT, _THREADS), coalescing of identical transcodes on a context cancelled by the last waiter, XI_VIDEO_TRANSFORM_TIMEOUT; input and output limits (_MAX_INPUT_*, _MAX_OUTPUT_*, _MAX_FPS) checked from the probe before an encoder opens; `XI_ROLE=all |
image | video(image builds no pool, 404 for video; video 404 for everything else);XI_VIDEO_TRANSFORM_ENABLED(default false,truewithout FFmpeg fails startup);XI_VIDEO_TRANSFORM_UNSIGNED_POLICY(defaultdeny: unsigned video 403 before any fetch; integration); server: per-request budget and write deadline via http.ResponseController, result cache with the resolved output in the key, ETag/304, HEAD, Range/If-Range/206/416viahttp.ServeContent; xi_video_transform_*metrics,transcodespan. No URL syntax yet (WP-122/125);internal/apptests route/_video/…` through a test-only router hook. Deviations from the first ADR draft (names, package, chain text, in-memory buffers) are recorded in ADR 0012 “Reconciliation with WP-121”. |
| WP-121b | done (branch m5/wp-121b-video-spool; ADR 0012 “Spool files”) |
Spool large video inputs/outputs to disk | Fetch video sources to a spool file (XI_VIDEO_TRANSFORM_SPOOL_DIR, _SPOOL_MAX_BYTES) read by the shim through a read/seek AVIO over a file descriptor, and encode into a spool file through a write/seek AVIO (faststart rewrite included), so a transcode’s memory no longer grows with MAX_INPUT_BYTES + MAX_OUTPUT_BYTES per worker; an optional range-capable result-store interface (disk: the file; S3: ranged GET) so a Range request on a cached result never reads the whole object; the memory tier skips video results above a size. Delivered: internal/spool (unlinked 0600 temp files in XI_VIDEO_TRANSFORM_SPOOL_DIR, stale xi-spool-* files removed and the directory checked writable at startup, a semaphore.Weighted byte budget XI_VIDEO_TRANSFORM_SPOOL_MAX_BYTES → 503 + Retry-After: 5, results as cache.FileBuffer); fetch.FetchLarge (sources over XI_VIDEO_TRANSFORM_SPOOL_THRESHOLD, default 32 MiB, declared or found while reading, go to a spool file; spooled sources shared for XI_VIDEO_TRANSFORM_SPOOL_TTL with conditional revalidation, single-flighted, reclaimed under budget pressure) used by video requests and every stream part; the shim reads a spooled source with pread on a duplicated CLOEXEC descriptor (xi_tx_new_fd, protocol whitelist still empty, no Go callback) and moves an output past the threshold into a spool file (spill_fd, pwrite, faststart and cues_to_front included); respondVideo serves file bodies by range; disk tier streams them in and answers hits ≥ 8 MiB from the entry file; memory tier skips them; redis checks its limit on the length; S3 uploads from the file; xi_video_spool_* metrics; DEPLOY notes in RELEASE.md “Running read-only” and the compose/k8s examples. Deviation: S3 hits are still read whole (no ranged GET). |
Peak RSS of a 500 MB source → 200 MB output transcode stays within a fixed bound (measured); Range on disk and S3 hits reads only the range (test with a counting store); spool files removed on success, failure, abort and shutdown (leak test); read-only rootfs with the spool on /tmp. Verified: TestTranscodeFromFile/TestTranscodeSpill (file sources and spilled MP4/WebM/M4A byte-identical to memory), TestSpoolNoLeak (descriptors, handles, outputs and spool usage back to zero over spilled, in-memory and aborted runs), spool/fetch leak tests on success, error, cancel and TTL (synctest), budget 503 with Retry-After (server, app), Range on spooled outputs and disk hits without mapping the file, TestStreamSharesSpooledSource (13 origin downloads → 1 for a 10-part HLS stream), BenchmarkVideoSpool (104 MB AVI source, per-process peak RSS, internal/app/testdata/bench-spool.txt). |
121 |
| WP-121c | done (branch m5/wp-121c-video-admission; ADR 0012 “Cost-based admission”) |
Cost-based video admission | Estimate a transcode’s cost from the probe (output duration × output pixels × fps × codec factor) and admit it against a weighted capacity, so a 4K AV1 job takes more of the pool than a 360p VP9 one; decide where the probe runs. Delivered: video.EstimateCost (pure; decode + encode pixels × frames × per-codec factors measured by BenchmarkTranscodeCost on the gpl builder, internal/video/ffmpeg/testdata/bench-transcode-cost.txt; audio per second and codec; waveforms; per segment for HLS/DASH; ≥ 1); internal/media admission (admission.go): a place (MAX_JOBS + QUEUE, 429), the probe before admission under a probe bound of WORKERS (no decode of the cut), then a run slot (XI_VIDEO_TRANSFORM_MAX_JOBS, default 2 × workers) and the cost in units of XI_VIDEO_TRANSFORM_CAPACITY (default workers × 10000, about ten seconds of work per worker), FIFO semaphore.Weighted (no starvation of large jobs, documented head-of-line blocking), queued cost bounded by XI_VIDEO_TRANSFORM_QUEUE_COST (429 queue_cost), probe + wait bounded by _QUEUE_TIMEOUT (503), Retry-After as before; prefetch only when a slot and the segment’s cost are free at once; metrics xi_video_transform_job_cost, _capacity, _capacity_used, _queued_cost, _overload_total{reason="queue_cost"}. Deviations: a job over the whole capacity is clamped and runs alone instead of 422 (brief); the key is XI_VIDEO_TRANSFORM_CAPACITY, not _COST_BUDGET. |
synctest tests with a fake engine: heavy and light jobs interleave (light ones beside a heavy one, a waiting heavy one is not overtaken), MAX_JOBS, queued cost → 429, queue timeout → 503, full pool → 429, clamped job runs alone, cancel while queued or probing releases only its place, prefetch backs off under load and a request that joined it still runs; cost table tests; estimates 0.78–0.96 of measured on real footage and within 2× on every fixture for every codec (benchstat -count=10). |
121 |
| WP-122 | done (branch m5/wp-122-123-video-options-audio) |
Video transform options (imgproxy-style URLs) | URL syntax (URL-DIALECTS §3.8–3.9): /video/<signature>/<options>/<source>@<container> through a new router.VideoParser (<mount>/video, like /info), the signature covering /video/ + the path so no image signature is a valid video URL; /q/video?url=… in the query dialect (every option as a key, xi_sig over /video + query). Options: start/end/duration (vs/ve/vd, exact cuts), imgproxy’s geometry names with video semantics (rs, s, rt fit/fill/fill-down/force, w, h, el, ex pad, g compass point, its offsets moving crops, c gravity-placed crop, rot, fl, bg), fps, f/extension containers, vc/ac, q, crf, vb (k/M), ki keyframe interval; values validated while parsing (400 naming the field). Overlays (WP-124, after its merge): wm/wmu/wmt/wms/wmr/wmsh parse as for images into VideoOp.Overlays, timed by ow/of, plus tc timecodes; the /_video test router hook is removed. Model: reuses WP-125’s VideoOp.Gravity/VideoCrop.Gravity; adds KeyframeInterval, FadeIn/FadeOut and crop sides of 0 (whole side). Declarative catalogue + ImgproxyVideoOptions JSON Schema, OpenAPI getImgproxyVideo/getQueryVideo, playground video_options, @xtandard/image videoUrl()/video() with generated types; Deviations: keyframe-only (“fast”) cuts and /info reporting the planned output are not implemented (exact cuts only; /info reports the source). |
Golden tables (testdata/golden/video.json: 139 imgproxy cases, 25 query cases) and fuzzers (FuzzParseVideo, query FuzzParse seeds); planner tests (crop sides, GOP, fades; TestFilterChainsAreNumeric extended to every new chain); transcode tests checked with ffprobe (size, codec, keyframes); cross-language fixture (93 /video cases built by the TS builder and parsed by Go); app tests with signed URLs (every container, Range, /q/video, image signature refused); docs in URL-DIALECTS, FORMATS §5.2, CONFIG, README. |
121 |
| WP-123 | done (branch m5/wp-122-123-video-options-audio) |
Audio | Mute; audio-only outputs m4a (AAC), mp3 (LAME), opus and ogg (Ogg Opus), flac, wav (16-bit PCM) from video or audio sources (the shim now demuxes MP3, AAC, AC-3, E-AC-3, Ogg, FLAC and WAV); ab, sr (per-codec rates, 422 when unsupported), ach (mono/stereo downmix), vol (factor or dB), ln (single-pass loudnorm, EBU R128 target per request within FFmpeg’s bounds, default -23 LUFS / -1 dBTP / 7 LU), fd fade in/out (audio and video); waveform pictures (wf, or a png/webp container): showwavespic into raw RGBA in the shim, flattened and encoded by libvips; /info audio streams gain channels. |
Each output decoded to the end by ffprobe with the requested codec, rate and channels (TestTranscodeAudioOutputs), outputs re-read as sources; normalised output within ±1 LU of -23 and -16 LUFS (TestTranscodeLoudness, measured by FFmpeg’s EBU R128 meter); fades by PCM RMS; waveform golden compared perceptually (testdata/golden/video/waveform-tones.png); a silent video gives 422 for audio-only outputs and waveforms. Audio fixture testdata/fixtures/audio/tones-stereo.mp3 (CC0). |
121 |
| WP-124 | done (branch m5/wp-124-video-overlays; model, renderer, shim; URL syntax with WP-122/125) |
Overlays on video | Image watermarks (fetched through the source path, prepared by libvips into RGBA, overlay with position, offset, scale relative to the video, opacity, time window) and text: static text rendered by the existing libvips/Pango text watermark, dynamic text (timecode) with drawtext and a planner-built expansion only; no request-controlled fontfile/textfile. Reuses the image watermark option names. |
Golden frames at given timestamps show the watermark at the right place and opacity; text matches the image path’s rendering; overlays outside the time window absent; a hostile text (graph syntax, %{…} expansions) renders literally. |
122 |
| WP-125 | done (branch m5/wp-125-video-dialects; URL-DIALECTS §6.1, §7.1, §8.2) |
Video dialects | Cloudinary /video/upload/ transformations (so/eo/du, w/h/c/g, ac_none, vc, br, q, fps, fl_attachment, l_ image/text overlays, e_volume, e_fade, audio formats, fl_waveform) beyond today’s so_ frames; Cloudflare /cdn-cgi/media/<options>/<source> with mode=video|frame|spritesheet|audio, time, duration, width/height/fit, audio=false, format; ImageKit video tr parameters (w, h, so, eo, du, vc, ac, f, q, br, l-image/l-text). Each maps onto video.Transform (or the existing frame/sprite path); unsupported options answer 400 in strict mode. |
Every option in each dialect’s table has a parse + golden test against the real provider’s documented output shape (.refs SDKs and docs); fuzzed parsers; URL-DIALECTS updated; the playground translate emitters cover the video options. Delivered: Cloudflare /cdn-cgi/media/ (dialect cloudflare_media: video/audio → KindVideo, frame → thumbnail path, spritesheet → tile path; Cloudflare ranges), Cloudinary /video/<type>/ (KindVideo unless the output is an image format), ImageKit video files and ik-video.mp4; VideoOp.Gravity, VideoCrop.Gravity and VideoOp.AutoContainer (f_auto/f-auto by Accept, Vary: Accept) added; Cloudinary l_ groups and ImageKit l-image/l-text layers become WP-124 overlays (window from so_/eo_/du_ and lso/leo/ldu, Cloudinary e_fade fades), audio effects “M5 (WP-123)”, streaming (done in WP-126); golden tables, fuzzing and an end-to-end test (signed URLs, ffprobe) per dialect. Playground translate has no video path yet (WP-127); video presets for the unsigned policy not done. |
122, 123, 124 |
| WP-126 | done (branch m5/wp-126-hls-dash; ADR 0012 §6, URL-DIALECTS §3.10, FORMATS §5.2) |
HLS/DASH JIT packaging and ABR (phase B) | Master/media playlists and MPD from the probe and a configured ladder (XI_VIDEO_TRANSFORM_ABR_LADDER, XI_VIDEO_TRANSFORM_SEGMENT_DURATION), CMAF fMP4 init + media segments transcoded on request on a fixed grid (forced keyframes, offset timestamps), audio renditions transcoded once and split, MPEG-TS segments in gpl when asked, segment cache entries, bounded prefetch (XI_VIDEO_TRANSFORM_PREFETCH_SEGMENTS), CODECS strings from the encoders used; dialect entries (Cloudinary sp_ streaming profiles / .m3u8, Cloudflare Stream-style manifest URLs where they map). |
hls.js and dash.js (playground) and ffprobe play every rendition end to end without gaps or timestamp jumps across segments; first-segment latency and segment throughput measured with benchmarks; a segment request never transcodes more than its window plus one GOP. Delivered: VideoOp.Stream (protocol, renditions, profile bounds, segment duration); video.PlanStream (manifests from the probe: ladder rungs no larger than the source, constant frame-rate grid, segment count, codec strings matching the encoders’ init segments, a stream offset that keeps composition offsets non-negative), PlanSegment and PlanAudioTrack; the shim writes CMAF (empty moov, absolute tfdt, delay_moov for AV1) and cuts the single audio encode into fragments on the grid; media/fmp4 splits init and fragments; media/manifest writes HLS v7 and DASH MPDs; security/ticket seals child URLs (/_stream/<ticket>/…, AES-256-GCM, expiry, rotation); stream cache + result cache; prefetch on idle workers only (limiter.TryAcquire); Cloudinary sp_/.m3u8/.mpd, ImageKit ik-master + sr-, imgproxy/query @m3u8/@mpd, st, sgd; XI_VIDEO_TRANSFORM_ABR_LADDER, _SEGMENT_DURATION, _PREFETCH_SEGMENTS, _STREAM_TTL, _STREAM_KEYS, _STREAM_CACHE_BYTES; XI_SERVER_CORS_ORIGINS implemented. Verified with ffprobe on the joined segments (frame grid, a keyframe per segment, no audio gap, A/V sync) in the full and gpl builders, and by hand with a stock FFmpeg playing the HLS and DASH URLs of a release image; the playground (WP-127 hook) plays HLS natively where the browser can (Safari) and lists and links the playlists elsewhere through its api/stream, checked by scripts/playground-check.ts (master, media playlist, first segment); hls.js/dash.js are not vendored (no CDN; an ADR would be needed). Not done: MPEG-TS segments, chunked audio for sources longer than the transcode timeout allows, overlays on streams (all three done in WP-131), Cloudflare Stream manifest URLs (another product). |
121, 122, 123 |
| WP-127 | done (branch m5/wp-127-playground-video) |
Playground video tools and docs | Playground: video sources in the Gallery, a video Preview (<video> with Range), the option builder for WP-122–126 options per dialect, audio player, waveform, HLS/DASH player (vendored hls.js/dash.js, no CDN), recipes for common video transforms; docs: FORMATS video output section, CONFIG video section, URL-DIALECTS video options, README feature list, deployment notes for XI_ROLE=video. Delivered: a Video and audio panel (image or video-transform mode; trim timeline with start/end handles over the probed duration; container and codec choice from this build’s encoders, api/meta video_transform; audio, overlay, timecode and waveform controls) over the /video catalogue on imgproxy (/video) and query (/q/video) mounts; api/sign signs /video paths (domain-separated); api/probe and api/probe/result (FFmpeg probe of a source, audio included, and of a result body); <video>/<audio> results with elapsed time and Cancel; a media Source vs result table; translate emitters for imgproxy /video, /q/video, Cloudinary /video/, ImageKit video and Cloudflare /cdn-cgi/media/ with a golden round-trip matrix; 10 video recipes (TestRecipesParse); an audio sample; demo config (compose.playground.yml: video on, unsigned allowed for the demo only, Cloudinary and Cloudflare bound to the fixtures); README. Deviations: results are fetched as blobs (no Range through api/render); HLS/DASH: no player library (CSP, no CDN, no vendoring without an ADR): a manifest shows its entry list and native HLS where the browser has it is a marked hook for WP-126; FORMATS/CONFIG/URL-DIALECTS video sections came with WP-121–125 and are unchanged. |
Every recipe renders in the full and gpl demo; docs consistency tests green; screenshots in the PR. | 122–126 |
| WP-128 | done (branch m5/wp-128-media-compare) |
Playground before/after for video and audio | Owner feedback: the Result played but the Source was a still. Server: raw:1 on /video (VideoOp.Raw, URL-DIALECTS §3.8) streams the media source unmodified as a video request (signature, unsigned policy, role, allowlists, SSRF-safe fetch, XI_VIDEO_TRANSFORM_MAX_INPUT_BYTES, hashsum), only sniffed video/audio types (422 otherwise), Range through http.ServeContent, never result-cached; api/render?ranges=1 forwards Range/If-Range. Playground (compare.js): the source plays (poster: its frame or waveform; own loader); a shared bar (play/pause k, seek, speed, sound, A/B x, link toggle) drives both; result time t ↔ source start + t × scale (scale ≠ 1 only when durations differ by > 5 %); trim window on a source timeline in every mode; slider wipe and side by side for video, stacked waveforms with playheads for audio; drift smoothed and corrected by rate nudges (> 1 frame, ≤ ±10 %) and seeks (> max(0.25 s, 4 frames)), unlink after > 5 hard re-syncs in 10 s; an unplayable source falls back to its still; % and × deltas in the media table. |
Go tests: raw dialect goldens and TS fixtures, server (TestVideoRaw*: Range, 416, If-Range, 304, HEAD, no transcode or cache, unsigned 403, role 404, allowlist 403, size 422, images/SVG 422; TestSniffMedia, FuzzSniffMedia), app end to end with FFmpeg (signed raw of WebM, MP4, MP3 with Range; unsigned and image signatures 403); scripts/playground-check.ts §11 against the gpl demo image (players ready, same-origin 206 source, linked play, drift after seeks, wipe clip, unlink, audio waveforms and A/B). |
127 |
| WP-129 | done (branch m5/wp-129-media-recipes) |
More video and audio examples in the playground | Owner request after WP-128: more media examples. Samples (internal/playground/samples.json, linked, never vendored; HEAD-checked by go test -tags network): Sintel trailer 720p and Big Buck Bunny trailer 480p with 5.1 AAC (download.blender.org), Big Buck Bunny 10 s 1080p60 H.264 and 1080p HEVC (test-videos.co.uk), all CC-BY 3.0 Blender Foundation; NASA vertical (9:16), talking-head and silent-audio timelapse clips (images.nasa.gov, public domain); a quiet LibriVox reading (public domain) and a Musopen Chopin waltz (CC0) on archive.org. Wikimedia originals were left out: upload.wikimedia.org answers 403 to Go’s default User-Agent and 429 after a few downloads. One generated fixture, audio/pan-sweep-stereo.ogg (CC0, testdata/gen/audio.sh). Recipes (recipes.json, 55 new, category video, tags video/audio): trims, social formats (9:16 pad and crop + gravity, 1:1, 4:5), portrait rotate/flip/pillarbox, fps 60 → 30/24, CRF ladder, H.264/AV1/VP9 and an HEVC source, mute, M4A/MP3/Opus/FLAC extraction, loudnorm up and down, volume, fades, mono downmix, resampling, waveforms, logo, lower third, timecode, chessboard, loop, HLS/DASH, raw, /q/video, Cloudinary, ImageKit and Cloudflare media equivalents. New recipe needs h264 and hevc (gpl encoders, video_transform.codecs): disabled with the reason on full, hidden without FFmpeg; TestRecipesParse checks that every H.264/HEVC output declares them. Gallery labels for MOV and MP3; an Audio filter chip. Demo: XI_FETCH_TIMEOUT=30s. |
TestRecipesParse (strict parsers), TestGallery, network link check; every new recipe URL 200 against the gpl demo image; scripts/playground-check.ts §10f (gpl-only recipes disabled on full, every video recipe renders) and §10f2 (a sample of media recipes with both players ready). |
128 |
| WP-131 | done (branch m5/wp-131-hls-extras; ADR 0012 “HLS extras”, URL-DIALECTS §3.10, FORMATS §5.2) |
HLS/DASH extras: MPEG-TS, overlays on streams, chunked audio | The three “not done” items of WP-126. MPEG-TS segments for HLS players older than version 7: imgproxy/query segment_format:ts (sgf), Cloudinary fl_hlsv3 on .m3u8 (ImageKit has no equivalent); HLS only, H.264 + AAC only (HLS has no AV1/VP9 in TS), so gpl only (full 422 before fetching); version 3 playlists, .ts segments muxed on their own with the audio of their span copied from the stream’s AAC track (fmp4.File.Samples, shim copy_*), PAT/PMT and an IDR first, the stream’s 90 kHz timeline, the discontinuity indicator on each PID’s first packet (counters restart per segment). Overlays on streams (image/text watermarks, timecodes; Cloudinary l_, ImageKit l-image/l-text no longer dropped): every segment of every rendition, on the stream’s timeline, laid out on the largest rendition and scaled to the others (exec.RenderOverlayScaled, scaled timecode); overlay images fetched by segment requests, sources checked at the manifest. Chunked audio (XI_VIDEO_TRANSFORM_AUDIO_CHUNK, default 30 s): chunks of whole segments, each its own job and cost, cached per segment, next chunk prefetched; one packet grid, 16-packet pre-roll, resampler phase kept, so chunks join as one encode; sources with sample-exact audio timestamps only (not Matroska/WebM) and not with loudnorm. A stream’s audio now runs on a continuous sample clock (a 24-sample jump of coarse timestamps removed), and the last segment always gets audio. Policy matrix: TS and stream overlays come only from presets under the restricted unsigned policy. |
Unit/table tests (planner, manifests, fmp4 samples + fuzz, server parts, media, dialect goldens, config, policy matrix, numeric graph invariants); cgo tests in the gpl builder with ffprobe/ffmpeg on the joined output: TS (165 frames on the grid, keyframe per segment, gapless audio in sync, continuity counters only jump at discontinuity indicators, clean decode), overlays (window 1.5–4.5 s across segment boundaries on both renditions), audio chunks against the single encode (identical fragment boundaries and sample count, 37.4 dB apart, joins rms 12.6 vs 31.0 elsewhere, no click); app end-to-end tests; acceptance and the headless playground check (TS and overlay stream recipes) on a gpl release image. | 126 |
| WP-132 | done (branch m5/wp-132-video-presets; ADR 0012 “Video presets”) |
Video presets and the restricted unsigned video policy | Video presets (XI_VIDEO_TRANSFORM_PRESETS, YAML video.transform.presets): named /video option sets in imgproxy’s preset syntax, separate from the image XI_PRESETS; imgproxy /video pr: (expanded in place, nested, default first; XI_PRESETS_ONLY admits preset-only /video URLs), /q/video preset=, Cloudinary t_ and ImageKit n- on videos (the presets are the op’s starting point, the URL’s parameters override them; a preset’s format wins over the extension, f_/f-/ik-video.mp4 over it; a stream preset fills ik-master). XI_VIDEO_TRANSFORM_UNSIGNED_POLICY=restricted: an unsigned video URL must name a video preset and equal what its presets alone make of it (each dialect parses the URL a second time keeping only the preset references, dialect.Result.PresetVideo; the router compares the whole VideoOp, so new fields fail closed), except XI_VIDEO_TRANSFORM_UNSIGNED_ALLOWED_OPTIONS (start, end, duration, mute; bounded by _UNSIGNED_MAX_DURATION) and a container of _UNSIGNED_FORMATS (default mp4, webm) of the same kind; streams and raw:1 only from presets; delivery options as the image restricted policy; 403 (404 with XI_COMPAT_IMGPROXY_STATUS) before any fetch; a deny mount still denies. Startup: syntax in Config.Validate, options and the output limits (pixels, fps, duration) when the dialects are built, naming the variable and the preset. Playground: preset names (not contents) in video_transform.presets, the preset option in the /video builder, two demo presets and two recipes (video_presets need). |
Policy matrix (TestVideoPolicyMatrix: signed/unsigned × allow/restricted/deny × mount policy × preset/none × file/stream/raw, four dialects), free options and duration bound, preset expansion per dialect, FuzzVideoPresets, app end to end with FFmpeg (TestVideoPresetsRestricted: transcode, HLS parts, raw, query, signed, 403/404), startup validation; docker lint/test on the gpl builder. |
122, 125, 126 |
| WP-133 | done (branch m5/wp-133-ts-client) |
TypeScript client audit and catch-up (clients/typescript) |
Owner request: the TypeScript client should cover everything added since it was created (WP-84). Gap audit against the server (coverage table in the package README). Delivered: streamUrl() for HLS/DASH (st, sgd, @m3u8/@mpd; StreamProtocol and VideoContainer derived from the generated catalogue); queryUrls(): /q/?url=… with native keys typed from OpenAPI getQueryImage and imgproxy options typed in o=, /q/video with every video option as a key (crop/flip written c/fl, their query spellings being native), imaginary endpoints typed from getImaginaryImage, xi_sig signing, pdp=; xiSource()/sourceUrl(); client image(), video(), media() (Range, HEAD), stream() (manifest; ticket child URLs need no signing), playgroundMeta(). Go: OpenAPI query keys gain vts, vtk, pages, pdf_password, pdp; TestQueryKeysMatchParser and TestTypeScriptCatalogue (catalogues vs the committed TS tables); golden cases for gain_map and the /video bypass_cache and mp4/opus/ogg/flac/wav/webp containers; the playground’s /q/video wrote crop=/flip= (400 on the native keys) and now writes c=/fl=. Additive: no export changed. |
The Go↔TS builder fixture covers every option of the three catalogues except pdf_password (own vectors), plus query_url for 495 cases that the Go query parser turns into the same canonical request as their imgproxy URL; bun test, tsc (type tests in test-d/), generate:check; task docker:lint and task docker:test on the gpl builder; a smoke run of the client against the gpl demo (images, /info, /q/, imaginary, /video WebM/MP4/MP3/waveform/raw, HLS/DASH and /q/video HLS, Range 206, HEAD, a ticket child, api/meta) answers 200/206. |
84, 126 |
M6: official clients
Section titled “M6: official clients”Goal (owner decision 2026-10-15): official URL builders and clients for the main backend languages, next to the TypeScript package: Python, Go, PHP, Ruby, Rust, Java, .NET and Elixir. Signing stays on servers (no mobile SDKs). No Bazel: mise and Taskfile with each language’s native tooling, path-filtered CI. Design: ADR 0013; how a language plugs in: clients/README.md.
| ID | Status | Package | Scope | Done when | Depends |
|---|---|---|---|---|---|
| WP-134 | done | Clients foundation + Python reference client | ADR 0013 (layout clients/<lang>/, TS stays in clients/typescript, the Go client as its own module, toolchains, versioning, registries, CI, why no Bazel); clients/spec/spec.json exported by internal/apidoc from the catalogues (options with typed forms, query keys, imaginary, containers, streams, sources, signing/encryption/URL rules, error kinds, response schemas); clients/spec/conformance.json (150 cases incl. negative ones with language-neutral error codes); the Bun generator clients/codegen with per-language emitters; task clients:spec, clients:generate, clients:fixtures, clients:check, clients:test, clients:python:test; clients/python (xtandard-image, Python ≥ 3.10, uv + hatchling, generated TypedDict/Literal option types, sync builders for imgproxy//info//video/streams/query/imaginary, signing, cryptography encryption, PDF passwords, Client and AsyncClient on httpx with Range/HEAD, typed InfoResponse, exceptions by status); workflows clients.yml, client-python.yml. |
TestClientSpec, TestClientSpecCoversCatalogues and TestConformanceFixture (every URL parsed by the server’s parsers, signatures verified, tokens decrypted) green; TS bun test writes and checks the conformance values; Python: 816 tests (conformance, the 400+ case builder fixture, shared vectors, client tests against a stand-in server) on 3.10 and 3.14, ruff, pyright and mypy strict, licence check, build; smoke test against the gpl demo; task clients:check clean; task docker:lint / docker:test green. |
133 |
| WP-135 | done | Go client (clients/go, own module github.com/xantiagoma/xtandard-image/clients/go) |
Builders, signing, encryption, net/http client; emitter clients/codegen/emitters/go.ts; the three fixtures; golangci-lint; client-go.yml. Follows clients/README.md. Delivered: go 1.26.0 (oldest supported Go), standard library only; root package xtandard (signing, encryption, sources, ranges, the option model, constants, InfoResponse, BuildError with ErrorCode sentinels for errors.Is); imgproxy and query builders (params structs and fluent chains with generated per-option methods); generated typed option structs per form in processing/info/video; client on net/http (context.Context first, injectable *http.Client, Range/HEAD, streamed bodies, *HTTPError unwrapping to xtandard.ErrorKind, *TransportError); Example* tests; tags clients/go/vX.Y.Z. |
Conformance 147 passed, 3 skipped (not expressible: chained /info and /video, /video PDF password); builder fixture 606/606 (given, short and query URLs); vectors 15/15; go test -race on 1.26.9 and 1.27.1; golangci-lint 2.14.0 0 issues, go vet, go fix -diff, go mod tidy -diff clean; licence check (no third-party modules); smoke test against the playground demo; task clients:check clean; README coverage table. |
134 |
| WP-136 | done | PHP client (clients/php, Packagist xtandard/image) |
PHP ≥ 8.2 (strict types, readonly classes, enums), Composer, PSR-4 Xtandard\Image\ over src/ and generated/; emitter clients/codegen/emitters/php.ts (PHPStan/Psalm option and response array shapes, name tables, per-option method traits, constants, backed enums); ImgproxyUrlBuilder/QueryUrlBuilder with options arrays, Pipelines and fluent chains, SigningKey (hash_hmac), EncryptionKey (openssl_encrypt), helpers and function forms; Client on any PSR-18 client + PSR-17 factory (no Guzzle dependency) with Range/HEAD; UrlBuildException (ErrorCode) and HttpException subclasses by status; README with Laravel and WordPress snippets; client-php.yml (PHP 8.2 to 8.5). |
PHPUnit: conformance (150 cases, 1 skipped: videoUrl() has no pdfPassword), the 400+ case builder fixture, the shared vectors, unit tests and client tests against a stand-in server (PHP’s built-in server through Guzzle), 812 tests on PHP 8.5 and 8.2; PHPStan level max with strict rules, plus a static check that PHPStan rejects 13 bad usages of the generated types; php-cs-fixer PER-CS 2.0; licence check (3 MIT PSR packages); composer validate --strict and archive; smoke test against the demo; task clients:check clean. |
134 |
| WP-137 | done | Ruby client (clients/ruby, gem xtandard-image) |
clients/ruby (Ruby >= 3.2, no runtime dependencies: openssl, net/http, json); emitter clients/codegen/emitters/ruby.ts (name tables, per-option snake_case methods, constants in lib/xtandard_image/generated/; RBS option records, per-shape method overloads, constants and response records in sig/generated/); Hash and fluent builders for imgproxy//info//video/streams/query/imaginary, signing, encryption, PDF passwords; XtandardImage::Client on Net::HTTP with Range, HEAD and block streaming; URLBuildError with code symbols and HTTPError subclasses by status; README with a Rails helper and the community imgproxy gem note; client-ruby.yml. |
808 Minitest runs (150 conformance cases, 606 builder-fixture cases, every shared vector, unit, type-usage and stand-in-server client tests) on Ruby 4.0, 3.4 and 3.2; RuboCop clean; Steep strict clean on lib/ and test/typecheck/; licence check; gem build --strict; smoke test against the demo; task clients:check clean. |
134 |
| WP-138 | done | Rust client (clients/rust, crate xtandard-image) |
Stable Rust, edition 2024, MSRV 1.89, #![forbid(unsafe_code)]; emitter clients/codegen/emitters/rust.ts (per catalogue an OptionName enum, name/short tables and one enum per enum argument; the per-option fluent methods as macros; constants and format/container/protocol/segment/scheme enums; serde response types); a no-I/O core (RustCrypto hmac, sha2, aes, cbc, base64, getrandom, thiserror) with ImgproxyUrlBuilder (processing, /info, /video, streams) and QueryUrlBuilder (/q/, /q/video, imaginary) in an Options object form and fluent chains, signing, encryption, PDF passwords, an Error enum with one variant per code; feature async (default, Client on reqwest) and blocking (blocking::Client), rustls with ring and the platform verifier, Range/HEAD, typed InfoResponse, HttpError classes; cargo deny; client-rust.yml. |
Conformance 147/150 (3 skipped: not expressible in the typed API), builder fixture 606 cases (fluent and object form) and 501 query URLs, vectors, unit, doc and stand-in HTTP tests of both clients, on 1.99 and the MSRV 1.89; clippy pedantic -D warnings for every feature set, rustfmt, rustdoc -D warnings, cargo deny and a publish dry run; smoke test against the demo; task clients:check clean. |
134 |
| WP-139 | done | Java client (clients/java, io.github.xantiagoma:xtandard-image) |
Same surface; java.net.http.HttpClient (sync + CompletableFuture async); Java 17+. Maven (POM = what Central publishes; mise pins JDK 21, JDK 17 and Maven, no wrapper jar); JPMS module io.github.xantiagoma.xtandard.image, zero runtime dependencies (javax.crypto, java.net.http, an internal RFC 8259 parser decoding /info into generated records), JSpecify @NullMarked (compile-only, requires static transitive; Kotlin sees Long?). Emitter clients/codegen/emitters/java.ts writes src/generated/java (a second source root): ProcessingMethods/InfoMethods/VideoMethods generic interfaces with one default method per option name, alias and argument shape (shared by ProcessingOptions/InfoOptions/VideoOptions and the fluent chains), name tables, QueryParams/ImaginaryParams, enums (formats, containers, protocols, segment formats, encodings, schemes, endpoints), Constants, response records. Hand-written: ImgproxyUrlBuilder/QueryUrlBuilder (immutable, Builder), UrlParams, StreamParams, Signing, Encryption, Sources, sealed ByteRange, XtandardClient + XtandardAsyncClient, sealed XtandardException (UrlBuildException with ErrorCode, HttpException with nested status subclasses, TransportException). Spotless/google-java-format, Error Prone + NullAway (JSpecify mode) with warnings as errors, JUnit 6, javadoc with doclint; release profile for the Central Portal (not run). Deviations: chained /info and /video pipelines are not expressible (2 conformance cases skipped); infoUrl refuses an extension (invalid_request). |
Conformance 148/150 (2 skipped, not expressible), builder fixture 606/606 (url, url_short, query_url), vectors 15/15, unit and stand-in HTTP tests, all on JDK 21 and 17 (and 25 locally); smoke test against the playground demo 4/4; licence check (no runtime dependencies; JSpecify Apache-2.0); client-java.yml (17, 21, 25). |
134 |
| WP-140 | done | .NET client (clients/dotnet, NuGet Xtandard.Image) |
Xtandard.Image (net8.0; net10.0, no package dependencies, IsAotCompatible): emitter clients/codegen/emitters/dotnet.ts generates the name tables, typed option records (ProcessingOption.Resize, …; one constructor per argument form), the fluent per-option methods for every name and alias (CRTP base classes), constants, StreamProtocol/SegmentFormat, and the /info response types with a source-generated XtandardJsonContext; ImgproxyUrlBuilder (Url, ChainedUrl, InfoUrl, VideoUrl, StreamUrl, Image/Info/Video chains), QueryUrlBuilder (/q/, /q/video, imaginary, xi_sig), Signing (HMAC-SHA256), Encryption (AES-CBC), Sources, ByteRange; XtandardClient on HttpClient (async, CancellationToken, Range/HEAD/streaming, typed exceptions by status, URLs sent uncanonicalized) with an IHttpClientFactory constructor; Xtandard.Image.Extensions (AddXtandardImage(), typed client + builder singletons; Microsoft.Extensions.Http 8.0.1, MIT) kept separate so the core stays dependency-free; xUnit v3 on MTP, locked restores, dotnet format, licence check (file-based app), dotnet pack; workflow client-dotnet.yml. |
task ci green: format, Release build with latest-recommended analyzers, nullable, trimming/AOT analyzers and warnings as errors; 1642 tests (821 per runtime on net8.0 and net10.0): conformance 150 cases (147 pass, 3 skipped as not expressible: /video PDF password, chained /info and /video pipelines), the builder fixture (every case through url, url_short, query_url), shared vectors, unit, stand-in HTTP and DI tests; licence check (no exceptions); both packages packed; smoke tests green against the demo (XI_SMOKE_URL=http://127.0.0.1:8080); a native-AOT sample publishes without warnings; task clients:check clean. |
134 |
| WP-141 | done | Elixir client (clients/elixir, Hex xtandard_image) |
Same surface; Req/Finch; Dialyzer, Credo. Delivered: emitter clients/codegen/emitters/elixir.ts (catalogue tables; XtandardImage.Processing/Info/Video with one typespec and one pipe-friendly function per option name and alias; XtandardImage.Spec constants and types; response structs with from_map/1); builders for imgproxy//info//video/streams/query/imaginary (keyword-list options form and fluent XtandardImage.Chain), {:ok, _} | {:error, %XtandardImage.Error{code: _}} plus bang variants, :crypto signing and AES-CBC, PDF passwords; XtandardImage.Client on Req (optional dependency; URL building needs nothing beyond OTP) with Range/HEAD/into: streaming, HTTPError by status and TransportError; Phoenix component snippet; client-elixir.yml (Elixir 1.17/OTP 27 and 1.20/OTP 29). |
Conformance (150 cases, both forms), builder fixture (400+ cases, url/url_short/query_url) and vectors green with no skips; 852 ExUnit tests incl. 32 doctests and client tests against a stand-in server on 1.17/OTP 27 and 1.20/OTP 29; mix format, Credo strict, Dialyzer clean; licence check; ExDoc and mix hex.build; smoke test against the demo; task clients:check clean. |
134 |
| WP-142 | done | Swift client (clients/swift, SwiftPM product XtandardImage from the repository root Package.swift) |
Swift 6 language mode (strict concurrency, Sendable values), tools 6.2; iOS 15+, macOS 12+, tvOS 15+, watchOS 8+, visionOS 1+ and Linux. No mirror repository: the root Package.swift declares only the Swift targets (path: "clients/swift/…"), versions are the repository’s vX.Y.Z tags. Emitter clients/codegen/emitters/swift.ts (ProcessingOption/InfoOption/VideoOption enums with short names, aliases, name tables and one nested enum per enum argument; the fluent per-option methods for every name and alias as protocol extensions; value enums with init(parsing:), ErrorKind, SpecConstants; Codable response types). Hand-written: ImgproxyURLBuilder (processing, /info, /video, streams) and QueryURLBuilder (/q/, /q/video, imaginary, xi_sig) with an Options dictionary-literal form and value-type fluent chains, SigningKey, EncryptionKey, PDF passwords, sources, ByteRange, typed throws (URLBuildError with the shared codes); XtandardClient on URLSession async/await (FoundationNetworking on Linux) with Range/HEAD, AsyncBytes streaming on Apple platforms, ClientError/HTTPError by status. Crypto: CryptoKit HMAC + CommonCrypto AES-CBC on Apple (no dependency); swift-crypto Crypto + _CryptoExtras AES._CBC (3.1.0..<6.0.0) elsewhere. DocC catalog with a Keys in apps article; client-swift.yml (macOS + Linux 6.2/6.3). |
task ci on macOS (swift-format strict, build with warnings as errors, 35 Swift Testing tests, licence check, DocC with warnings as errors) and task linux in swift:6.3.3 (34 tests; AsyncBytes is Apple-only): conformance 147/150 (3 skipped, not expressible: chained /info and /video, /video PDF password), builder fixture 606 cases (fluent and mapping form) and 501 query URLs, vectors, unit and stand-in HTTP tests; smoke test against the demo on both; a scratch package outside the repository consumes it with .package(path:) (macOS, and Linux with swift-crypto pinned at 3.1.0); licence check (one documented exception: CC0-1.0 XKCP vendored in swift-crypto, non-Apple only); task clients:check clean. |
134 |
| WP-143 | done | Kotlin Multiplatform client (clients/kotlin, Maven Central io.github.xantiagoma:xtandard-image-kmp) |
Owner decision 2026-10-22: Kotlin gets its own client because the Java client’s HTTP part uses java.net.http (absent on Android) and Kotlin/Native cannot use Java. Kotlin 2.4, Gradle 9.8 (pinned by mise, no wrapper jar), targets jvm (servers, desktop and Android apps through the JVM variant; Java 11 bytecode), iosArm64, iosSimulatorArm64, macosArm64; package io.github.xantiagoma.xtandard.image.kmp, explicitApi(), warnings as errors, ktlint. Emitter clients/codegen/emitters/kotlin.ts: per-catalogue generic method interfaces (one overload per argument shape and name/alias, Number numerics, merged JVM signatures), name tables, QueryParams/ImaginaryParams, constants, enums (formats, containers, protocols, segment formats, encodings, schemes, imaginary endpoints, error kinds), @Serializable response data classes. Runtime in common code: ImgproxyUrlBuilder/QueryUrlBuilder (named arguments, processingOptions {} DSLs, fluent chains and img.image(src) { … } blocks), Signing, Encryption, Sources, ByteRange, ECMAScript number formatting on an exact big-integer expansion; HMAC-SHA256, AES-CBC and secure random from the platform (javax.crypto; CommonCrypto and SecRandomCopyBytes) behind expect fun; XtandardClient on Ktor (suspend, OkHttp on the JVM, Darwin on Apple, Range/HEAD, mediaStream over ByteReadChannel, HttpException subclasses by status, TransportException), kotlinx.serialization for /info. Signing and encryption are optional in apps; the README documents that embedded keys are extractable and recommends the restricted unsigned policy or backend-signed URLs. Gradle checkLicenses over every target’s runtime closure; Dokka; vanniktech maven-publish (Central Portal, no publish); client-kotlin.yml (JVM 11/17/21/25 on Linux, Apple targets on macOS). |
task ci green on macOS: ktlint, assemble of all four targets, allTests on the JVM, macOS and the iOS simulator (conformance 150 cases: 147 pass, 3 skipped as not expressible: /video PDF password, chained /info and /video pipelines; the builder fixture’s 606 cases through url, url_short and query_url; 15 shared vectors; unit, README and MockEngine stand-in HTTP tests on every target; OkHttp against the JDK HttpServer on the JVM), JVM tests also on JDK 11 and 17, licence check (104 runtime modules, no exceptions), Dokka, publish dry run into build/repo; smoke test green against the demo (XI_SMOKE_URL=http://127.0.0.1:8080); task clients:check clean. No androidTarget (no Android SDK on the build machines; Android consumes the JVM variant), no JS/Wasm (WebCrypto is async; TypeScript has its own client). |
134 |
| WP-144 | done | Dart client (clients/dart, pub.dev xtandard_image) |
Owner decision 2026-10-22: Dart covers Flutter apps (iOS, Android, web, desktop) and Dart backends (shelf, dart_frog, serverpod); pure Dart, no Flutter dependency; published from the subdirectory (no mirror). Delivered: Dart 3.5+ (Flutter 3.24+); emitter clients/codegen/emitters/dart.ts (name tables; sealed OptionValue with ProcessingOption/InfoOption/VideoOption, one named constructor per option; ProcessingMethods/InfoMethods/VideoMethods mixins with one typed method per name and alias, named records for repeated groups, merged signatures for multi-form options; StreamProtocol/SegmentFormat/SourceEncoding/SourceScheme/ErrorKind enums and constants; immutable response classes with hand-rolled fromJson, no build_runner); ImgproxyUrlBuilder and QueryUrlBuilder (map and fluent forms, streams, imaginary, xi_sig), HMAC-SHA256 on package:crypto, AES-256-CBC on pointycastle (MIT, pure Dart and synchronous on every platform), XtandardClient on any package:http client (Range, HEAD, streamed bodies, abortTrigger, timeout, typed exceptions by status, TransportException); README on keys in apps (extractable: prefer the restricted unsigned policy or backend-signed URLs) and a Flutter Image.network width helper; client-dart.yml (3.13 full, 3.5 tests). |
task ci green: dart format, dart analyze --fatal-infos (lints recommended plus strict casts/inference/raw types and extra rules), 812 VM tests (803 passed, 9 skipped: 4 conformance cases not expressible — chained /info and /video pipelines, /video PDF password, unknown stream protocol — and the 5 smoke tests without XI_SMOKE_URL), the same fixtures in Chrome with dart2js and dart2wasm (36 tests, every case run through a hybrid isolate), and on Dart 3.5.4 (VM and Chrome); licence check of the 14-package runtime closure (no exceptions); dart doc 0 warnings; dart pub publish --dry-run 0 warnings; smoke tests green against the demo; task clients:check clean. |
134 |
| WP-145 | done | Playground code snippets for every official client | Owner request: the playground showed code only for TypeScript; generate it for every language, with syntax highlighting. Generator (internal/playground/assets/snippets/, plain ES modules, no dependency): the builder state becomes a request (request.js), then a language-neutral code model of typed nodes (model.js: client, request, keys, environment reads, options, print), which each language’s refiner (lang/<id>.js, Kiota’s CodeDOM → refiner → writer) lowers into its client’s idiom (Go key parsing and error checks, Rust ?, Swift try, Java builders, C# config initializers, Kotlin DSL blocks, Elixir module functions, keyword arguments, option mappings or fluent chains) and its printer maps to a Doc (doc.js, Wadler/Prettier group/indent/line/ifBreak with a width-driven layout): leaf tokens (string escaping against each language’s interpolation, numbers as the spec writes them, identifier casing) are the only text a printer makes. curl, TypeScript, Python, Go, PHP, Ruby, Rust, Java, Kotlin, C#, Elixir, Swift, Dart; images (chained pipelines, extension, plain/base64/enc/ sources, PDF password), /info, /video, HLS/DASH streams, /q, /q/video, imaginary, signXiUrl for xi_sig mounts and a note for dialects without a client builder. Keys only from the environment. UI: a Code block under the URL and in the Info tab: language picker (remembered per browser, shared by both), copy, wrap, signed, encrypt source; highlighting by a small tokenizer (snippets/highlight.js, no vendored library: CSP self), token colours ≥ 4.5:1 in both themes. The asset handler serves subdirectories (assets/{version}/{name...}). |
scripts/playground-snippets-check.ts (task playground:snippets): 13 builder states × 12 languages, each snippet written into a scratch project depending on the local client and run with its toolchain (mise; local Swift), the printed URL equal to the TypeScript client’s: 156/156 pass; the page’s own URL equal or equivalent to the reference. bun test scripts/playground-snippets*.test.ts (Doc layout, escaping per language, numbers, collections, breaking, casing; every language × case renders without key material and highlights losslessly; the TypeScript snippets run). playground-check.ts §12 on xtandard-image:wp145-gpl with the demo environment, §8 and §10 updated. |
80, 134–144 |
| WP-146 | done | Client code generator on a code model (no template strings) | Owner requirement (2026-10-23): no generated source built by concatenating strings. clients/codegen is now spec → neutral model (build.ts: catalogues, names, short names, aliases, shapes, enums, constants, schemas) → per-language refiner (emitters/<lang>.ts) building typed nodes (model.ts: files, comments, imports, namespaces, type aliases, constants, enums, class-like declarations, fields, functions, annotations, statements, expressions, type references, plus <lang>.<kind> nodes) → printer (printer.ts base, lang/<lang>.ts with the leaf tokens: escaping against each language’s interpolation, numbers as JavaScript writes them, casing, reserved words) on the playground’s Wadler/Leijen Doc algebra (doc.ts re-exports internal/playground/assets/snippets/doc.js, one module; go:embed keeps it there) → optional official formatter (Go keeps gofmt). All 11 emitters and the TypeScript client’s options.ts migrated; template literals remain only for identifiers, escaped literals, comment prose, paths and error messages. ADR 0013 §4 amended; clients/README.md “Adding a language” rewritten. |
Every committed generated file byte-identical (generate --check, bun run generate:check); bun test clients/codegen/test (Doc module identity, layout helpers, model, printer base, leaf tokens and narrow-width layout per language, one golden per language for a small spec); codegen tsc; task clients:test (all 12 clients’ task ci); task playground:test; Go TestClientSpec*. |
134–145 |
| WP-147 | done | HTTP QUERY method (RFC 10008) | Owner request: requests whose options do not fit in a URL (long chains, watermark text, encrypted sources, video overlays) send them in the body. Server (ADR 0014, URL-DIALECTS §4.4): QUERY on the query dialect’s endpoints (/q/, /q/video, imaginary /q/<endpoint>, every mount of the dialect); the ingress turns it into the equivalent GET (same parser, xi_sig/sign message, unsigned policy, presets, allowlists, SSRF, limits, ETag/304, Cache-Control, result cache key). Content application/x-www-form-urlencoded (the GET query verbatim) or application/json (typed members in order → canonical query escaped like the clients; QueryContent schema); xi_sig in the content, method not signed, so signed QUERY content and signed GET URLs are interchangeable. 415 + Accept-Query (missing/unsupported type, charset, Content-Encoding), 413 (XI_SERVER_QUERY_MAX_BODY_BYTES, 64 KiB), 400 (malformed content, URL query), 405 elsewhere or with XI_SERVER_QUERY_METHOD=false (exactly as before). Accept-Query on GET/HEAD/405 of those endpoints, Vary: Content-Type, Location = equivalent GET URL on 2xx (≤ XI_SERVER_QUERY_LOCATION_MAX_LENGTH, no pdf_password; never minted), no Content-Location. CORS preflight for QUERY (Content-Type allowed), Location/Accept-Query exposed. OpenAPI 3.1 x-xi-query-method + QueryContent; spec.json query.method and the query_method signing rule. Clients (off by default): queryMethodRequest (split a built URL) in all 12; HTTP opt-in (queryMethod/query_method + mount, or an explicit QUERY method) in all 12 HTTP layers; query.query_request conformance op (9 appended cases). Playground: “send as QUERY” on query mounts (shows method, headers, content); QUERY snippets for curl (-X QUERY --data-binary), TypeScript, Python, Go, the other languages keep the GET snippet with a note. |
Go: TestQueryMethod* (media types, 415/400/413/405, signatures under allow/restricted/deny, GET↔QUERY cache sharing both ways on the pinned GET key, 304, CORS, headers, path prefix, pdf_password), TestResultKeysPinned (GET keys computed on origin/main), TestContentQuery* + FuzzContentQuery, TestMountFor, conformance check through the server’s content parser (form and JSON give the same signed query), app e2e TestQueryMethodEndToEnd/Disabled (a 9 KB query: GET 414, QUERY 200); task docker:lint, task docker:test (gpl builder); acceptance 155/155 on xtandard-image:wp147-gpl incl. TestQueryMethod; task clients:check, every client’s task ci; task playground:test, task playground:snippets (Python, Go); playground-check.ts §13 (7/7). |
23, 134–146 |
M7: deployment and operations
Section titled “M7: deployment and operations”Owner request (2026-10-23): guides and tested artefacts for running the server on every common platform, with autoscaling by demand (minimum and maximum replicas, signals, limits), a CDN in front, and observability. Docker images stay the distribution. Each artefact is checked by a tool (schema validation, helm lint, kubeconform, promtool, terraform validate, type checks, a local smoke run), and anything that cannot run without a cloud account is marked as not verified on that platform.
| WP | Status | Title | Scope | Acceptance | Depends on |
|---|---|---|---|---|---|
| WP-148 | done | Operations: sizing, autoscaling, CDN, observability, Kubernetes | docs/OPERATIONS.md; deploy/kubernetes (Kustomize), deploy/helm, KEDA and HPA, deploy/observability (Grafana dashboard, Prometheus alert and SLO rules, OTel Collector, local stack) |
task deploy:check (pinned containers) passes: generated copies current, every metric and XI_* name exists in the code or CONFIG.md, promtool check/test rules (30 rules, 5 test groups), kustomize build of the 4 overlays and helm lint --strict + helm template of 6 value sets through kubeconform -strict with the KEDA and prometheus-operator CRD schemas, 5 bad-value refusals, docker compose config. Local stack run with main-gpl: Prometheus scrapes, rules load, Grafana provisions the dashboard with data, traces reach Tempo. kind + KEDA 2.20.2: 1→5 replicas within 21 s of a burst (ready at 32 s), back to 1 within 83 s of the queue draining (test windows). go test ./internal/config/... green in the builder. Found: XI_PROCESSING_GO_MEMORY_FRACTION and XI_SERVER_READY_QUEUE_THRESHOLD are validated but not applied (OPERATIONS §1.3, §3.5) |
— |
| WP-149 | done | Platforms and infrastructure as code | docs/DEPLOY.md (requirements, scaling signals, image/video fleets and their routing table, a platform matrix with how each scales, min/max knobs, scale to zero, video spool disk, admin reachability and verification level, one section per target); deploy/: docker run, Compose (single; image/video split behind nginx routing /video/, /q/video, /_stream/), Swarm stack (replicas, update/rollback; no autoscaler), Nomad job (Nomad Autoscaler policy), Dokploy (Stack), Coolify, Openship (compose only; platform undocumented), Fly.io (image and video apps), Cloud Run (image and video services), ECS/Fargate CLI inputs with target tracking, App Runner (closed to new customers), Azure Container Apps (KEDA http + cpu), Railway, Render, DigitalOcean (ingress routing to the video component); Terraform/OpenTofu modules (Cloud Run, ECS Fargate + ALB + autoscaling + optional CloudFront, Azure Container Apps, Kubernetes helm_release of the WP-148 chart; no maintained Fly provider), Pulumi (TypeScript, Bun: ECS Fargate, Cloud Run, Helm), SST 4 (sst.aws.Service on a Cluster), AWS CDK (ApplicationLoadBalancedFargateService). Checks in deploy/check/, tasks deploy:platforms:check and deploy:platforms:run |
task deploy:platforms:check: docker compose config / docker stack config on every compose and stack file, nginx -t, check-jsonschema (Fly SchemaStore, Railway, Render), the DigitalOcean app_spec and Azure ContainerApp OpenAPI schemas with unknown keys rejected, botocore models for the ECS and App Runner inputs, kubeconform (Knative v1) for Cloud Run, nomad job validate, xtandard-image config validate of every container’s environment in xtandard-image:main-gpl, OpenTofu 1.13.2 and Terraform 1.17.0 fmt/init -backend=false/validate + tflint on the four modules, tsc --noEmit for Pulumi/SST/CDK, sst install and cdk synth without credentials, Node licence check, shellcheck. task deploy:platforms:run on xtandard-image:main-gpl: compose single and split (2 image + 1 video replicas) healthy, signed image 200, unsigned 403, admin port unpublished, a signed /video/ transcode 200 from the video fleet, /q/video and /_stream/ routed to it, each fleet refusing the other’s requests; a throwaway single-node Swarm with 2/2 replicas and the update/rollback settings, then removed. Nothing deployed to a cloud: every managed platform is “not verified on the platform” in the matrix |
148 (Helm chart) |
| WP-150 | done (branch m7/wp-150-config-apply) |
Apply validated-but-unused settings; audit every Config field |
XI_PROCESSING_GO_MEMORY_FRACTION: app.Run sets debug.SetMemoryLimit to the fraction of the cgroup memory limit (v2 memory.max, v1 memory.limit_in_bytes, smallest from the process’s cgroup up; GOMEMLIMIT set by the operator wins, via config.Meta; warns when it plus XI_PROCESSING_MEMORY_BUDGET exceeds the limit). XI_SERVER_READY_QUEUE_THRESHOLD: /readyz 503 busy over the image queue fill (limiter.QueueFillAtLeast, lock-free; video not counted; OpenAPI and TS types updated). XI_SERVER_MAX_CONNECTIONS: server.LimitListener (x/sync/semaphore) on the public listener only. The type-checked audit of every Config leaf found three more without a consumer, now wired: XI_SECURITY_SECRET (Bearer check, 403), XI_SECURITY_ALLOWED_PROCESSING_OPTIONS (imgproxy dialect, 403) and XI_FETCH_MAX_CONCURRENT (download slots, 503 past the attempt timeout); XI_PROCESSING_LINEAR_RESIZE is a backlog gap (CONFIG.md says so). Guard TestEveryFieldHasAConsumer (by field name, reasoned allowlist). Chart goMemLimit defaults to "", the Kustomize base, role-video and the local stack set no GOMEMLIMIT |
Unit tests (synctest: limiter fill, listener slots and unblocking, fetch slots; cgroup v1/v2/nested/unlimited parsing; HTTP/1.1 and h2c through the listener with graceful Shutdown). task docker:lint, task docker:test (slim and gpl builders), task deploy:check. Release image xtandard-image:wp150-gpl: --memory 1g logs go memory limit set 322122547 B and go_gc_gomemlimit_bytes 3.22e8 (GOMEMLIMIT=200MiB kept, no limit unchanged); readyz with 1 worker, queue 3, threshold 50: 115 of 400 probes busy during an 8-request burst, ready before and after; XI_SERVER_MAX_CONNECTIONS=4 with 4 idle clients: a 5th waits (not refused), admin answers, served 0.3 s after one closes, 20 concurrent clients all 200; secret and allowlist 403/200 by hand. scripts/acceptance.sh: 155/155 |
148 |
| WP-151 | done | Modal (modal.com) as a deployment target | deploy/modal/app.py (modal client 1.6.2, 1.x API): one App, ImageFleet (slim) and VideoFleet (full) classes, each a @modal.web_server(8080) endpoint with its own pool. Image.from_registry of the private GHCR image (registry Secret) with add_python="3.13" and the entrypoint cleared; @modal.enter starts xtandard-image serve as UID 65532 without MODAL_* variables, @modal.exit sends SIGTERM and waits within Modal’s 30 s grace. @modal.concurrent (images target 8 / max 32, under workers + queue; video 4 / 16), min_containers/max_containers/buffer_containers/scaledown_window, cpu/memory as (request, limit) with GOMAXPROCS and GOMEMLIMIT set explicitly, ephemeral_disk for the video spool and disk cache, secrets via modal.Secret.from_name(required_keys=…). docs/DEPLOY.md “Modal” (how the container runs, scaling, cold start, 150 s request cap, 200 req/s workspace limit, shutdown, secrets, Volumes vs cache, OTel push, routing and custom domains, fit and price; Modal docs cited), matrix row, OPERATIONS §3.7 row. validate.py modal and env modal; deploy/check/modal-run.sh with modal_harness.py |
task deploy:platforms:check: the app loads with modal 1.6.2 with deprecation warnings as errors (an old container_idle_timeout fails it), one web endpoint per env dict, config validate of both fleets’ environments in xtandard-image:main-gpl. deploy/check/modal-run.sh (in task deploy:platforms:run): a Modal-like image from main-gpl (root, standalone CPython 3.13 from python-build-standalone, modal 1.6.2, ENTRYPOINT []) runs the app’s start_server()/stop_server() for both fleets: /readyz 200, server UID 65532, no MODAL_* env, go memory limit from GOMEMLIMIT logged, exit 0 on SIGTERM. shellcheck 0.11, ruff. Nothing deployed: not verified on the platform |
149 |
M8: open source
Section titled “M8: open source”Owner decision (2026-10-25): prepare the repository to become public. It stays private until the owner flips it. ZeroVer is adopted as the versioning policy.
| WP | Status | Title | Scope | Acceptance | Depends on |
|---|---|---|---|---|---|
| WP-152 | done (branch worktree-agent-ad5f06a68078ab9b2) |
Open-source readiness | ZeroVer: RELEASE.md “Versioning” documents one product version 0.MINOR.PATCH[-rc.N] from the git tag, shared by the images and every client; what a minor or a patch may break; how tags map to GHCR tags (no X.Y tag exists) and npm dist-tags (next for prereleases). It replaces the “before 1.0” wording (README, ADR 0003 and ADR 0013 amendments). CHANGELOG.md: written by hand, with entries for rc.1 and rc.2. Community files: CONTRIBUTING.md, SECURITY.md (private vulnerability reporting; scope: SSRF, signing, decompression bombs, the Ghostscript sandbox), CODE_OF_CONDUCT.md (Contributor Covenant 2.1), SUPPORT.md, .github/ISSUE_TEMPLATE/ (bug report with flavour, version, dialect and URL; feature request; security link), the PR template, CODEOWNERS. README restructured for users: header, badges, a 30-second quick start (tested by scripts/quickstart.sh), features, flavours, usage, a clients table, deploy, a docs index, and status and versioning. The long sections moved to GETTING-STARTED, COMPARISON, PLAYGROUND and FORMATS “At a glance”. The intros of CONFIG, URL-DIALECTS and FORMATS no longer read as process notes. The TypeScript client’s README and npm description name it as the client of the server. Pre-public audit: gitleaks over the whole history and the tree, a grep for internal references, and an inventory of agent-internal files (report only; history is not rewritten). |
The doc links resolve; TestDocBlocks and TestSchemaURLsInDocs pass; the quick-start blocks run against the rc.2 image |
— |
| WP-153 | done (branch worktree-agent-aa4d7aafe26d95fc5) |
Brand: vector logo, favicons, social card | The owner’s AI-generated rasters (low resolution, halos, white wordmark invisible on light backgrounds) redrawn as SVG masters in docs/assets/: app icon logo.svg, symbol.svg, horizontal and stacked lockups for dark and light backgrounds, monochrome white/black, favicon.svg, social.svg; the wordmark is Urbanist Black (OFL) outlined to paths, so no font is needed (credited in NOTICE). Rasters (logo.png 1024/256/128, lockup PNGs at 2×, favicon.ico 16/32/48, apple-touch-icon.png, social.png 1280×640) built by scripts/brand-assets.sh (task brand:assets, task brand:check; rsvg-convert, ImageMagick, oxipng). BRAND.md: palette tokens in hex and OKLCH with contrast, gradients, wordmark font and licence, clear space, minimum sizes, do/don’t, which file for which use, fidelity notes; blue here, lime in the sibling projects. Playground: favicon set and header symbol served from the embedded FS (assets/<version>/brand/…, CSP 'self'), brand blues and ink as accent and dark tokens. Pages: favicon, header lockup (<picture>), og:image. README <picture> header and a header logo in every clients/*/README.md (absolute raw URLs; plain Markdown where the registry drops HTML) |
go test -race -shuffle=on ./internal/playground/..., go vet, golangci-lint fmt --diff and run on the package in the slim builder; headless Chrome screenshots of the playground header and the Pages index in light and dark; task pages:build; task brand:check; the icon and lockups compared with the originals side by side and by outline overlay |
152 |
Backlog for going public (owner actions or later work packages):
- Logo assets (WP-153, done):
docs/assets/*, the README<picture>header, the playground favicon (embedded, served by the playground itself under CSPdefault-src 'self'), the Pages favicon andog:image. Left for the owner:- the GitHub social preview: upload
docs/assets/social.png(1280×640) in Settings → General → Social preview, by hand, because there is no API for it.
- the GitHub social preview: upload
- Make the repository and the GHCR packages public. Check first:
- the audit findings of WP-152;
- the
xtandard-imageandxtandard-image-basepackage visibility, sincecompose.dev.yml, the Taskfile and the Dockerfile pull the base from GHCR; - private vulnerability reporting enabled in Settings → Code security, which SECURITY.md points to;
- the repository description, homepage and topics.
- Re-enable GitHub Actions (disabled since 2026-10-24, billing), then the workflows:
ci,nightly,base-image,pages(and Settings → Pages → GitHub Actions),ts(XI_TS_WORKFLOW) and the client workflows. Add the CI badge to the README. - Re-run the signed release: run
release.ymlforv0.1.0-rc.2(or tagv0.1.0), so that cosign signatures, SBOM attestations and SLSA provenance replace the unsigned local prereleases; confirm gpl on native amd64; then setXI_NPM_PUBLISH=enabledafter configuring npm trusted publishing for@xtandard/image.
Backlog after M4
Section titled “Backlog after M4”Optional, not scheduled. Each item waits for demand or an owner decision.
- Linear-light resizing (
XI_PROCESSING_LINEAR_RESIZE, imgproxyIMGPROXY_USE_LINEAR_COLORSPACE; found by the WP-150 audit). The setting is parsed, validated and translated from imgproxy, but no pipeline step reads it: resizing always happens in the image’s own colour space. Doing it means converting to scRGB (16-bit or float) around the resize inpipeline/planandexec(alpha premultiplied in linear light, the shrink-on-load path, chained pipelines), new goldens, and a benchmark of the cost. CONFIG.md marks the variable as not implemented;unconsumedininternal/config/consumers_test.golists it. - Standalone Linux tarballs (after v0.1.0, owner note 2026-10-23). Docker stays the primary
distribution. The release’s
.debugbinaries are not runnable alone, because they need the base’s shared libraries. A tarball per flavour (slim,full;gplonly with its source offer) and architecture (linux amd64/arm64) would be extracted from the release image: the stripped binary,/opt/xtandard/libwithrpath=$ORIGIN/../lib, the licences and the SBOM. That is the same tested set of files as the image, the approach sharp takes for its prebuilt libvips. The glibc baseline is the runtime image’s (Ubuntu noble). Then, only if asked:.deb/.rpmbuilt from the tarball with nfpm plus a systemd unit, and a Homebrew formula against brew’s libvips for local development, without the container’s guarantees. No Windows build and no static binary: about 40 shared C libraries, FFmpeg and glibc make one fragile. imgproxy OSS and imagor ship Docker only.
Open questions for the owner
Section titled “Open questions for the owner”H.264 for GIF→MP4 (WP-76).Resolved (2026-09-27): H.264 encoding (libx264) exists only in the opt-ingplimage flavor (WP-70b, ADR 0006 amendment);fullkeeps WebM/VP9 and AV1-in-MP4, and openh264 is not pursued. The encoder is detected at runtime (FORMATS 5.1).HEVC patents.Resolved (2026-09-27): HEVC video encoding (libx265) exists only in thegplflavor, whose deployer is responsible for the patent review (stated in the README and the image’sLICENSE-GPL.md). slim and full keep HEVC/HEIC decode, and HEIC encode (kvazaar) stays behindXI_HEIC_ENCODE(default off).- Invalid-URL status. imgproxy answers 404 for malformed or unknown options. We plan 400. Proposal: 400, with an
XI_DIALECTS_IMGPROXY_STRICT_COMPATswitch that restores 404. - Upload/POST processing (imaginary
POST /resize, multipart). Out of scope so far. Should it be in M2? - OpenStack Swift source. imgproxy supports it. Is it needed?
- Default for unsigned foreign dialects.
restrictedis safe but surprises users who expect arbitrary sizes. Should the default stayrestricted? - Object-detection model. Proposal: YOLOX-s (Apache-2.0) plus YuNet (MIT). The alternative is RT-DETR/D-FINE (Apache-2.0), which is more accurate but slower.
| WP-89 | done (#91) | Documentation status audit | README, ROADMAP, FORMATS, URL-DIALECTS, CONFIG, ARCHITECTURE, OPENAPI and RELEASE state what is implemented (on
mainvs in review), never what is planned as if it were done or the reverse;x-xi-dialectsmarks every mounted dialect as served. | Every M1/M2 row has a verified status; the docs consistency tests stay green. | — | Playground (WP-80).Resolved: inside the server binary, off by default (XI_PLAYGROUND_ENABLED=false), on the admin listener at/_playground/and on the public listener only withXI_PLAYGROUND_PUBLIC=true; plain HTML/CSS/JS embedded withgo:embed, no bundler, npm or CDN (air-gapped, CSPdefault-src 'self'). Signing happens server-side (CONFIG.md).Public schema/spec URL (WP-81b).Resolved: the GitHub Pages URL is the$id, and the binary also serves its embedded schema at adminGET /config.schema.json(andconfig schemaprints it), so a self-hosted instance can point editors at its own URL (ADR 0007 decision 6).TypeScript package (WP-84).Resolved: stay in this repo, with a root Bun workspace overpackages/*(the Grafana and terraform-cdk convention;clients/has no precedent). The first package is@xtandard/imageinclients/typescript, in the owner’s existing@xtandardscope with animage-prefix (ADR 0009).Secrets in config files (WP-81).Resolved: secret-typed values accept{file: /abs/path}, which works likeXI_…_FILEfor one value.${file:…}string interpolation was rejected because it collides with Compose interpolation (ADR 0007 decision 7).huma handlers (ADR 0008).Resolved: builder-only in WP-82. The WP-82b spike moves JSON endpoints to huma handlers only if huma’s own errors can emit ourerrsenvelope and status without huma globals and without regressing go-standards §2. Image endpoints and/infostay hand-written regardless (ADR 0008 decision 5). Spike outcome (WP-82b): rejected. The envelope and status were reachable only through an undocumented ordering inside huma,/healthzwent from 12 to 17 allocations and about +57% latency, huma can still write to stderr or panic on a failed write, and its generated error docs come from theNewErrorglobal. huma stays builder-only.