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
- 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.
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 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.
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.transcodeit to a constant-frame-rate mp4 first — see the note there.
Parameters:
srcKey(string) — storage key of the source videoopts(object):fps(number) — frames per second to sample the source atwidth(number, optional) — max width; aspect ratio preserved, no upscalingformat('jpg' | 'png' | 'webp', default:'jpg') — frame image formatquality(number, optional) — 1–100 quality hint for jpg / webpseek_ms(number, optional) — start offset into the clip, in msduration_ms(number, optional) — how much of the clip to extract fromseek_ms, in msdetach_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) tobitrate_kbps(number, optional) — audio bitrateseek_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 videoopts(object, optional):bitrate_kbps(number, optional) — audio bitrateseek_ms(number, optional) — start offset into the source, in msduration_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 documentopts(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’sNameproperty (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;sizedefaults 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 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)
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
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)
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 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',
});
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:
urlmode captures any public webpage. Internal addresses are blocked so a tenant script can’t reach private services.pathmode 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 ofurlorpath: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×800wait_for(object, optional) — wait for one of{ selector: 'main' },{ network_idle: true }, or{ ms: 500 }before snappingfull_page(boolean, default:false) — capture the entire scroll height, not just the viewportformat('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 / webpcookies(array, optional) —[{ name, value, domain? }]set before navigationheaders(object, optional) — extra request headerstarget(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 ofurlorpath:url(string) orpath(string) — same asscreenshotformat('A4' | 'A3' | 'Letter' | 'Legal' | 'Tabloid', optional) — page size; defaults toA4landscape(boolean, default:false)print_background(boolean, default:true) — include CSS backgrounds and imagesmargin(object, optional) — page margins in inches:{ top, right, bottom, left }(each optional). Omit a side for Chromium’s default (~0.4in); set all to0when the document supplies its own margins.prefer_css_page_size(boolean, optional) — use the document’s CSS@pagesize instead offormat.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 explicitfont-size). The spans.pageNumber,.totalPages,.title,.date,.urlare 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;1–120duration_ms(number) — total clip duration in millisecondscodec('h264' | 'vp9', default:'h264') — video codec (vp9produces WebM with Opus audio;h264produces MP4 with AAC)crf(number, optional) — constant rate factor; lower = higher quality. Defaults:23for h264,31for vp9audio(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 timelinetarget(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 akind: 'video'instructionsrc(string) — a URL the browser preview fetches and decodeskey(string, optional) — the storage key the renderer mixes into the exported video; omit for preview-only tracksrole('voiceover' | 'music' | 'sfx', default'music')gain(number, default1) — linear volume multiplierfadeIn/fadeOut(number, ms) — fade durationsduck(boolean) — whether a music track dips under voiceover; defaults totrueforrole: '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.
| 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