The Capability Model
Every capability with more than one viable implementation is a contract, not a dependency. You compose the implementation when you build the pipeline, the choice is visible at the call site, and swapping it later touches one line. Nothing is ever auto-selected — that's the "bring your own everything" rule applied to media processing, and it is not negotiable here either. A library that picks your OCR engine for you has also picked your WASM payload, your model downloads, your licensing exposure, and your failure modes. We'd rather hand you a table of trade-offs and make you read it — that table lives on The Bundled Fleet.
Three capabilities. That's the whole taxonomy.
A media engine does exactly three things, and the contract refuses to pretend otherwise:
- Convert — change the format.
docx → pdf. And once you see it that way, a lot of things that usually get bespoke interfaces turn out to be conversions wearing costumes: OCR isimage/* → txt. Transcription ispcm → txt. Decoding audio isaudio/* → pcm. Extracting a PDF's embedded images ispdf → images(multi-output). And generating a new file is a conversion whose source format happens to be nothing —EMPTY_MIME → xlsx(see Generating Media). One declaration shape covers all of them. - Mutate — change the content, keep the family. Resize, rotate, flip, strip metadata — with an optional re-encode riding the same call, because making a fused resize-and-convert cost two decode/encode cycles to satisfy a taxonomy would be vandalism.
- Edit — restructure the document. Insert a worksheet row, rename a sheet, splice columns. One named op per request.
Why mutate and edit are different capabilities (and not one)
The obvious "simplification" — one mutation capability for both pixels and worksheets — was considered and rejected, because the two shapes exist for opposite reasons:
Mutate exists for fusion. Adjacent image transforms (image resize | image rotate | image format) fold into ONE engine request with fixed fields (resize, rotate, flip, format), because every image op pays the same decode/encode toll and paying it once instead of three times is the whole point. The request shape is closed and image-shaped on purpose — fusion only works when the engine sees all the folded ops at once.
Edit exists for fidelity arbitration. A worksheet splice is not a pixel pass: there's no decode/encode to amortize, so edits run one named op per request with open, verb-shaped args ({ op: 'sheet.update_cells', args }). What edits need instead is something mutate never needed — two engines truthfully declaring the same ops while differing in what they preserve. ExcelJS keeps fonts, fills, and comments through an edit; SheetJS CE strips them. Same declared capability, different fidelity, and the registry doesn't rank fidelity — your engines-array order does. That arbitration is the fleet page's two-spreadsheet-engines story.
Contorting worksheet ops into the fused image request (or un-fusing image ops into named requests) would have cost one of the two properties. Two shapes, each earning its keep.
Declarations are plain data
An engine declares what it can do as plain data — the registry never has to call engine code to know the shape of the world:
import type { MediaEngine } from '@nhtio/adk/batteries/media/contracts'
const myEngine: MediaEngine = {
id: 'my-engine', // shows up in error messages, so name it honestly
converts: [
{ from: ['application/pdf'], to: ['txt', 'html'], convert: async (request) => ({ outputs: [/* … */] }) },
],
mutates: [
{ over: ['image/*'], ops: ['resize', 'rotate'], encodes: ['png', 'jpg'], mutate: async (request) => ({ /* … */ }) },
],
edits: [
{ over: ['application/vnd…spreadsheetml.sheet'], ops: ['sheet.update_cells'], edit: async (request) => ({ /* … */ }) },
],
}A new capability is a new edge in the data — never a new contract, never a new slot, never a registry PR. Plain data is also the only thing the registry trusts: it reads declarations to decide what a deployment can do, and it never calls into an engine to find out. An engine that lies in its declarations gets caught the moment it's asked to deliver, not before — which is exactly why the declarations are validated and the outputs are validated and the types are treated as the least of the three.
The guard is the contract: TypeScript types are a courtesy; construction duck-checks every engine and every declared capability entry, and a malformed one fails loud with the index named — engines[1] ("sharp") does not implement the MediaEngine contract — never silently mid-chain. A misbehaving engine's output is also validated at the step boundary, so a BYO engine that returns garbage produces a clear error pointing at the engine — not corrupt media three steps later with your name on it. Loud at construction, loud at the boundary, never quietly wrong in between: the failure you can't see is the only kind this contract is afraid of.
Read next
- Engine Dispatch — ordered supply, the one selection rule, multi-hop pathfinding, selection middleware.
- The Bundled Fleet — every bundled engine, its trade-offs, and the two-spreadsheet-engines story.
- BYO Runtime & Custom Engines — implementing the contract yourself.