Media Transforms
Media Transforms turn any file the user uploads into the shape you actually want to serve. Normalize a wobbly phone-recorded webm into a clean mp4, pull a poster frame out of it, resize an oversized photo down to a thumbnail variant, extract text from a scanned PDF, convert a Word doc to PDF, render a single-file HTML for an email, or fill a .docx template with the user’s logo. Your code asks for the transform; the platform queues, runs, and stores the result. In development, the CLI processes files locally. In production, Maravilla Cloud handles everything. Your code works identically in both environments.
import { getPlatform } from '@maravilla-labs/platform';
const platform = getPlatform();
// Extract a poster frame one second into the video
const job = await platform.media.transforms.thumbnail('uploads/videos/clip.webm', {
at: '1s',
width: 640,
format: 'jpg',
});
// Render the derived file at `/api/v/{job.outputKey}` — no round-trip needed.
How It Works
- Your server (or a handler running in response to an upload) calls a transform method like
transcodeorthumbnail. The call returns immediately with a JobHandle — no blocking on a multi-minute encode. - The job is queued and processed in the background. The derived file’s storage key is deterministic — computed from the source key and the options — so you can render the final URL the moment you enqueue the job.
- Clients subscribe to lifecycle events (
transform.queued,transform.started,transform.progress,transform.complete,transform.failed) via Real-Time Events and update the UI as work progresses. - The output lands in Storage under the
__derived/prefix, served by the same/api/v/...route as any other file.
Two entry points: the explicit platform.media.transforms.* API in any server code, and a declarative transforms block in your maravilla.config.ts that reacts to new uploads automatically.
Transform Operations
transcode(srcKey, opts)
Re-encode a video into a consistent container and codec. Use this to normalize the output of mobile browsers (which write whatever their OS supports) into something every device plays.
Parameters:
srcKey(string) — storage key of the source videoopts(object):format('mp4' | 'webm') — target containercodec(string, optional) — specific video codec (e.g.'h264','vp9')maxWidth(number, optional) — cap on output width; aspect ratio preservedmaxHeight(number, optional) — cap on output heightaudioCodec(string, optional) —'aac','opus', etc.bitrateKbps(number, optional) — target bitrate
Returns: JobHandle — { id, srcKey, outputKey, status }
const job = await platform.media.transforms.transcode('uploads/videos/raw.webm', {
format: 'mp4',
maxWidth: 1080,
});
// Serve the result at `/api/v/${job.outputKey}` as soon as `transform.complete` fires.
thumbnail(srcKey, opts)
Extract a single frame from a video as an image. Perfect for poster frames in <video poster=...>.
Parameters:
srcKey(string) — storage key of the source videoopts(object):at(string) — offset into the source. Accepts'1s','00:00:01', or a plain number of secondswidth(number, optional) — output width in pixelsheight(number, optional) — output height in pixelsformat('jpg' | 'png' | 'webp', default:'jpg') — output formatquality(number, optional) — 1–100 quality hint for jpg / webp
const poster = await platform.media.transforms.thumbnail('uploads/videos/clip.webm', {
at: '1s',
width: 640,
format: 'jpg',
});
resize(srcKey, opts)
Resize or reformat an image. Generate responsive variants at upload time so your pages never ship a 5 MB hero image.
Parameters:
srcKey(string) — storage key of the source imageopts(object):width(number, optional) — target width; omit to let height drive aspectheight(number, optional) — target height; omit to let width drive aspectformat('jpg' | 'png' | 'webp') — output formatquality(number, optional) — 1–100 quality hintstripMetadata(boolean, optional, default:true) — drop EXIF and camera data
const thumb = await platform.media.transforms.resize('uploads/photos/pic.png', {
width: 400,
format: 'webp',
quality: 80,
});
probe(srcKey)
Read metadata without producing a new file. Synchronous (returns MediaInfo directly, not a JobHandle) because the work is cheap.
Returns: MediaInfo — { durationSecs?, width?, height?, videoCodec?, audioCodec?, bitrateBps?, container? }
const meta = await platform.media.transforms.probe('uploads/videos/clip.webm');
if (meta.durationSecs && meta.durationSecs > 60) {
throw new Error('clip too long');
}
ocr(srcKey, opts)
Extract text from an image or PDF.
Parameters:
srcKey(string) — storage key of the source image or PDFopts(object):lang(string, default:'eng') — ISO 639-2 language code, or+-separated combination ('eng+deu')
const job = await platform.media.transforms.ocr('uploads/scans/receipt.png', {
lang: 'eng',
});
// The extracted text lands at job.outputKey as a .txt file once complete.
Document Operations
Office documents (.docx, .doc, .odt, .rtf, .xlsx, .xls, .ods, .pptx, .ppt, .odp, .csv, .html, .txt, .epub, .md) come in through the same Storage upload path as anything else and use the same JobHandle lifecycle as the media operations above. Seven methods cover the common needs: convert to PDF, render thumbnails, extract content for LLMs, fill templates with images, and embed QR codes.
docToPdf(srcKey, opts?)
Convert any supported document into a PDF. The most common ask — invoices, contracts, reports, exports.
Parameters:
srcKey(string) — storage key of the source documentopts(object, optional) — reserved for future page-range / quality / accessibility knobs; pass{}or omit
const job = await platform.media.transforms.docToPdf('uploads/contracts/agreement.docx');
// Render the PDF at `/api/v/${job.outputKey}` once `transform.complete` fires.
docThumbnail(srcKey, opts)
Render a single page of a document as an image. Useful for document previews in lists, dashboards, and search results.
Parameters:
srcKey(string) — storage key of the source documentopts(object):page(number, default:1) — 1-indexed page number to renderwidth(number, optional) — output width in pixels (height preserves aspect)height(number, optional) — output height in pixelsformat('png' | 'jpg' | 'webp', default:'png') — output formatquality(number, optional) — 1–100 quality hint for jpg / webp
const cover = await platform.media.transforms.docThumbnail('uploads/reports/q3.pptx', {
page: 1,
width: 480,
format: 'webp',
});
docConvert(srcKey, opts)
Convert between any pair of supported document formats — .docx → .pdf, .csv → .xlsx, .html → .docx, etc.
Parameters:
srcKey(string) — storage key of the source documentopts(object):to('pdf' | 'docx' | 'odt' | 'xlsx' | 'html' | 'txt' | 'rtf') — target format
const job = await platform.media.transforms.docConvert('uploads/data/sales.csv', {
to: 'xlsx',
});
docToMarkdown(srcKey, opts?)
Extract a document’s content as clean Markdown — headings, lists, and tables preserved. Built for RAG and LLM pipelines that prefer structured text over raw HTML or PDF.
Parameters:
srcKey(string) — storage key of the source documentopts(object, optional):preserveTables(boolean, default:true) — keep GitHub-flavored Markdown tables; turn off when the consumer wants pure prose
const job = await platform.media.transforms.docToMarkdown('uploads/docs/handbook.docx');
// Once complete, the .md file is at `/api/v/${job.outputKey}` — feed it to your LLM.
docToHtml(srcKey, opts?)
Convert a document into a single self-contained HTML file with images base64-inlined and styles inlined. The output has no sidecar assets — perfect for email rendering, iframe embedding, or sending the whole document as one string.
Parameters:
srcKey(string) — storage key of the source documentopts(object, optional):inlineImages(boolean, default:true) — embed<img>references as base64data:URIsinlineStyles(boolean, default:true) — inline CSS as<style>blocks
const job = await platform.media.transforms.docToHtml('uploads/newsletters/march.docx');
// Once complete, the HTML is fully self-contained — embed in <iframe> or send via email.
docReplaceImages(srcKey, opts)
Fill a .docx / .odt / .pptx template with caller-supplied images. Two strategies, both supported in one call:
- Placeholder text-tags (
{{LOGO}}) — find the literal text in the document and swap in an image at that position. - Named drawing objects — match by the object’s
Nameproperty (set in Word/Writer via Format → Anchor → Properties → Name) and swap the image while preserving the template’s exact frame size, border, anchor, and text-wrap.
Use placeholders when the user types a tag in their template. Use named objects when the template already has a positioned dummy image you want to “fill” — frame styling stays.
Parameters:
srcKey(string) — storage key of the template documentopts(object):placeholders(object, optional) —{ '{{TAG}}': { srcKey: 'images/logo.png' }, ... }namedObjects(object, optional) —{ 'CompanyLogo': { srcKey: 'images/logo.png' }, ... }outputFormat('pdf' | 'docx' | 'odt' | 'xlsx' | 'html' | 'txt' | 'rtf', optional) — output format; defaults to keeping the input formatautoResize(boolean, default:true) — pre-fit each replacement image to the target frame’s dimensions to keep the output size sane
const job = await platform.media.transforms.docReplaceImages('templates/welcome.docx', {
placeholders: {
'{{USER_PROFILE_IMAGE}}': { srcKey: 'avatars/user-42.png' },
},
namedObjects: {
'CompanyLogo': { srcKey: 'brand/logo-2026.png' },
'HeaderBanner': { srcKey: 'campaigns/spring.jpg' },
},
outputFormat: 'pdf',
});
docInsertQrCode(srcKey, opts)
Generate QR codes server-side and inject them into a document — typically a backlink to the document’s own page in your app, a payment URL, or a tracking code on an invoice. Each code can target a placeholder text-tag or a named object, same as docReplaceImages.
Parameters:
srcKey(string) — storage key of the template documentopts(object):codes(array) — one entry per QR to embed:placeholder(string) ORnamedObject(string) — exactly onepayload(string) — the URL or string to encode (≤ 1500 bytes)size(number, default:256) — output PNG width in pixels
outputFormat('pdf' | 'docx' | 'odt' | 'xlsx' | 'html' | 'txt' | 'rtf', optional) — output format; defaults to keeping the input format
const job = await platform.media.transforms.docInsertQrCode('templates/invoice.docx', {
codes: [
{
placeholder: '{{QR_BACKLINK}}',
payload: `https://app.example.com/invoice/${invoiceId}`,
size: 256,
},
{
namedObject: 'PaymentQR',
payload: 'upi://pay?pa=acct@bank&am=42.00',
size: 384,
},
],
outputFormat: 'pdf',
});
job(id)
Look up the current status of a job by its id. Returns 'pending' | 'running' | 'complete' | 'failed'.
const status = await platform.media.transforms.job(job.id);
Derived Keys
Every transform output lands at a deterministic storage key derived from the source key and the options:
__derived/<srcHash>/<variantHash>.<ext>
Two users requesting the same transform of the same file hit the same output — the expensive work happens once. Because the key is a pure function of the inputs, you can compute it on the client before the job finishes:
import { transforms } from '@maravilla-labs/platform';
const posterKey = transforms.keyFor('uploads/videos/clip.webm', {
kind: 'thumbnail',
at: '1s',
width: 640,
format: 'jpg',
});
// → 'uploads/videos/__derived/Abc123.../Def456....jpg'
// Render the <video> element immediately with the final URL — the browser
// will 404 until the job completes, then hydrate on transform.complete.
Lifecycle Events
Every transform job emits five events through Real-Time Events. Subscribe in the browser to drive loading states, progress bars, or poster swaps without a page reload.
| Event | Fires when |
|---|---|
transform.queued | Job has been accepted; outputKey is already known |
transform.started | A worker picked up the job |
transform.progress | Periodic update with { percent, stage } for long-running work |
transform.complete | Output is written to storage |
transform.failed | Terminal failure after retries are exhausted |
import { RenClient } from '@maravilla-labs/platform/ren';
const ren = new RenClient();
ren.on('transform.complete', (event) => {
if (event.data.outputKey === expectedPosterKey) {
posterEl.src = `/api/v/${event.data.outputKey}?v=${Date.now()}`;
}
});
Declarative Config
Declare transforms in maravilla.config.ts and Maravilla runs them every time a matching file is uploaded. No handler code to write.
// maravilla.config.ts
import { defineConfig } from '@maravilla-labs/platform';
export default defineConfig({
transforms: {
'uploads/videos/**': {
transcode: [{ format: 'mp4' }, { format: 'webm' }],
thumbnail: { at: '1s', width: 640, format: 'jpg' },
},
'uploads/photos/**': {
variants: [
{ width: 1600, format: 'webp', quality: 85 },
{ width: 400, format: 'webp', quality: 80 },
],
},
'uploads/scans/**': {
ocr: { lang: 'eng' },
},
},
});
The variants shorthand is sugar for multiple resize calls — use it when you want several sizes of the same image (thumbnail + hero + full).
Reacting to Uploads with onStorage
When you need custom logic on upload — conditional branching, enqueuing a workflow, posting to an external API — drop the declarative config and write an Event Handler instead:
// events/on-video-upload.ts
import { onStorage } from '@maravilla-labs/platform/events';
export const processVideo = onStorage(
{ keyPattern: 'uploads/videos/*', op: 'put' },
async ({ key, platform }) => {
const meta = await platform.media.transforms.probe(key);
if (meta.durationSecs && meta.durationSecs > 15) {
await platform.kv.invites.put(key, { rejected: 'too_long' });
return;
}
await Promise.all([
platform.media.transforms.transcode(key, { format: 'mp4' }),
platform.media.transforms.thumbnail(key, { at: '1s', width: 640, format: 'jpg' }),
]);
},
);
The same pattern works for documents — every uploaded contract auto-renders a PDF preview and a thumbnail:
// events/on-contract-upload.ts
import { onStorage } from '@maravilla-labs/platform/events';
export const processContract = onStorage(
{ keyPattern: 'uploads/contracts/*.docx', op: 'put' },
async ({ key, platform }) => {
await Promise.all([
platform.media.transforms.docToPdf(key),
platform.media.transforms.docThumbnail(key, { page: 1, width: 480, format: 'webp' }),
]);
},
);
Next Steps
- Storage — where source and derived files live
- Event Handlers — the full
onStoragetrigger surface - Real-Time Events — subscribe to
transform.*events in the browser - Platform Services Overview — how the pieces fit together