Media Transforms

Media Transforms turn any file the user uploads — or any URL on the open web, or any route from your own deployment — 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, extract text from a scanned PDF, convert a Word doc to PDF, fill a .docx template with the user’s logo, screenshot a public webpage, render an OG image from your own /og/posts/[slug] route, or produce a deterministic MP4 from an HTML composition with animations. Your code asks for the transform; the platform queues, runs, and stores the result. In development, the CLI processes everything locally. In production, Maravilla Cloud handles it. 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.

Transcode browser & webcam recordings before you seek into them. Video captured in the browser (MediaRecorder) or by some screen/webcam tools is variable-frame-rate — its frames are spaced unevenly in time, so the clip doesn’t seek to an arbitrary timestamp reliably and can stall partway through continuous playback. Transcoding rewrites it to an evenly-paced, constant-frame-rate clip that seeks frame-accurately. This is a hard prerequisite for extractFrames and for driving a Frames Render from an uploaded clip — both step through the source frame by frame, so feed them a transcoded clip, never the raw recording.

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.

extractFrames(srcKey, opts)

Decode a video into a sequence of still images at a fixed frame rate, written as a numbered frame set plus a small JSON manifest. Use it for scrubbable filmstrips, frame-accurate thumbnails, or to drive a Frames Render composition that plays back an uploaded clip.

Give it a seekable source. Frame extraction steps through the clip at a fixed rate, so a variable-frame-rate recording (anything from MediaRecorder / a browser or webcam capture) can yield an uneven or short frame set. transcode it to a constant-frame-rate mp4 first — see the note there.

Parameters:

  • srcKey (string) — storage key of the source video
  • opts (object):
    • fps (number) — frames per second to sample the source at
    • width (number, optional) — max width; aspect ratio preserved, no upscaling
    • format ('jpg' | 'png' | 'webp', default: 'jpg') — frame image format
    • quality (number, optional) — 1–100 quality hint for jpg / webp
    • seek_ms (number, optional) — start offset into the clip, in ms
    • duration_ms (number, optional) — how much of the clip to extract from seek_ms, in ms
    • detach_audio (object, optional) — also split the source’s audio into a standalone track in the same job (see below)

job.outputKey is the JSON manifest; the individual frame images live alongside it.

Detach the audio in the same call. A recorded clip — a camera or screen capture — carries its picture and sound muxed together. When you want both as separate, independently-editable assets, pass detach_audio and a single call produces the frame set and the audio track, with no second round-trip:

  • target (string) — storage key to write the extracted audio (AAC / m4a) to
  • bitrate_kbps (number, optional) — audio bitrate
  • seek_ms / duration_ms (number, optional) — same windowing as the frame extraction
const job = await platform.media.transforms.extractFrames('uploads/clips/take.mp4', {
  fps: 30,
  width: 480,
  detach_audio: { target: 'uploads/clips/take.audio.m4a' },
});
// Frame manifest at job.outputKey; the detached audio at uploads/clips/take.audio.m4a —
// ready to drop onto a frames timeline as an audio track (below).

extractAudio(srcKey, opts?)

Split a video’s audio track off into a standalone AAC / m4a file. Use it to detach the mic or system audio recorded alongside a clip so it can be placed, gained, faded, and ducked independently on a frames timeline. When you’re already extracting frames from the same upload, prefer extractFrames’s detach_audio option above — it does both in one job.

Parameters:

  • srcKey (string) — storage key of the source video
  • opts (object, optional):
    • bitrate_kbps (number, optional) — audio bitrate
    • seek_ms (number, optional) — start offset into the source, in ms
    • duration_ms (number, optional) — how much of the source to extract, in ms
const job = await platform.media.transforms.extractAudio('uploads/clips/take.mp4');
// The standalone audio lands at job.outputKey once complete.

Document Templating

The headline use case for documents: take a .docx / .odt / .pptx template, fill in a customer name, swap in their logo, and embed a backlink QR code — render the result as a PDF. One API call. Conceptually similar to Word’s mail merge, but driven from your server with everything (text, images, QR codes) in a single render.

docTemplateMerge(srcKey, opts)

