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

  1. Your server (or a handler running in response to an upload) calls a transform method like transcode or thumbnail. The call returns immediately with a JobHandle — no blocking on a multi-minute encode.
  2. 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.
  3. 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.
  4. 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 video
  • opts (object):
    • format ('mp4' | 'webm') — target container
    • codec (string, optional) — specific video codec (e.g. 'h264', 'vp9')
    • maxWidth (number, optional) — cap on output width; aspect ratio preserved
    • maxHeight (number, optional) — cap on output height
    • audioCodec (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 video
  • opts (object):
    • at (string) — offset into the source. Accepts '1s', '00:00:01', or a plain number of seconds
    • width (number, optional) — output width in pixels
    • height (number, optional) — output height in pixels
    • format ('jpg' | 'png' | 'webp', default: 'jpg') — output format
    • quality (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 image
  • opts (object):
    • width (number, optional) — target width; omit to let height drive aspect
    • height (number, optional) — target height; omit to let width drive aspect
    • format ('jpg' | 'png' | 'webp') — output format
    • quality (number, optional) — 1–100 quality hint
    • stripMetadata (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 PDF
  • opts (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 document
  • opts (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 document
  • opts (object):
    • page (number, default: 1) — 1-indexed page number to render
    • width (number, optional) — output width in pixels (height preserves aspect)
    • height (number, optional) — output height in pixels
    • format ('png' | 'jpg' | 'webp', default: 'png') — output format
    • quality (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 document
  • opts (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 document
  • opts (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 document
  • opts (object, optional):
    • inlineImages (boolean, default: true) — embed <img> references as base64 data: URIs
    • inlineStyles (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 Name property (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 document
  • opts (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 format
    • autoResize (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 document
  • opts (object):
    • codes (array) — one entry per QR to embed:
      • placeholder (string) OR namedObject (string) — exactly one
      • payload (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.

EventFires when
transform.queuedJob has been accepted; outputKey is already known
transform.startedA worker picked up the job
transform.progressPeriodic update with { percent, stage } for long-running work
transform.completeOutput is written to storage
transform.failedTerminal 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