Engine Dispatch
Engines declare what they can do; this page is how the pipeline decides who actually does it.
Supplying engines: an ordered array
const mp = await createMediaPipeline({
engines: [
() => import('@nhtio/adk/batteries/media/engines/jimp').then((m) => m.jimpEngine()),
() => import('@nhtio/adk/batteries/media/engines/tesseract_js').then((m) =>
m.tesseractJsEngine({ languages: ['eng'] })
),
() => import('@nhtio/adk/batteries/media/engines/soffice').then((m) =>
m.sofficeEngine({ path: '/usr/bin/soffice', executor, workspace })
),
],
})The array is priority order: when several engines can perform the same transform, the earliest capable one wins. Instances work too (handy in tests).
Resolvers run eagerly, at construction. Capability declarations drive which verbs the deployment advertises, so they must be known up front. This costs you nothing real: every bundled engine imports its heavy peer (sharp, tesseract, transformers) lazily inside its capability methods, so constructing the pipeline loads only thin wrapper modules. What it buys you: every configuration error surfaces at createMediaPipeline, with the index named — not three turns into an agent run.
How dispatch picks an engine
One rule, applied everywhere:
- Capability filter. Only engines whose declarations match the request are candidates —
from/overmatch the input MIME,to/encodescover the requested output,opscover the requested operations. A declaration can't be argued with and can't be conscripted around. - Selection middleware (optional, below) may exclude or reorder the candidates.
- Array order among survivors. Always the final word. You can read the engines array and know the default outcome.
And when no single engine has a direct convert edge, the registry computes a path: if engine A does ODT→DOCX and engine B does DOCX→PDF, then convert to=pdf on an ODT input runs A then B — shortest path wins, capped at three hops, array order breaking ties. convertTargets() reports everything reachable, so error messages reflect multi-hop reality, not just direct edges.
One guardrail you should know exists: lossy and virtual formats are path endpoints, never stepping stones. The pathfinder will not route docx → txt → anything (that "conversion" would launder a document through plain text and call the result a document), and it will not auto-chain audio → pcm → txt (the transcribe step owns that composition, because a 16 kHz resample has to happen between the two legs). If you're wondering why your creative conversion chain isn't being found — it's probably this rule, and it's protecting you. To be equally clear about what the rule is not: it gates routing only. A lossy format as the destination is still valid media — convert to=txt works, empty:txt is creatable, txt can be appended to and patched.
The composition mistakes this model makes possible — a broad engine ahead of a narrow one silently dead-coding it, static engine imports bloating bundles, ConvertOptions augmentation aimed at the wrong module, an engine composed without the peer dependency it needs to actually run — are machine-checkable, so the battery ships an ESLint plugin at @nhtio/adk/batteries/media/lint that catches all four at lint time instead of at 2 a.m.
Selection middleware: when capability isn't the whole story
Here is the problem the middleware exists to solve, made concrete. Register both spreadsheet engines — exceljsEngine() and sheetjsEngine() — and ask to edit an .xlsx. Both engines truthfully declare sheet.update_cells over xlsx. Both are capable. But they are not interchangeable: ExcelJS preserves the workbook's fonts, fills, and comments through the edit; SheetJS CE strips them (styling is a Pro feature — see the fleet's two-spreadsheet story). Which one is "right"?
There is no right answer the library can pick for you. If the workbook is a human-authored quarterly report, stripping its formatting is corruption and ExcelJS must win. If it's a machine-generated data dump with no styling to lose, SheetJS's wider format reach is pure upside and there's nothing to protect. The correct engine is a property of your workload and your bytes, which the registry cannot see and has no business guessing at. Array order gives you a static default (earliest wins); but when the choice is content-dependent — "styled workbooks to ExcelJS, everything else fine on SheetJS" — that's policy, and policy gets the battery's standard seam: a middleware onion.
const mp = await createMediaPipeline({
engines: [exceljs, sheetjs],
selection: [
async (ctx, next) => {
// The decision the library refuses to make for you, made explicitly here:
// styled workbooks must keep their styling, so they never reach the CE editor.
if (ctx.kind === 'edit' && looksStyled(ctx.request.bytes)) {
ctx.candidates = ctx.candidates.filter((e) => e.id !== 'sheetjs')
}
await next()
},
],
})This is the canonical reason the seam is shaped the way it is. The registry will not silently route your styled workbook to the styling-stripper, and it will not silently route your plain data dump away from the engine with the wider format support — because both of those "smart defaults" are wrong for someone, and a library that guesses wrong about your data has made a decision you'll discover in production. So it doesn't guess. It hands you the candidates and the bytes, and you encode the rule that's right for your workload.
Stages receive the dispatch kind ('convert' | 'mutate' | 'edit'), the request (including bytes — content-dependent rules need them), and the capable candidates in array order. They may exclude and reorder. They may not add: survivors are re-filtered against the original capable set, so a stage cannot conscript an engine the capability filter rejected. Excluding everyone is an honest, named failure ("all capable engines (exceljs, sheetjs) were excluded by selection middleware"), not a silent fallback.
Be clear-eyed about the trade: with stages configured, priority is logic, not data — you can no longer read the engines array alone and know who wins. Most deployments need zero stages — if you only register one spreadsheet engine, there's nothing to arbitrate, and array order is a complete answer. Write a stage when you have a real quality or override rule like the one above, keep it cheap (it runs per dispatch, bytes in hand), and keep it deterministic.
Verbs follow capability supply
With no convert provider, the convert verb isn't advertised to models, mp.compile('convert to=pdf') throws with a do-not-retry message, and the granular forge surface simply doesn't mint a convert tool. Add an engine that declares the capability and all three appear. The sheet.* edit verbs follow the same rule, which catches some people coming from earlier versions:
sheet.* edits require a registered edit engine
Installing the exceljs peer is necessary but not sufficient: the sheet.* verbs are advertised only when an edit-capable engine is registered in the engines array. This is the same rule every other capability follows — convert needs a convert engine in the array, OCR needs tesseract in the array, and editing workbooks needs exceljsEngine() (or sheetjsEngine()) in the array. There is no special-case "if exceljs is installed, light up sheets automatically" path, because that would be the one capability that bypasses the registry, and a consistent model is worth more than the convenience. Register the engine you installed; the failure message names this fix if you forget. (In earlier internal builds workbook editing was a hidden lazy import that lit up on install alone — if you're carrying that assumption forward, this is the line that changed.)
mp.capabilities and mp.engines make the active set inspectable, and buildEngineRegistry is exported if you want the same dispatch machinery outside a pipeline.
Read next
- The Bundled Fleet — who's actually in the array.
- Generating Media — the
EMPTY_MIMEedge dispatch treats like any other conversion.