The unified template-fill: text + images + QR codes in one call. Use this whenever a render mixes more than one substitution kind. (docReplaceImages and docInsertQrCode below remain available for the narrower single-kind cases.)

Parameters:

  • srcKey (string) — storage key of the template document
  • opts (object):
    • data (object, optional) — text-tag → string, e.g. { '{{NAME}}': 'Acme Corp' }. Tags are matched verbatim; missing tags are silently skipped (idempotent).
    • images (object, optional) — text-tag → image, e.g. { '{{LOGO}}': { srcKey: 'images/logo.png' } }.
    • namedObjects (object, optional) — object-name → image. Matches by the drawing object’s Name property (set in Word/Writer via Format → Anchor → Properties → Name) and preserves the template’s frame size, border, anchor, and wrap.
    • qrCodes (object, optional) — text-tag → { payload: string, size?: number }. The platform generates each QR PNG and injects it via the same placeholder pipeline as images. payload ≤ 1500 bytes; size defaults to 256 px.
    • outputFormat ('pdf' | 'docx' | 'odt' | 'xlsx' | 'html' | 'txt' | 'rtf', optional) — output format; defaults to keeping the input format. Render to PDF if you don’t want the user receiving an editable copy of your template.
    • autoResize (boolean, default: true) — pre-fit each image (and generated QR) to its target frame’s dimensions, so a 4K user upload doesn’t blow up render memory.
const job = await platform.media.transforms.docTemplateMerge('templates/invoice.docx', {
  data: {
    '{{CUSTOMER_NAME}}': 'Acme Corp',
    '{{INVOICE_ID}}':    '#2026-0142',
    '{{TOTAL}}':         '€ 1,234.56',
  },
  images: {
    '{{CUSTOMER_LOGO}}': { srcKey: 'customers/acme/logo.png' },
  },
  namedObjects: {
    'CompanyLogo': { srcKey: 'brand/maravilla-logo.png' },
  },
  qrCodes: {
    '{{PAYMENT_QR}}': { payload: 'upi://pay?pa=acct@bank&am=1234.56', size: 384 },
  },
  outputFormat: 'pdf',
});
// `job.outputKey` is the PDF — render an iframe with `/api/v/${job.outputKey}` once `transform.complete` fires.

The doc is rendered in one server-side pass — text replacement first, then images, then named-object swaps, then QR injection — same outcome as composing docReplaceImages + docInsertQrCode would give, ~3× faster.

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)

Image-only template fill. For mixed renders (text + images + QR), use docTemplateMerge — it does this job in one pass instead of two.

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)

QR-only template fill. For mixed renders (text + images + QR), use docTemplateMerge — it does this job in one pass.

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',
});

Browser Capture

Capture any public webpage — or any route from your own deployment — as a PNG, JPEG, WebP, or PDF. Two modes share the same surface:

  • url mode captures any public webpage. Internal addresses are blocked so a tenant script can’t reach private services.
  • path mode captures a route on your own deployment. Your auth, SSR, and dynamic data work exactly as in a normal browser visit — your page is rendered the same way a user would see it.

path mode is the recommended way to render content you already build in your app: OG images, dynamic social cards, email previews, dashboard exports. Everything you already render in production becomes capturable without duplicating templates.

BROWSER.screenshot(opts)

Capture a webpage as an image.

