File transforms
FileTransformer derives a file from a source — resize/crop/re-encode an image, render a PDF
page, extract a video frame or audio cover, OCR to Markdown, transcribe media. It is a
pipeline of small transformers that run in phase order; each decides whether it applies.
Results are cached on disk.
const tf = FileTransformer.create({ cacheDir: "/var/cache/pri/" });
const { path, mime, transformed, key } = await tf.transform("/uploads/photo.heic", { w: 800, fmt: "auto" });
The app creates one instance at boot (app.fileTransformer). Usually you call
dbFile.transform(options), which uses it (see DbFileManager).
Options
transform(sourcePath, options, knownMime?) — options: TransformOptions:
| Key | Meaning |
|---|---|
w / h |
target width/height (px); triggers geometry |
max |
scale-to-fit instead of scale-to-fill + crop |
hpos / vpos |
crop focus point in % (default 50 / 20) |
zoom |
min scale factor when cropping |
q |
quality 1–100 (default 77) |
dpr |
device pixel ratio — multiplies w/h (capped at the call site), then dropped |
fmt |
avif | jpg | png | md | json | auto (default auto) |
page |
PDF page (1-based) |
frame |
video frame (1-based) |
fmt: "auto" encodes several formats and keeps the smallest: AVIF vs JPEG if AVIF is available,
else PNG for images with alpha, else JPEG vs PNG. fmt: "md" extracts text (documents → Pandoc, PDF → pdftotext/OCR, images → OCR,
media → transcript). fmt: "json" yields a raw transcript.
Result
interface TransformResult {
path: string; // the derived file, or the source if nothing ran
mime: string;
transformed: boolean; // false = original returned (no transformer matched, or an error)
key?: string; // content identity (source + options) — stable, usable as ETag
error?: Error; // set on failure; path/mime fall back to the source
}
transform() never throws; on failure it returns transformed: false with error, and the
original is served.
Phases & the pipeline
Each transformer has a phase. Phases run in a fixed order; within a phase, after sets the
order:
| Phase | Purpose | Built-ins |
|---|---|---|
decode |
reduce an exotic input to a plain image/text | gif-guard, pdf-decode, svg-decode, transcript, video-decode, audio-decode, markdown, ocr |
geometry |
resize / crop | image-resize |
encode |
pick format + quality | image-encode, svg-optimize, then pngquant (after: image-encode) |
All transformers share one TransformContext and update currentPath / mime / meta for the
next one. Only transformers whose handles(ctx) returns true run (checks mime, options, tools).
If currentPath is unchanged at the end, the source is returned.
The context:
| Field | Use |
|---|---|
currentPath |
current input; write your output to a new file and point this at it |
mime |
current MIME; update it when you change the format |
sourcePath |
the original source (read-only) |
tmpDir |
scratch dir for output files — cleaned up after the run |
meta |
notes passed between transformers, e.g. geometryApplied lets encode know a resize ran |
signal |
the shared timeout AbortSignal — pass to every external command |
options |
the (dpr-resolved) TransformOptions |
interface TransformerDef {
name: string;
phase: Phase;
props: string[]; // option keys this transformer reads → folded into the cache key
after?: string; // ordering within the same phase
handles: (ctx) => boolean | Promise<boolean>;
transform: (ctx) => Promise<void>;
}
Register your own with tf.register(def) (unique names). Only options listed in props are part
of the cache key — list every option you read, or old cache entries are served.
Caching
The cache key is SHA-1(sourcePath + size + used options, sorted). A hit returns the cached file
(its mtime is refreshed once a day for LRU). Writes are atomic: meta first, then the file is
renamed into place, so readers never see half a file. The key also serves as ETag.
Sources whose content can change at the same path (db-files can't) have no content hash yet; only
a size change invalidates them — see the > 1.0 note in FileTransformer.ts.
External tools
The real work is done by system binaries. Each has a small wrapper module with an available()
check and its commands:
| Wrapper | Binary | API |
|---|---|---|
magick |
magick/convert (ImageMagick) |
available · run · identify · avifSupported · resetCache |
ffmpeg |
ffmpeg |
available · coverArt · frame · audio |
pandoc |
pandoc |
available · run |
pdftotext |
pdftotext (Poppler) |
available · run |
tesseract |
tesseract |
available · run |
pngquant |
pngquant |
available · run |
rsvg |
rsvg-convert (librsvg) |
available · run |
inkscape |
inkscape |
available · run |
svgo |
svgo |
available · run |
scour |
scour |
available · run |
import * as ffmpeg from "./ffmpeg.ts";
if (await ffmpeg.available()) await ffmpeg.frame(input, 0, out, signal);
available() comes from probe(cmd, args) (tryCommand.ts): it runs the binary once and caches
the result. resetProbes() clears all probes, so a new tool needs no central change. magick
has its own cache (resetCache) because it also detects IM6 vs IM7.
No tool is required: if a binary is missing, available() is false, its transformers don't run,
and at worst the original is served.
Capability introspection
await FileTransformer.capabilities.avif; // Promise<boolean> per tool: magick, ffmpeg, avif, pngquant, pandoc, pdftotext, tesseract, rsvg, inkscape, svgo, scour
FileTransformer.resetCapabilityCache(); // re-probe after installing a binary at runtime
The superuser "transform tools" page reads these to show what's installed.
OCR & transcript engines
OCR and transcripts use engines, so modules can add better ones than Tesseract (e.g. AI
vision or speech-to-text). The available engine with the highest priority wins.
app.fileTransformer.registerOcrEngine({
name: "ai-vision", priority: 10, beatsTextLayer: true,
available: (ctx) => hasApiKey(),
ocr: (imagePath, mime, ctx) => callVisionModel(imagePath),
});
app.fileTransformer.registerTranscriptEngine({ name, priority, available, transcribe });
With beatsTextLayer, PDFs are always OCR'd, not only scans — for engines whose output is better
than the embedded text. Core registers tesseractEngine with priority 0.
Engines cannot be unregistered; they stay after
unlink. See "Not yet torn down" in module.md.
Timeouts
Each run has one AbortSignal (transformer.timeout, default 600 s). Pass it to every external
command ({ signal: ctx.signal }) so a hanging process gets killed.
Rules of thumb
- New transformer → set
phase,props(every option it reads) andhandles(incl. tool check). Updatectx.currentPath/mime, or leave them to pass through. - New tool → a wrapper with
available = probe(...)andrunfor the main call. - Never assume a tool exists — check
available(); a missing binary must not throw. - An option read but not listed in
props= stale cache.