v0.70.0 — Image Manipulation
The platform can already remove a background, find a face, read a dominant colour, upscale, and outpaint. Each of those was built for one screen, wired into that screen, and left there. There is no such thing as "an image operation" in this codebase — there are five features that happen to take an image, and which one you can reach depends entirely on which modal you opened. The Token modal has nearly all of them because every capability was added to it first and to nothing else; the Library, the campaign card and the user avatar have none.
Spec: 17-image-manipulation — read § What is actually there today first, then § Where an image can be used for the ten-surface grid this project exists to fill in. Tasks are requirements.md, one commit each, in order.
Scope is Phases 0, 1 and 2 — repair, the variant model, and the operation registry. 20 tasks. Phases 3–7 are charted onto the roadmap above as v0.71.0, v0.72.0 and TBD rather than left unscheduled, because they are a dependency chain: the Retouch dialogue has nothing to show until an artwork can hold more than one processed image, and object removal has nowhere to put the boulder until the dialogue can offer a disposition.
Two of the eleven findings are why Phase 0 blocks the rest. The in-browser background
remover resolves its WASM from cdn.jsdelivr.net/…@latest and its weights from a
…/latest/ Google Storage path — unpinned, unsigned, third-party, on the critical path of
a feature the user has already clicked, and currently working, which is worse than broken
because nothing will tell us when it stops. And remove-background-openai is the only AI
path in the app that spends process.env.OPENAI_API_KEY rather than the user's own key;
putting a Retouch button on ten surfaces multiplies whatever that costs by ten.
The end of Phase 2 is a real place to stop: it is the point at which "upscale this image" and "remove this background" stop being properties of a screen and start being properties of an image. What it is not is a visible release — the only thing a user sees is the character sheet's dead end closing. That is the trade the phase order makes deliberately.
Decisions, settled 2026-08-26
§11 had eight open. Four are in scope for this release and are answered below; D1 and D8 are answered when v0.71.0 opens, D4, D5 and D7 when v0.72.0 does — none of them changes a task in phases 0–2, and D5 in particular is a production-cost decision that should not be made as a side effect of a feature release.
- D3 — browser-first, with the server offered on the result. MediaPipe runs on click; a labelled "refine on the server" upgrade sits beside the output. This is the decision the spec flagged as its own weak point, because finding 3 is right that BiRefNet_lite is the better model for anything that is not a person — so most users get the worse cutout first. It is still the answer: the browser path costs the operator nothing, keeps the image on the user's machine, and is the only one that scales to "every image, every surface" (Principle 6). The upgrade is one click away from a result the user can already see is wrong, which is a far better position than a ten-second server round trip they did not ask for. Task 0.3 is written against this.
- D2 — variants live until they are deleted. No cap, no eviction, no interval sweep. Silently binning a derived image is the same class of mistake as 6.6's "discard is explicit", and an eviction policy chosen before anyone has run the feature is a guess. Task 1.7 measures what it actually costs so 12-early-saas-maturity can answer it with numbers.
- D6 —
ArtworkExtenderExtensionis deprecated, not removed. Extend registers as an operation in 2.4 and the old extension keeps working as an alias. genai is versioned and released separately, and a platform release that breaks a module the day it lands is how the dist-vs-src trap bites. Removal is a v0.72.0-or-later commit, after at least one genai release has shipped against the registry. - Correction to the spec: 4.7 and 6.4 are no longer blocked. Both are marked as waiting
on Library Overhaul Phase 0 to give library items a real storage URL. That shipped in
v0.66.0 —
library_items.asset_urlexists in both the sqlite and pg schemas. Neither task is in this release, but the note that says they cannot be written is stale and is corrected in the spec.
Phase 0 — Repair (blocking)
- [x] 0.1 — Stop fetching image models from a third-party CDN at
@latest.apps/web/src/utils/backgroundRemoval.tsresolves MediaPipe's WASM fromcdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasmand its weights from astorage.googleapis.com/…/latest/selfie_segmenter.tflitepath. Serve the WASM from our own bundle —@mediapipe/tasks-visionis already a dependency — and pin the weights. Extend v0.69.0's worker-source guard into one contract test covering every runtime model fetch inapps/webandpackages/*: no unpinned third-party host. (Done. The WASM could not take the pdf.js route —@mediapipe/tasks-visiondeclares anexportsmap with no subpath entry, so?urlcannot resolve it — soscripts/copyMediapipeWasm.mjscopies it out ofnode_modulesat predev/prebuild into gitignoredpublic/, which pins it to the lockfile. The weights are Apache-2.0 and are committed atpublic/models/mediapipe/, 249,537 bytes, with source, hash and license beside them;float16/1is byte-identical to whatlatestserved and upstream has not republished since 2023. Verified in the browser rather than asserted: the vendored pair and the CDN pair produce byte-identical category masks — 0 differing pixels of 102,400 — on a shaded portrait that segments at 31.9% foreground, plus noise and gradient controls, and the local path initialises ~4× faster. The guard is nowruntimeAssetHosts.guard.test.ts, four rules wide. Writing it found two things worth keeping:baseUrlis an API base far more often than a model root, so it is deliberately not a watched key, andmap.purplecat.gamesserving a genai.onnxis not a third party — the rule is host-based, with a first-party allowlist that a lookalike domain cannot pass.) - [x] 0.2 — Make the character sheet's promise true.
PortraitArtPanetells the user background removal and AI extension "apply to the source art itself — edit the source art to reach them", andSourceArtEditoroffers Upload / Search / URL / Library / Paste under the placeholder line "Modules may add more sources here." Module sources are typed (module:${string}) and never rendered. Render them frommoduleRegistry.getArtworkSources(), host the registered extender panels the waySourceImageEditorDrawerdoes, and make the pane's copy describe what is actually reachable until Phase 4 makes it true properly. (Done, and the spec was half-stale: module sources have been rendered here since v0.65.0 — the finding was written against v0.59.0. What was genuinely missing was the extender, which is the half the Portrait pane's sentence actually pointed at. Each registered extender is now its own option (extend:${id}) in the source list, opening the same half-width pane a module source does — one button per registration, matching how module sources already appear here, rather than the Token drawer's tabs, which exist because that drawer is one flat panel with no half-step. Extenders are hidden when there is no image to extend, so a new artwork gets no dead button, and the pane extends the UNPROCESSED source so a cutout cannot be baked in. The result goes down the sameuploadArtworkpath as every source. The option list moved into a purelistSourceOptions(), which let four source-text assertions in the contract test be replaced with real behaviour tests against the registry — order, module entries, extender entries and the no-image gate now execute rather than being matched as text. Live-verified against the app's own singleton registry in the browser. The Portrait pane's copy no longer names background removal, which is not reachable anywhere on the sheet; Phase 4 makes it true and the sentence goes away.) - [x] 0.3 — One background-removal call, three implementations behind it. Replace
BackgroundRemovalPanel's three buttons — "Remove Background", "Refine on Server", "OpenAI" — with one action over a singleremoveBackground(image, { impl })inshared, defaulting per D3. The models are genuinely different and the choice stays; what goes is the user having to know which of three verbs suits their picture. Record which one ran; 1.1 gives it somewhere durable to go. (Done. It was four verbs by the time this was written, not three — "Remove Background", "AI Remove", "Refine on Server" and "Re-process" — behind three handlers that each carried their own state, error string and, for the two server ones, their own drifted copy of the same blob-vs-URL posting logic. The component was where "how to remove a background" lived, which is precisely why no other screen had the capability. The catalogue is now data inshared/types/imageOperations.ts— label, model name, what each is actually good at, cost class, and whether the image leaves the machine — so task 0.5's server response and Phase 2's registry read the same thing.apps/web/src/utils/removeBackground.tsis the single dispatcher; the panel has one button. Per D3 the alternatives appear on the result, tooltipped with what each does better, never as a setting chosen in advance. Every run reports itsimplementationId, shown on the preview and logged until 1.1 stores it. Two English bugs that no test or type could see were caught by running it in a browser: interpolating the label bare gave "On our server needs a signed-in session.", and lower-casing it gave "with openai" — hence the separatewherefield, and a guard againstlabel.toLowerCase()coming back.) - [x] 0.4 — Fix
remove-background-openai's funding model. It readsprocess.env.OPENAI_API_KEYatimages.ts:458. Route it through the samex-ai-key/x-ai-providerheaders every other AI feature uses, or remove the button. (Done — routed, not removed.resolveImageProviderKey()isresolveLLMConfig's ladder (caller's key, then the environment) made pure and reusable, and it reads the pluralx-ai-keystoo, which is what the client actually sends. The route stops being special rather than becoming special in a new direction: the operator's key remains the fallback exactly as it is everywhere else, which is the part 12-early-saas-maturity inherits rather than this release inventing. What is new is that the response now saysfundedBy: 'user' | 'operator', because the caller cannot tell from outside whose key was spent and metering needs to know. The client half mattered as much as the server half — the panel used the plainuseAuthFetch, so no key rode on the request and the ladder would have fallen through every time; it now usesuseImageGenAuthFetch, andneedsImageGenKeymatches the one exact path rather than the/api/images/prefix, so the BiRefNet remover and the upload routes do not receive a key they have no use for. The 501 stops saying "OpenAI API key not configured" — true, and useless to someone who has two other removers available. The guard forbidsprocess.env.*API_KEYanywhere inimages.ts, not just OpenAI's: the next server-side image operation will be Gemini's, and the cheap way to write it is the way this one was written.) - [x] 0.5 — Give each server-side operation a declared cost class and a ceiling.
POST /api/images/remove-backgroundruns a ~10s CPU inference in the API process with no queue and no limit. Per-user concurrency of 1 and a request-rate ceiling returning a real 429 rather than queueing silently inside Express, and the cost class (server-cpu) on the response so metering has something to read. (Done.ImageOperationGateis per-user and per-operation — concurrency 1 plus 10 starts per 60s — and it rejects, which is what distinguishes it from theimageWorkGatealready in that directory, which deliberately queues. That one bounds peak memory across the process, where waiting is right because a decode is short; this bounds one user's share of a ten-second inference, where queueing three deep is a request that times out somewhere the user cannot see, having consumed the resource anyway. Both limits are needed: concurrency alone permits an unbounded serial stream, which is exactly what a script does and exactly what costs money on the third-party path. The 429 carriesRetry-Afteras the real seconds left on the window, and a message pointing at the in-browser remover — which has no ceiling, because it runs on the user's own machine.costrides on every response including the refusal. Both routes release in afinally: with concurrency 1 a leaked permit is a permanent lockout after one 500, and nothing would report it. Two things worth recording —server-corecompiles withstrict: false, which switches off literal-discriminant narrowing, so the union needs explicit type predicates rather thanif (!decision.allowed); and the gate is in-process, so a multi-instance deployment gets it per instance. Weaker than a shared counter, far stronger than nothing, and the durable budget is 12-early-saas-maturity's — which is whatcostandfundedBynow exist to feed. Verified against the built singleton with its shipped defaults, not only a hand-built instance in a test.)
Phase 1 — Variants
- [x] 1.1 — Declare
ArtworkVariantand addvariants[]toArtworkEntity. Type, zod schema and the parity-guard entry in one commit. Zod strips undeclared keys and this has bitten the codebase four times: a variant list on the type but not the schema is dropped on save and looks exactly like "the operation failed". Artworks live intoken.artworks,character.artworksandcampaign.properties.artwork; assert the round-trip per store. (Done, and "assert it per store" earned its keep immediately. There are two copies ofArtworkEntitySchema—characters.tsandtokens.ts— and the parity guard only ever walked the first. The second had drifted both ways: missingportraitCrop, the exact field whose loss on the character route was the headline bug of v0.51.0, so a crop sent throughPATCH /api/tokens/:id/artworks/:artworkIdwas being silently discarded — the identical defect alive in a second place nobody had checked; and declaring anamethat is on no artwork type and that nothing reads. Both fixed. The guard now walks every schema that accepts an artwork, verified by re-introducing both drifts and watching it name each one rather than trusting a green run. The round-trip tests must be per store because the three validate differently — character and token strip, campaign is.passthrough()and does not — so an undeclared variant list would have vanished on two screens and worked on the third.paramsisz.record(z.string(), z.unknown()): opaque to the platform, still required to be an object. A variant with nooperationIdis rejected outright.) - [x] 1.2 — Slot bindings may point at a variant.
tokenArtworkId/portraitArtworkIdkeep identifying the artwork; the variant selection goes alongside, so two slots on one artwork can use different variants. This is the mechanical form of "the user chooses which version is used, per use", and it is what v0.47.0 wanted when it kept background removal off the Portrait pane. Extend the type-based fallback rules rather than bypassing them: a null selection still means "the artwork's current image". (Done. The selection lives on the artwork astokenVariantId/portraitVariantId, not on the owner, and that is the load-bearing choice here: the owner'stokenArtworkId/portraitArtworkIdare DB columns, whereas per-slot render settings already live on the artwork —tokenizeSettingsis the token slot's framing andportraitCropis the portrait slot's. This is a third one of exactly that kind, so it rides inside theartworksJSON every store already carries and needed no migration in either dialect. An artwork belongs to one owner, so there is no ambiguity about whose selection it is. Resolution extends the existing rules rather than replacing them: no selection returns precisely what the slot rendered before variants existed, so deriving an image changes nothing until someone chooses it. A dangling id degrades to the source rather than rendering nothing — 1.6 will re-point slots on delete, but a client can hold a stale id across that, and the failure mode of getting this wrong is a token vanishing off the map.ArtworkSlotmoved tosharedandartworkAssignment.tsre-exports it, because a second local copy of what a slot is would be the same mistake as the secondArtworkEntitySchemathat 1.1 just found drifted.) - [x] 1.3 — Read
cutoutUrlas a variant; migrate on first edit.normalizeArtworkVariants()derives a syntheticremove-backgroundvariant from a legacycutoutUrlin memory on read, persisted with the user's first edit — the same shape as the campaign artwork migration, with stable derived ids so repeated derivation is idempotent. (Done.normalizeArtworkVariants()derives aremove-backgroundvariant with a stable${artworkId}-legacy-cutoutid, applied innormalizeArtworkOwnerso the editor sees it and every operation's patch carries it on the first real edit — deriving never writes on its own.implementationIdis deliberately absent: three models wrote that one field and nothing recorded which, so guessing would manufacture provenance and tell a user "BiRefNet" about an image OpenAI made. It binds the portrait slot only, which is not a new preference but the existing behaviour written down — every surface that preferred the cutout is portrait-shaped (getCharacterDisplayArt,chatAvatar,PortraitImage, card art), whilegetMapImageUrl, which decides the actual map token image, has always ignored it. Binding the token slot would have silently changed what is on people's maps, and a migration that alters what a user sees is not a migration. Asserted directly: the portrait renders the same URL after normalising as before.) - [x] 1.4 — Stop writing
cutoutUrl. Every writer moves tovariants[]; the field becomes read-only historical data. (Done. Both writers —TokenArtworkEditorandBackgroundRemovalEditorDrawer— now append a variant, and the panel hands backimplementationIdalongside the URL, because it is the only place that knows which of three models ran. The behaviour that actually changes for users: a second removal no longer overwrites the first, so trying BiRefNet after MediaPipe keeps both and the earlier result is still there if it was better. Two adjacent writers had to move as well: the drawer's Apply Crop baked its result intocutoutUrl, destroying the un-cropped removal it was cropped from, and now records its ownportrait-cropvariant with aparentId— that operation id is not in the registry yet, and Phase 2 should decide whether a baked crop belongs there at all given the renderer appliesportraitCropat draw time; filing it underremove-backgroundwould simply have been false. Clearing deletes the legacycutoutUrltoo, which looks like a violation of this task and is the opposite: leaving it would let 1.3 re-derive the variant on the next read and the cutout the user just cleared would reappear. And replacing an artwork's source now dropsvariantsand both slot bindings — derived images belong to the picture they were derived from, and a replacement is a different picture.) - [x] 1.5 — Strip
processedUrl. The one-time migration its deprecation note has specified since v0.22.0. Nothing reads it. (Done — and "Nothing reads it" was false. Eighteen files still preferred the pre-baked image overimageUrl: the chat avatar, the party panel, the target panel, both token contexts, the monster and encounter generators, the character importer, twoui-componentsgalleries, and more. That is precisely how a token could look one way on the map and another way in chat — the renderer moved to a draw-time transform in v0.22.0 and these surfaces never followed. All of them now readimageUrl. Measured before writing the migration rather than assumed: on a database of 490 tokens and 106 characters, 117 token artworks and 23 character artworks carried aprocessedUrl, and 139 of those 140 also carry atokenizeSettings.transform, so they re-derive their exact appearance at draw time. Exactly one token artwork has no transform and falls back to its source image; four of the values weredata:URLs stored in the row. Both dialects ship a migration,equipmentincluded (it had none locally, but a migration covering two of three stores is one somebody has to remember). Campaign artwork is deliberately excluded — that feature postdates v0.22.0 and never wrote the field, confirmed by query rather than assumed. The sqlite half has a trap worth naming:json_removereturns TEXT, sojson_group_arrayover it stores every artwork as a quoted string — no error, the row still parses, and the column silently stops holding objects. Hence thejson(...)wrapper, and a test that executes the migration against real SQLite and asserts the results are objects. The pg half was dry-run against 711 real artworks inside a rolled-back transaction: 117→0 with the artwork count and every id unchanged. One gap this exposed:stripTransientUrlsnever learned about variants, so a variant built from adata:URL before upload would have been persisted as an entry resolving to nothing — it now drops those and unbinds any slot pointing at one.) - [x] 1.6 — Variant lifecycle. Deleting a variant a slot points at re-points the slot at
its parent; deleting an artwork deletes its variants' stored objects. Two names, two
behaviours, tested separately — the sheet already has two Trash buttons an inch apart
with different meanings, and a third arriving un-named is how that confusion doubles.
(Done, and the two behaviours really are different. Deleting a variant re-points
a slot at that variant's parent, not at the source — the parent is the closest
thing to what the user was looking at, and falling all the way back would discard
every earlier operation as a side effect of deleting the last one. Children are
re-parented rather than deleted, so removing "remove background" from under "upscale"
does not silently take the upscale with it. Deleting an artwork deletes the
storage objects its variants own, which is new — the route deleted the row entry and
no objects at all before. Two guards, because the failure mode is deleting an image
something else is still showing: only variant URLs are considered (
imageUrlandoriginalUrlcan be shared, since "copy to portrait" mints a second artwork pointing at the same upload), and any URL a surviving artwork still references is skipped. A storage failure is logged, not fatal — the artwork is gone from the row either way, and a 500 would leave someone unable to delete an artwork because of a storage hiccup.storagePathFromUrlcame out of the route intostorage/where it belongs: several upload paths append?t=${Date.now()}, and a path still carrying that query matches no stored object.) - [x] 1.7 — Storage accounting. Variants are full-size stored images; four operations
across twenty portraits is eighty extra objects the user never explicitly created.
Record variant bytes against the owner, so D2 stays answerable with numbers.
(Done.
ArtworkVariant.bytesis recorded at creation from the upload result, andvariantStorageBytes()aggregates it;GET /admin/data/variant-storagereports the total, the owners with variants, and the biggest offenders. The design point is that it reportsunmeasuredalongside the total instead of folding it in: variants derived from a legacycutoutUrlcarry no byte count, because those objects were uploaded before anything counted them, and a total that silently omitted them would read as authoritative while being wrong in one direction only. D2 asked for numbers rather than a guess, and "1.2 MB across 8 variants, 3 unmeasured" is a number where "1.2 MB" alone is a guess wearing a number's clothes. Also hardens against a storedNaN, which would otherwise poison every downstream total and make a future quota fail open. Writing this tripped the admin router's own guard, which asserted an endpoint count of exactly 5 — correct as a not-passing-vacuously check, useless as a message; it now names the endpoints it expects, so adding one fails with which line is missing rather than with a number that moved.)
Phase 2 — The operation registry
- [x] 2.1 — Declare
ImageOperationinsharedper §2.2, with a platform-side registry andmoduleRegistryintegration. React-free like every other extension type inmodule.ts— the panel isunknown, narrowed on the client.isAvailableis the whole point: it turns five bespoke conditionals into one list filter. (Done. Two registries rather than one, deliberately: the platform's own operations go intoimageOperationRegistryand only module contributions go throughmoduleRegistry.registerImageOperation, so the platform never has to pretend to be a module to register its own features —imageOperationsFor()merges them. A module cannot shadow a platform operation: one that could registerremove-backgroundover ours would silently redirect every cutout in the app to itself. Unavailable operations are returned, not filtered — carrying a reason is the whole point, and "needs the GenAI module" is actionable where an absent button is not. AnisAvailablethat throws is treated as unavailable rather than crashing the list, because that call runs module code and one bad module must not take out the dialogue for every operation including ours. The result model has ananalysiskind from the start: face detection (2.5) and palette extraction (2.6) return data, and a registry that could only express image-in-image-out is exactly why those two ended up welded to the one screen that wanted them.) - [x] 2.2 — Register background removal. 0.3's unified call becomes the first registered
operation, with
implementationIdas its parameter andcostvarying by implementation (local/server-cpu/third-party). (Done. One operation with the implementation as a parameter, not three operations — three entries reading "Remove Background (MediaPipe / BiRefNet / OpenAI)" would put the whole choice back in front of a user who mostly wants the background gone, which is the UI defect 0.3 removed. Cost varying by implementation needed a small addition to the type:costFor(params), because a singlecostwould be a lie for two of the three and the badge shown before clicking is exactly where that lie would land. An unknownimplfalls back to the default rather than throwing — a stale client sendingimpl: 'bria'should get a cutout, not an error dialog. Registration happens at startup rather than lazily: the operation list has to be answerable the first time any surface asks, and a list that is empty until something imports the right module is how a capability ends up looking unavailable. It is cheap — four objects, no model loaded;isAvailableis what defers the expensive questions.) - [x] 2.3 — Register upscaling, and make it work on any image. Today it is a post-step
inside genai's generate flow gated on
layerId === 'background'; there is no path that upscales an image you already have. LiftupscaleToFactorout and register it,isAvailablebeing the existingavailableLocalUpscalers()plus the backend check. The buffer-to-buffer core needs no change. The Big Map checkbox stays where it is — generate-then-upscale in one pass is a real convenience, and now it is the same operation. (Done, in genai v0.7.0 — the upscaler needs ONNX Runtime, the model catalogue and backend detection, all module infrastructure, so lifting the code into the platform would have meant lifting all of that with it. Registering it achieves what the task actually asks: thelayerId === 'background'gate stops being the only way to reach it, andupscaleToFactoris untouched. Big Map stays.isAvailabledistinguishes "your browser has neither WebGPU nor WebAssembly" from "no upscaler is published on this deployment" — two failures with two different fixes. The interesting part was the host-compatibility trap, which genai documented for itself in v0.6.1: its CI resolves@purple-cat/sharedagainst platform main, so importingImageOperation— which only exists on this unmerged branch — would have reproduced the v0.6.0 deadlock exactly (module cannot publish until platform merges; platform publishes modules before it merges). The shape is declared locally instead, and I verified it by compiling genai against a real checkout of platformmainrather than reasoning about it. The runtime half is easier to miss and worse: a host below 1.28.0 has noregisterImageOperation, and an unguarded call throws insideregister(), taking the artwork source and settings panel down with it — so registration is guarded and a missing registry is a silent no-op, with extend still reachable there viaArtworkExtenderExtension, which is exactly what D6 chose.) - [x] 2.4 — Register extend. genai's
ArtworkExtenderExtensionis an operation in all but name. Register it withinputs: ['direction', 'prompt']and keep the old extension working as a deprecated alias per D6. The size multipliers matter to exactly one caller (BackgroundTokenBarresizes its map token in proportion) and belong on the operation result, not in a bespoke callback signature. (Done, in genai v0.7.0. The size multipliers now travel on the operation result, which is the substance of the task: they used to ride in a bespokeonExtendedImageReady(url, w, h)callback that exactly one caller in the platform understood, so every other surface either ignored them or could not use extend at all. Getting there meant extracting the work out ofExtendImagePanelintoextendImage.ts, shared by the panel and the operation — same downscale, same direct-or-proxy fallback, same stitch. One real fix fell out: the panel re-derived the multipliers fromdirectionat accept time, which was only correct because the two could not disagree; they come off the result now.supportsExtendmoved with it, so the operation filters models with the same predicate the dropdown shows — two predicates would eventually disagree and the user would pick a model that 400s. D6 in practice:ArtworkExtenderExtensionstays registered and deprecated, and that is not merely politeness — it is the only way extend is reachable on a host below MODULE_API_VERSION 1.28.0. Removal is a later module release. Extend declarescost: 'third-party'even when proxied through our server, because the question a cost class answers is who sees the image, not which host the bytes transited.) - [x] 2.5 — Register face detection as a real operation. Split
detectFaces()out oftokenizeImageso the boxes are available on their own;tokenizeImagekeeps working by calling it. The result is data, not an image, so the registry needs an "analysis" result kind — which 2.6 needs too. (Done.detectFaces(imageUrl)is exported and returnsDetectedFace[]in plain image pixels — BlazeFace's ownNormalizedFacecarries tuples that may be numbers OR tensors plus landmarks nothing uses, a shape onlytokenizer.tscould consume, which is half of why the capability was unreachable.tokenizeImagekeeps working by calling the same detection, and the three-branch probability unwrap it carried inline becameconfidenceOf, shared. Results come back most-confident first, which is the ordertokenizeImagealready assumed when it tookfaces[0].) - [x] 2.6 — Register palette extraction, and store the result. The pure half already
exists in
portraitGradient.ts; wrap the Vibrant call as an analysis operation and persist the swatches on the variant.usePortraitGradientthen reads a stored palette and derives only on miss, instead of re-deriving node-vibrant on every mount — and a token's chat colour or a campaign card's tint can finally ask for one. (Done, and the storable half was the real work. node-vibrant returns Swatch class instances with methods — they survive neitherJSON.stringifynor a round trip through the database, so persisting them directly would write{}: a palette that looks stored and reads back empty.StoredPalettekeeps hex and population, which is what makes the answer durable.usePortraitGradientnow takes an optional stored palette and short-circuits before touching the image; with none, it derives exactly as before — a cache with a correct miss, not a new dependency. A failed extraction (almost always CORS) reportspalette: nullrather than throwing, because "this picture has no obvious colour" is a legitimate answer and an error dialog over a decorative tint would be out of proportion.) - [x] 2.7 — Bump
MODULE_API_VERSION(minor, additive) and document the operation extension point. A module that registers nothing is unaffected. (Done — 1.28.0, additive. The history entry does more than name the extension point: it records the two traps a module hits if it assumes the host has it, both of which genai learned the hard way in its own v0.6.0. A module's CI resolves@purple-cat/sharedagainst the platform's default branch, so importingImageOperationcannot compile while the release is unmerged; and a pre-1.28.0 host has noregisterImageOperationat all, so an unguarded call throws insideregister()and takes every other registration down with it.ArtworkExtenderExtensionis marked deprecated here, per D6, while still working. Documenting this turned up rot worth naming:docs/committed/architecture/api-index.mdadvertised the Module API as1.5.0— twenty-two minor versions of extension points that the one document a module author is pointed at did not know about. Fixed, with a guard tying the constant, its history entry and the index together so a bump cannot skip its own documentation.) - [x] 2.8 — Registry tests. Availability gating, cost classes, ordering, and a
module-contributed operation appearing and disappearing with its module.
(Done — those four, plus three properties later phases quietly assume: ordering is
stable regardless of registration order (a list that reshuffles because two modules
loaded in a different order has worthless muscle memory); module operations
interleave by
orderrather than being appended after the platform's, so one that belongs beside a platform operation sits beside it rather than at the bottom because of who registered it; andisAvailableis not consulted until something asks — it is a method precisely so probing a GPU adapter or listing model files stays out of startup.)