Parameters:

  • opts (object) — provide exactly one of url or path:
    • url (string) — a fully-qualified public URL (https://example.com/foo)
    • path (string) — a path on the tenant’s own deployment (/og/posts/hello)
    • viewport (object, optional) — { width, height } in CSS pixels; defaults to 1280×800
    • wait_for (object, optional) — wait for one of { selector: 'main' }, { network_idle: true }, or { ms: 500 } before snapping
    • full_page (boolean, default: false) — capture the entire scroll height, not just the viewport
    • format ('png' | 'jpeg' | 'webp', default: 'png') — output format (webp falls back to png on builds where Chromium lacks webp support)
    • quality (number, optional) — 1–100 quality hint for jpeg / webp
    • cookies (array, optional) — [{ name, value, domain? }] set before navigation
    • headers (object, optional) — extra request headers
    • target (string) — storage key the image is written to (required)

Returns: JobHandle{ id, outputKey, status } (poll via job(id)).

// Screenshot a public URL
const shot = await platform.env.BROWSER.screenshot({
  url: 'https://example.com',
  viewport: { width: 1440, height: 900 },
  wait_for: { network_idle: true },
  format: 'png',
  target: 'shots/example.png',
});
// Render an OG image from your own deployment
const og = await platform.env.BROWSER.screenshot({
  path: `/og/posts/${slug}`,
  viewport: { width: 1200, height: 630 },
  format: 'png',
  target: `og/${slug}.png`,
});
// Serve at /api/v/og/${slug}.png once transform.complete fires.

BROWSER.pdf(opts)

Render a webpage as a PDF. Same url / path modes as screenshot.

Parameters:

  • opts (object) — provide exactly one of url or path:
    • url (string) or path (string) — same as screenshot
    • format ('A4' | 'A3' | 'Letter' | 'Legal' | 'Tabloid', optional) — page size; defaults to A4
    • landscape (boolean, default: false)
    • print_background (boolean, default: true) — include CSS backgrounds and images
    • margin (object, optional) — page margins in inches: { top, right, bottom, left } (each optional). Omit a side for Chromium’s default (~0.4in); set all to 0 when the document supplies its own margins.
    • prefer_css_page_size (boolean, optional) — use the document’s CSS @page size instead of format.
    • wait_for (object, optional) — wait before printing: { selector }, { network_idle: true }, or { ms }. Use a selector to wait for client-side rendering (charts, a paged.js layout) to finish.
    • display_header_footer (boolean, optional) — render a repeating header/footer on every page.
    • header_template / footer_template (string, optional) — HTML for the header/footer (inline styles only; set an explicit font-size). The spans .pageNumber, .totalPages, .title, .date, .url are filled in by Chromium — this is how you get “Page n of n”.
    • target (string) — storage key the PDF is written to (required)
// Invoice with a repeating footer and page numbers
const invoice = await platform.env.BROWSER.pdf({
  path: `/invoices/${invoiceId}/print`,
  format: 'A4',
  print_background: true,
  display_header_footer: true,
  header_template: '<span></span>',
  footer_template:
    '<div style="font-size:8px;width:100%;text-align:right;padding:0 18mm;color:#94a3b8;font-family:sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
  margin: { top: 0.55, bottom: 0.5, left: 0, right: 0 },
  target: `invoices/${invoiceId}.pdf`,
});

Multi-page layouts (paged.js). For richer paged media — running headers/footers, a table of contents, CSS @page margin boxes — render the document with paged.js client-side (in the page you point path at) and print it from the browser, or capture it server-side here. When capturing server-side, run paged.js to completion first (signal a selector and pass it via wait_for), then print with prefer_css_page_size: true and margin: { top: 0, right: 0, bottom: 0, left: 0 } so paged.js’s pages are emitted 1:1. For a simple repeating header/footer + page numbers, the native display_header_footer + *_template options above are simpler and need no extra dependency.

Frames Render

Produce a deterministic MP4 (or WebM) from a route on your deployment. Same input → same bytes out, every time — render twice and the files are byte-identical.

Use it for animated title cards, narrated explainers, social-share teasers, demo loops — anything you’d normally need a video-editing pipeline for. Because the composition is a route in your own app, everything you already have works: SSR, auth, dynamic data, designed components, your CI tests.

FRAMES.render(opts)

Parameters:

  • opts (object):
    • path (string) — relative path on the tenant’s deployment (required)
    • width, height (number) — frame dimensions in pixels (required)
    • fps (number) — frames per second; 1120
    • duration_ms (number) — total clip duration in milliseconds
    • codec ('h264' | 'vp9', default: 'h264') — video codec (vp9 produces WebM with Opus audio; h264 produces MP4 with AAC)
    • crf (number, optional) — constant rate factor; lower = higher quality. Defaults: 23 for h264, 31 for vp9
    • audio (string, optional) — storage key of a single pre-mixed audio file to mix in; the output runs as long as the shorter of the video or the audio. For multiple, individually-placed tracks (voiceover + music + sfx, with automatic ducking), declare them on the timeline instead — see Audio in the timeline
    • target (string) — storage key the MP4/WebM is written to (required)
// Render the route at /render/title-card as a 4-second 1080p30 MP4 with narration
const job = await platform.env.FRAMES.render({
  path: '/render/title-card?headline=Hello',
  width: 1920,
  height: 1080,
  fps: 30,
  duration_ms: 4000,
  codec: 'h264',
  crf: 20,
  audio: 'audio/narration.mp3',
  target: 'reels/title-card.mp4',
});
// Serve at /api/v/reels/title-card.mp4 once transform.complete fires.

The @maravilla-labs/frames Helper

A tiny vanilla-JS helper (no runtime dependencies) you import on the page being rendered. Declare a TimelineSchema and the renderer drives the animation deterministically by stepping the Web Animations API frame by frame. In a regular browser visit, the timeline auto-plays — your render route stays directly testable as a normal webpage.

Each entry has an at (when on the timeline it starts, in ms) and an optional duration (how long it runs). Entries with different at values are staggered; entries with the same at start together. Use kind: 'video' to schedule a <video> element instead of an animation, or kind: 'css' to drive a CSS-class-based animation from a library like Animate.css.

// src/routes/render/title-card/+page.svelte (or any framework's equivalent)
import { defineTimeline } from '@maravilla-labs/frames';
import { onMount } from 'svelte';

onMount(() => {
  defineTimeline({
    duration: 6000,
    instructions: [
      // Title flies in over the first 500 ms.
      {
        at: 0,
        duration: 500,
        selector: 'h1',
        props: [
          { opacity: 0, transform: 'translateY(20px)' },
          { opacity: 1, transform: 'translateY(0)' },
        ],
      },
      // Subtitle fades in 300 ms later.
      {
        at: 300,
        duration: 600,
        selector: '.subtitle',
        props: [{ opacity: 0 }, { opacity: 1 }],
      },
      // An Animate.css class bounces a "Premiere" pill in at t=1.4 s.
      // The helper captures the CSS animation as a WAAPI Animation and
      // drives it frame by frame for deterministic rendering.
      {
        kind: 'css',
        at: 1400,
        duration: 1000,
        selector: '.badge',
        classes: ['animate__animated', 'animate__bounceInDown'],
      },
      // A clip plays from t=2 s to t=6 s — 4 seconds of video.
      {
        kind: 'video',
        at: 2000,
        duration: 4000,
        selector: 'video.intro',
      },
    ],
  });
});

The CSS-class instruction works with any library that ships its motion as CSS @keyframes triggered by a class name — Animate.css, Magic, your own custom keyframes. Make sure the stylesheet that defines the keyframes is loaded before defineTimeline runs (the simplest pattern is await new Promise(r => addEventListener('load', r, { once: true })) at the top of a <script type="module">).

Using Animate.css

A complete minimal example — load the stylesheet, pre-hide the element, declare the instruction:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" />

<style>
  .badge {
    visibility: hidden;  /* pre-hide until the timeline fires */
  }
</style>

<span class="badge">Premiere</span>

<script type="module">
  import { defineTimeline } from '@maravilla-labs/frames';

  // Wait for the stylesheet to finish loading before the helper runs
  await new Promise(r => addEventListener('load', r, { once: true }));

  defineTimeline({
    duration: 3000,
    instructions: [
      {
        kind: 'css',
        at: 600,
        duration: 1000,
        selector: '.badge',
        classes: ['animate__animated', 'animate__bounceInDown'],
      },
    ],
  });
</script>

The helper flips visibility: visible automatically when the instruction fires, then drives the animation through to completion.

The pre-hide gotcha

Always pre-hide with visibility: hidden, never with opacity: 0. Animate.css’s “In” keyframes (bounceInDown, fadeInUp, zoomInLeft, all of them) define opacity at the start and middle of the animation, but not at 100%. CSS fills any missing keyframe value with the element’s underlying baseline — so an opacity: 0 baseline silently gets pulled in as the animation’s 100% value, and the element fades right back out as soon as it lands. The result: you see the bounce, then the element vanishes. Use visibility: hidden instead — it isn’t touched by any Animate.css keyframe, so it stays cleanly out of the way until the helper flips it on.

If the same animation also needs prefers-reduced-motion to be respected (Animate.css collapses to 1 ms by default under that media query), omit duration on the instruction. If you want the timeline to run regardless — typical for video-render compositions — pass a duration and the helper writes it inline with !important so the media query can’t shrink it.

The same declaration drives both your live page (each item plays at its scheduled time) and the renderer (it steps through the timeline frame by frame), so the rendered video matches exactly what users see in a browser. Bring your own animation library if you want — GSAP, Lottie, Three.js all work alongside the helper. Vanilla CSS or the Web Animations API is enough if you don’t.

Keeping animations smooth

The renderer captures frames one at a time so it doesn’t care about runtime performance — every frame gets as long as it needs. But while you’re designing the composition in a real browser (and especially while previewing inside a CSS-scaled container like a preview iframe, a popup, or a thumbnail), animations can stutter even though the same page runs smoothly when opened on its own. Two quick rules avoid it:

Tell the browser what’s about to change. Add will-change to any element your timeline animates. The browser promotes that element to its own GPU compositor layer; subsequent frames apply opacity / transform / filter changes as cheap GPU operations instead of re-rasterizing the parent every frame.

.title,
.tagline,
.meta {
  will-change: opacity, transform, filter;
  transform: translateZ(0); /* nudge older Safari onto a GPU layer */
}

<video> elements get the same treatment when they sit inside a scaled or transformed container:

.composition video {
  will-change: transform;
}

Animate the cheap properties. transform, opacity, and filter composite on the GPU. width, height, top, left, margin, padding trigger layout — every animated frame recomputes the entire page flow. Slide an element with transform: translateX(...) rather than left: or margin-left:. Scale with transform: scale(...) rather than changing width/height.

Filter effects like blur(), drop-shadow(), and backdrop-filter are particularly worth promoting — without will-change the blur kernel runs over the full parent surface every frame.

To check whether a given element is on its own GPU layer, open Chrome DevTools → Rendering → enable “Layer borders”. Yellow borders mark elements that the browser is compositing on the GPU. No border means the browser is still drawing them in pixels every frame.

Audio in the timeline

Beyond visuals, the helper schedules time-bound audio tracks right in the same timeline — a voiceover, a background-music bed, and one-shot sound effects, each placed at its own start time. The tracks are mixed into the exported video and played back in the browser preview by a built-in Web Audio engine, so what you hear while designing matches the final file.

import { defineTimeline } from '@maravilla-labs/frames';

defineTimeline({
  duration: 12000,
  instructions: [
    // ...your visual instructions...

    // A music bed under the whole piece, fading in and out.
    {
      kind: 'audio',
      at: 0,
      duration: 12000,
      role: 'music',
      src: '/audio/bed.m4a',   // URL the browser preview fetches + decodes
      key: 'audio/bed.m4a',    // storage key the renderer mixes into the export
      fadeIn: 400,
      fadeOut: 800,
    },
    // A voiceover starting at 3 s — the music bed automatically ducks beneath it.
    {
      kind: 'audio',
      at: 3000,
      role: 'voiceover',
      src: '/audio/vo.m4a',
      key: 'audio/vo.m4a',
    },
  ],
});

Audio fields:

  • at / duration / seek (number, ms) — placement on the timeline and the window into the source file, exactly like a kind: 'video' instruction
  • src (string) — a URL the browser preview fetches and decodes
  • key (string, optional) — the storage key the renderer mixes into the exported video; omit for preview-only tracks
  • role ('voiceover' | 'music' | 'sfx', default 'music')
  • gain (number, default 1) — linear volume multiplier
  • fadeIn / fadeOut (number, ms) — fade durations
  • duck (boolean) — whether a music track dips under voiceover; defaults to true for role: 'music'

Ducking lowers the music under the voice automatically. The dip is a fixed envelope computed from where the voiceover sits on the timeline, not from its loudness — so it’s deterministic, and the preview reproduces the exact same dip the export bakes in.

During continuous preview playback the tracks are audible; scrubbing the playhead stays silent, the way a video editor behaves. The detached audio you get from extractAudio — or from extractFrames’s detach_audio — drops straight in as a track’s key.

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