Case study · Building in public

MotionStudio.
A real video editor, right in your browser.

2026·Personal project · solo buildLive · Actively Building

Think of it as Canva for programmatic video. You drop text, images, video, audio and animated backgrounds onto a canvas, arrange them on a timeline, animate them, and export a real MP4 — all without leaving the browser. It's built on Remotion, so what you see while editing is what actually renders. This page is my living build journal: what it does, the decisions behind it, and the bugs that taught me something.

In-browser
A full video editor — no install, no server needed to create
6
Element types — text, image, video, audio, shaders, blocks
34 + 18
Text effects and animated shader backgrounds
2 ways
To export — free in your browser, or full-fidelity on the cloud
See it
The editor — assets panel, canvas, live properties, and a frame-accurate timeline, with text animation presets (Fade / Slide / Pop) on the right
The editor — assets panel, canvas, live properties, and a frame-accurate timeline, with text animation presets (Fade / Slide / Pop) on the right · click to zoom
What you can actually do

A full editor, no install required.

🎨 Compose on a canvas

Drag, resize, rotate and edit text, images, video and audio right on the frame — the way you'd expect any editor to work.

Animate anything

Keyframe opacity, position, scale and rotation, or grab one of 34 ready-made text effects and 18 animated backgrounds. Every one previews live before you commit.

Start from a template

21 ready-made templates — announcements, product demos, hooks, title cards. Pick one and you've got an animated video in three clicks instead of twenty.

🎵 Cut in time with music

Drop in a track and it finds the beat, then your shots snap to it — so cuts land on the music instead of you eyeballing it.

🎬 Two ways to export

Render for free right in your browser, or send it to the cloud for full-fidelity 1080p from any device — even your phone.

☁️ Sign in & pick up anywhere

Google, email, or jump in as a guest. Signed-in projects auto-save to the cloud and come back on any device you log into.

🧭 It explains itself

A hands-on quick start walks you through your first video, and a hover-anywhere helper mode explains any control — without ever blocking a click.

Remotion

Why Remotion is the core bet

The whole product hinges on one thing: it renders real video. Remotion gives me frames as a first-class unit, <Sequence> for laying things out in time, <Player> for the in-app preview, and a real render pipeline — building a renderer and encoder from scratch would be months of work that teaches nothing about this product. So I bought that, and spent my time on everything built on top of it:

Frames, not seconds — every element carries a `startFrame` and `durationInFrames`, mapped 1:1 onto `<Sequence from={startFrame}>`. Frames because Remotion is frame-based and frames are exact — no floating-point drift creeping into your timing.

One renderer for the preview and the cloud — the editor canvas renders the *same* `MotionComposition` through a Remotion `<Player>`, with drag/resize as a transparent overlay on top. The editor preview and the AWS Lambda render run the identical component tree, so what you see is genuinely what the cloud renders. (The free in-browser export is a separate, faster path — see below.)

Effects bought as source, not built — 34 Remocn text-effect components (per-character rise, typewriter, glitch, shimmer, counting numbers…), 18 frame-synced WebGL shader backgrounds, and 4 structured UI blocks (terminal, code editor, progress steps, confetti) as a 6th element type. Each is vendored into the repo and lazy-loaded per preset, so I own the code but didn't write the polish.

Animation on Remotion's primitives — `interpolate` with `extrapolate: 'clamp'` so animations *finish* instead of running off to infinity, and `spring` for real physics (it needs `fps`, because a bounce happens in real time). Stack several on one element and they accumulate — scale factors multiply, offsets add — the same algebra a compositor uses.

DOM video for scrubbing, Remotion for the truth — the editor previews with a plain `<video>` (seek on scrub for exact frames, play natively during playback, muted for reliable autoplay); the export uses `<OffthreadVideo>`, which is authoritative.

Export

The export pivot — from a CLI command to two real paths

The first version of export was a Remotion CLI command the app generated for you. It worked — if you were a developer happy to run a terminal command. For an actual end user, unusable. That gap is the real story: the missing piece wasn't a feature, it was a way for normal people to get a finished video out. So it became two paths, each with an honest trade-off.

Browser export (WebCodecs)

Free · unlimited · Chrome/Edge

Renders entirely inside your browser — no server, no terminal. Each frame is drawn to an off-screen canvas, audio is mixed sample-exact with OfflineAudioContext, and the browser's hardware encoder (WebCodecs / H.264) compresses it, muxed to MP4 with Mediabunny and downloaded straight to your device. Zero infrastructure cost. There's a beta 'Include effects' mode that renders the real composition so text effects, shaders and blocks survive — it's ticked automatically when your project needs it.

Free, instant, private, and it works offline. The plain path is fast and dependable; the beta effects path closes most of the fidelity gap without a round-trip to a server.

Limits: Chrome and Edge only (WebCodecs isn't in Safari yet). The fast path draws to a 2D canvas, so it drops the 34 text effects, animated backgrounds and blocks unless you turn on the slower 'Include effects' mode — and the dialog warns you when your project needs it.

Cloud render (Remotion Lambda)

Full fidelity · any device · 1080p · AWS Lambda

Click Render in the Cloud tab and a Vercel function verifies your login, checks your monthly quota in Supabase, and kicks off a Remotion Lambda render on AWS. It returns immediately with a render ID; the browser polls for progress, so you get a real percentage instead of a spinner, and the render is never silently capped by a serverless timeout. Media you imported is uploaded to S3 in the background, so Lambda has real URLs to fetch. Out comes a 1080p MP4.

This is the path that just works — on every device, including Safari and phones, and with every effect, shader and block intact because it runs the exact same composition as the editor. The render happens on dedicated infrastructure, not your laptop.

Limits: Quota-based — Lambda costs real money, so guests get 1 free render and signed-in users get a monthly allowance.

The honest bit

The honest bit: 'what you see is what you get' holds for the editor preview and the cloud render — they're the same component tree. It does not fully hold for the fast browser export, which paints to a 2D canvas and can't draw React-rendered effects. Rather than pretend otherwise, the export dialog inspects your project and warns you, and the beta effects path exists to close the gap. Being upfront about the seam beat quietly shipping a broken file.

Export Dialog
Browser tab · Cloud Render tab
Browser Export
Frame Renderer
shared style.ts · same as editor preview

Each frame is painted to an off-screen canvas using the exact same function the editor preview uses. WYSIWYG is a mathematical consequence, not a design goal.

VideoEncoder — WebCodecs
GPU-accelerated H.264 · frame-by-frame

Each frame's ImageData is pushed into a VideoEncoder. WebCodecs uses the device's hardware encoder — no plugin, no server, no round-trip.

OfflineAudioContext
Web Audio API · faster than real-time

All audio tracks are mixed into a precise AudioBuffer — no timing drift, no desync risk from real-time mixing.

Mediabunny — MP4 Mux
video chunks + AudioBuffer → MP4 container

Encoded chunks and audio are muxed into an MP4. The finished blob downloads directly to the user's device. No server.

MP4 downloaded
Free · Unlimited · No server Chrome & Edge only · GPU-accelerated
Cloud Render
Vercel Serverless
/api/render · Node.js

Client posts project state + device ID. Four gates run in order — nothing reaches Lambda until all pass.

4-Gate Auth Guard
JWT → device → quota → Lambda
1JWT verified via Supabase admin
2Device cookie checked — anon only
3Monthly render count vs. quota
4Lambda called — only if all pass
renderMediaOnLambda()
@remotion/lambda-client · 2 GB · 120 s

Pre-deployed Lambda renders the same MotionComposition in headless Node.js — same composition, AWS infrastructure.

S3 — MP4 written
Remotion bucket · signed URL returned

Lambda writes the MP4 to S3. The Vercel function polls getRenderProgress() until done, then returns the URL to the client.

Download URL returned
Any device · Any browser · Quota-based Authenticated · 1080p · No local CPU

Both paths render the same MotionComposition component — the browser encodes frames locally, Lambda renders them in headless Node.js.

Auth & infrastructure

Auth, quota, and keeping guests honest

Three ways in — Google, email/password, or anonymous guest with no sign-up at all. All of it lives behind a single useAuth hook, so no UI component ever touches the Supabase client directly. The interesting engineering isn't the login button; it's making a free cloud render abuse-resistant and keeping people's projects from bleeding into each other on a shared computer.

Keeping guests honest

Guests get 1 free cloud render — and the obvious cheat is to clear localStorage and get another. So the free slot is tracked by a **device ID cookie** (a `crypto.randomUUID()` in a 1-year cookie) that survives a localStorage wipe. The slot is only spent **after a confirmed successful render**, so a failed or abandoned render never burns your one free try.

The API layer

Four small Vercel functions sit between the browser and AWS — `/api/render`, `/api/quota`, `/api/upload-url`, `/api/contact`. Every render runs a **4-gate guard, strictly in order**: verify the login → check the device (guests only) → check the monthly quota → *then* start Lambda. And because a render can outlast a serverless request, `/api/render` returns a render ID immediately and the client polls a status endpoint — which is what removed the silent ~60s timeout cap and gave the progress bar real numbers.

Account isolation

Projects live locally in IndexedDB. On any auth change, a tiny non-rendering AuthBridge compares the new user to the last one and, if it's different (or a sign-out), wipes the local store and IndexedDB. User B never opens their laptop to find User A's projects sitting there.

Tech stack — and why each
Remotion

Frames, <Sequence>, <Player>, and real Lambda rendering — the video engine, and the one dependency the whole product is built around.

React 19 + TypeScript (strict)

Elements are a discriminated union, so adding a new element type is one type plus one renderer — the compiler tells you everything you forgot.

Zustand

Global state with zero boilerplate: one create() gives a hook and selectors. No providers, no reducers.

React Router v7

/ is the dashboard, /editor/:projectId is the editor — the URL is the single input that picks a project.

react-moveable

Drag/resize/rotate handles are a solved problem; rebuilding them is weeks of hit-testing math that teaches nothing about this product.

Remocn + shaders-react

34 copy-paste Remotion text effects, 18 WebGL shader backgrounds and 4 UI blocks — animation polish vendored as source and lazy-loaded per preset, not built from scratch.

Tailwind v4 + shadcn/ui

Fast, consistent dark UI from design tokens, plus accessible primitives (Dialog, Popover, Select) I didn't have to reinvent.

IndexedDB + localStorage

Local persistence split by shape: small JSON state in localStorage, large media blobs in IndexedDB (which is built for Blobs).

Supabase

Auth (Google / email / guest) + Postgres with row-level security for render quotas and per-user project sync (a JSONB row, 2s-debounced) — no auth server to build or run.

Vercel Functions

Four Node endpoints (render, quota, upload-url, contact) deployed alongside the SPA — zero standing infrastructure for that footprint.

AWS Lambda + S3

The cloud render path: headless render on Lambda, output and media stored in S3. A cloud pipeline for ~20 minutes of config instead of months of infra.

Pexels (stock)

Search and import free stock photos/video straight into a project — proxied through a server function, auth-gated.

PostHog + Vercel Analytics

Product analytics and unhandled-exception capture tagged with the build SHA. Signed-in users are identified by their stable Supabase UUID (not email, which can change); guests stay anonymous.

driver.js + Radix Popper

The guided quick start and the hover-anywhere helper mode — spotlighting and positioning that don't require any of the 21 explained controls to know onboarding exists.

Architecture

Two layers, one rule

engines/   own DATA + LOGIC   (no UI)
features/  own UI             (compose engines)

The whole thing rests on one rule: the Project is the aggregate root. One Project object owns all the data — every element, asset and setting. Engines don't keep their own copies; they expose verbs that read and write the one Project through a single mutation point (updateProject). Everything good falls out of that: undo/redo hooks that one point and covers every edit for free, autosave persists the one Project with nothing wired per-feature, and state can't drift because there's only ever one source of truth.

The three data moves — immutability everywhere

add     → [...arr, x]
remove  → arr.filter(x => x.id !== id)
update  → arr.map(x => x.id === id ? { ...x, ...patch } : x)

React and Zustand detect change by reference identity, and undo snapshots have to stay frozen. A single .push() would skip re-renders and quietly corrupt the undo history.

The engines
projectThe projects array (aggregate root) + undo history + persistenceThe only real store
editorEphemeral view state: selection, current frame, playing, zoomDeliberately not persisted
canvasVerbs: addText/Image/Video/Audio/Block, updateElement, removeElement, reorderLayerA hook, not a store
timelinePure frame ↔ pixel mathStateless helpers
animationinterpolate / spring evaluation + presetsPure functions
renderingThe shared style.ts + Remotion MotionCompositionOne renderer, many consumers
assetUpload, metadata probing, blob persistenceReads/writes project.assets
audioBeat detection + the offline audio mix for exportPure analysis + Web Audio plumbing
exportThe browser render loop + frame drawing + muxingThe free in-browser path

Some engines are stores and some are hooks, on purpose. A store owns state (Project, Editor). A hook owns verbs over state it doesn't hold — Canvas and Asset read the active project and write it back through updateProject. Keeping element data on the Project, not scattered in the Canvas engine, is the aggregate-root rule actually enforced in code.

Core systems & decisions

Composition-space coordinates

Coordinates are stored once, in the real output resolution (1920×1080), never in screen pixels — the editor just shows a scaled-down view. That's why the export always matches the editor: every view (editor at ~50%, the render at 100%) just multiplies by its own scale. This one conversion powers canvas dragging, drop-to-canvas, and scrubbing.

data → screen : × scale   (shrink to fit the window)
screen → data : ÷ scale   (grow a drag back to real coords)

Timeline coordinate math

Same idea, on the time axis. Retiming a clip by dragging it is `updateElement(id, { startFrame })` — the exact same verb as dragging it on the canvas, through the exact same door.

pxPerFrame  = trackWidth / totalFrames
frameToX(f) = f × pxPerFrame          (draw a clip / ruler tick)
xToFrame(x) = round(x / pxPerFrame)   (scrub / drag)

Time-based playback clock

Playback advances by real elapsed time × fps, not `currentFrame++` per frame. `requestAnimationFrame` fires at the monitor's rate — 60 or 120Hz, and it drops under load — so counting frames would play 30fps content at 60fps on a 60Hz screen. Measuring wall-clock time keeps the speed correct on any hardware.

Animation engine — accumulate, don't replace

Built on Remotion's interpolate (clamped so animations finish instead of running to infinity) and spring (real physics, which needs fps because a bounce is real-time). The key call: multiple animations on one element accumulate into a single transform — scale factors multiply, position offsets add — the same algebra a compositor uses. That's why Fade In and Slide Up stack cleanly instead of one clobbering the other.

value  = base × Π(scaleFactors) + Σ(offsets)
finish = extrapolate:'clamp'   (no infinite extrapolation)

Persistence, split by data shape

Object URLs die on reload, so I persist the raw bytes and mint a fresh URL each session. localStorage can't hold large binaries; IndexedDB is built for Blobs. Signed in, projects also sync to Supabase: on login the cloud copy is the source of truth, and a 2s-debounced upsert pushes each project as a JSONB row (scoped per user). Because everything is one Project object, cloud sync was one table and about 40 lines.

metadata (JSON, small)  → localStorage (Zustand persist) + Supabase (cloud, per user)
media bytes (binary)    → IndexedDB (local) + S3 (public URL for Lambda)

Undo/redo — snapshots + coalescing

History is snapshots of the projects array. Because edits build new objects immutably, snapshots share the unchanged sub-objects — cheap, no deep copies. Rapid edits within ~500ms coalesce into one step, so a whole drag or a typing burst is a single undo. It was nearly free to build, because every edit already flows through updateProject.

Shots — a label on time, not a box around it

A video is a sequence of shots, and the timeline needs to say so. The obvious model is nested `scene.elements[]` — but I didn't do that. The render contract is a **flat array with absolute startFrames**, and nesting would force every consumer (the exporter, the web renderer, the audio mix, the Lambda site) to flatten first — the code least worth destabilising. So elements stay flat and just gain a `sceneId`, and a shot becomes a labelled span of time. Not one line of the render path changed. The price is that two invariants (an element lies inside its shot; the total equals the sum of shots) are enforced in ~20 lines of pure, tested code instead of falling out of the shape — a deliberate trade, because a render-path regression is the failure mode this codebase has been bitten by most.

Beat detection — infer a grid, don't report the onsets

Cutting to music is the point of shots, and the naive build — detect each onset, draw a tick, snap to it — is wrong. Onset detection is jittery, and a jittery grid is worse than none: a clip lands two frames off and you can't tell if that was you or the tool. So the peaks are only ever *evidence* for two numbers — **BPM and first-beat offset** — and the grid drawn from them is perfectly regular, more accurate than the detections it came from, and survives passages with no kick. Everything's kept in seconds and converted to frames only at the moment of snapping, because a grid stored in frames drifts a couple of frames out by beat 32 — fine in a five-second test, broken in a real edit.

Transitions are just animations

A transition *is* an animation — a zoom punch is `scale 1.18 → 1`, a whip is `x +768 → 0`. The animation evaluator is already called by both the Remotion renderer and the browser export, so materialising a shot's transition as animations on its elements means it renders and exports everywhere with nothing else changing — the same trick that kept shots and beat-snapping cheap. A `source: 'transition'` tag keeps the generated ones out of the Motion panel and lets them be swapped without touching anything hand-made. The cost, accepted on purpose: no true cross-dissolve, because shots never overlap.

One editor per project, across tabs

Both persistence paths serialise the whole projects array, and neither reconciles — so a second tab holding a stale copy silently overwrote the first tab's work, and last-writer-won never told the loser. Merging concurrent edits to a video has no obvious right answer, so I chose a **lock, not a merge**: a per-project claim in localStorage (synchronous, and its `storage` event fires in *other* tabs — exactly who needs to know). Claims expire after 75s (a crashed tab never releases, and Chrome clamps background timers to ~once a minute, so a shorter window evicted every backgrounded tab). The guard sits on `updateProject` — the one chokepoint — plus undo/redo, which restore the whole array.

Blocks — a registry, not another hardcoded union

Text effects and shaders are string unions with a lazy map — fine for 'one component, one string,' but useless for components that take arrays and objects (a terminal's lines, a pipeline's steps). Those became a 6th element type backed by a **registry**: each entry declares its lazy import, defaults, natural length, a field schema the Properties panel renders inputs from, and a `toProps` translator. Adding a block is a registry entry — the renderer and the panel don't change. Two things the registry also owns: composition-scale sizing (a component tuned for someone else's canvas can render at 1.4% of the frame), and enforcing each block's natural length so a too-short clip doesn't cut the animation off.

The app reports its own bugs

A tool people actually use needs a way to tell you when it breaks — so there's an **in-app feedback form** that auto-attaches the build SHA, browser, screen size and (in the editor) the current project's format and contents, and you can read exactly what's attached before you hit send. Every failure surface — a failed export, a failed cloud render, a file that needs re-uploading — carries a **"Report this"** link that opens that form with the problem already described. And a once-per-release **"What's new"** dialog (with a dot on the ? button until you read it) keeps people in the loop without nagging. It's the difference between a demo and something you can hand to a stranger.

Schema migrations that travel with the data

Zustand's persist has a version, but it only covers IndexedDB — a project pushed to Supabase and pulled back down arrives as a bare object with no version anywhere on it, so the cloud path could only run the migration and hope. So the version lives on the Project itself (`schemaVersion`), and `migrateProject()` walks a ladder of steps and stamps the result — even when no step ran, because saying 'this is version 1' is what lets the next migration skip it. It runs at all three entry points (IndexedDB restore, cloud load, template create), and a project from a *newer* build is detected and left untouched rather than stamped backwards.

Bugs that taught me something

Problems faced, and how they fell.

Bug

Every cloud render crashed with a raw "supabaseUrl is required." on every single frame — while the browser app worked fine. The render entry imported a helper from a barrel file that also re-exported the cloud-sync module, which built a Supabase client at module top-level. Remotion's bundler doesn't swap Vite's env syntax, so the URL came through undefined in the Lambda bundle and threw before a frame rendered.

Fix

Made the client a lazy getSupabase() instead of an eager top-level singleton, so importing the module (even transitively, via a barrel) no longer has a side effect. Lesson: a top-level createClient() in any file a render entry can reach is a landmine — the render bundle is not the app bundle.

Bug

Fixing the code didn't fix the render. After the lazy-client fix shipped to Vercel, cloud renders still failed with the identical error.

Fix

The Lambda-executed bundle is a separate artifact in S3 that a Vercel deploy never rebuilds. Added `npm run deploy:lambda-site` and ran it to push the fixed bundle. Any change reachable from the render entry needs that command re-run, or the cloud keeps executing the old bundle while the rest of the app looks fully deployed.

Bug

Every text effect silently rendered in Times New Roman. The vendored Remocn components set `font-family: var(--font-geist-sans), …, sans-serif` — but that variable ships with Remocn's own Next.js setup, not mine, and CSS throws away the entire declaration when a var() is undefined, sans-serif fallback and all.

Fix

Defined the variable once on the composition's root — the one component mounted by both the editor preview and the render, so they stay identical. Found by rendering a frame through the CLI and looking at it: 'no error' is not 'correct.'

Bug

Cloud project sync silently never worked — the table didn't exist. The sync code was written correctly against a projects table that was never actually created in Supabase, and every failure only reached console.error, so the UI showed nothing. A new browser just always looked empty.

Fix

Created the table with RLS and an explicit GRANT (this project's public schema didn't have the default grants). The real lesson took two goes: a console.error on a persistence path is invisible until someone goes looking, so saveProject now returns a result the toolbar actually shows.

Bug

A prop-name mismatch silently ate a color. Every text effect gets a shared { text, fontSize, color, speed } object, but one effect declared its prop as baseColor — so color was passed, matched nothing, and was dropped with no TypeScript error (mismatched props on a spread aren't checked the way an object literal is).

Fix

Renamed the prop to color to match the shared shape every other effect already uses.

Bug

Omit on a discriminated union silently collapses to common fields — updateElement lost content, assetId, and friends with no error.

Fix

An ElementPatch type: the intersection of per-member partials, so every field of every union member is patchable.

Bug

Remotion's <Composition> inferred props as unknown. An interface isn't assignable to Record<string, unknown> — it could be augmented later; a type alias is.

Fix

Changed interface ExportProps to type ExportProps. One keyword, fixed inference.

Bug

contenteditable cursor jumped to the start on every keystroke — React re-rendering the element reset the DOM selection.

Fix

Set initial text via a ref on mount only, then let onInput push to the store without React re-writing the node.

Bug

react-moveable under a scaled canvas: element coords are composition-space but the stage is scaled, so handles and elements disagreed.

Fix

Drive drag/resize with client-pixel deltas ÷ scale, committing composition coords on release.

Bug

Layer reorder did nothing. The first attempt shuffled array order — but stacking is driven by zIndex, not array order.

Fix

Rewrote it to reassign contiguous zIndex values.

Bug

Undo stepped pixel-by-pixel — a clip drag commits on every pointer-move, so one drag became dozens of undo steps.

Fix

Time-based coalescing groups edits within ~500ms into one history step, so a whole drag (or a typing burst) is a single undo.

Bug

Undo restored dead blob: URLs — asset rehydration went through updateProject and entered history.

Fix

Rehydration became a silent update ({ history: false }).

Bug

blob: URLs can't be rendered in Node/Lambda — browser-only object URLs are meaningless to a headless renderer on AWS, so cloud renders came out with missing media.

Fix

Assets upload to S3 in the background at import (presigned PUT), the asset is patched with a public storageUrl, and the export remaps blob: → storageUrl before invoking Lambda. The browser keeps the blob: URL for instant local preview.

Bug

shadcn's CLI wrote generated components to a stray root @/ folder instead of src/.

Fix

The root tsconfig.json was missing paths — added it so @/ resolves to src/.

Bug

White-on-white text: default text color was #ffffff on a white canvas. Invisible, and no error anywhere.

Fix

A default that contrasts with the canvas — and another reminder that "no crash" ≠ "correct."

Bug

supabase-js .throwOnError() returned an error object with an empty message string — impossible to debug.

Fix

Replaced it with a direct fetch() to the Supabase REST API with explicit headers. Same query, raw HTTP, returned the actual error. The abstraction was hiding the signal.

Bug

Supabase's new sb_secret_ key format caused 403s on the renders table even with the correct key value.

Fix

Switched back to the older JWT-format keys from the dashboard — the new format wasn't compatible with the supabase-js version in use.

Bug

Remotion version drift broke Lambda — client at 4.0.483, Lambda at 4.0.488 failed at invoke time.

Fix

Pinned all Remotion packages to one exact version (removed the ^). They must match exactly across the tree.

Bug

RLS blocked even the service_role key from inserting into the renders table, though service_role is supposed to bypass RLS.

Fix

The table was created with grants off, so service_role had none. An explicit GRANT … TO service_role fixed it.

What each decision bought
Project as aggregate rootUndo/redo + autosave added at one point, covered everything
Engines own verbs, not stateNo state drift; features stayed thin and composable
One shared rendererEditor preview = cloud render, guaranteed — not hoped for
Player-based editor canvasText effects, video and audio all preview for free — no separate editor render path
One Project objectCloud sync was one Supabase table and ~40 lines — nothing wired per feature
Shots as a label on timeA whole shot model, and not one line of the render path changed
Transitions are animationsThey render and export everywhere with no new render code
Composition-space coordsEditor preview = export, at any zoom
Frames + <Sequence>Timeline maps directly onto Remotion; export just works
Immutable updatesCheap undo (structural sharing) + reliable re-renders
Discriminated-union elementsNew element type = one type + one renderer
Blocks as a registryNew block = one registry entry; renderer and panel untouched
Centralized useAuth hookZero auth logic leaks into UI — components call the hook, never Supabase
Device cookie for guestsAbuse prevented without forcing accounts — clearing storage doesn't help
Record device render after successFailed cloud renders don't burn the guest's one free slot
Trade-offs, honestly
  • The fast browser export draws to a 2D canvas, so it drops text effects, shaders and blocks — there's a beta mode that keeps them, and the dialog warns you when your project needs it; the cloud render always has everything.
  • Browser export needs Chrome or Edge (WebCodecs isn't in Safari yet); the cloud render covers every other device.
  • Editor audio/video preview is muted and autoplay-dependent; the export is authoritative for sound and timing.
  • Dashboard and editor are desktop-only (≥1024px) — below that a gate shows a "use a bigger screen" message; the landing page and sign-in stay fully responsive, so you can still sign up on a phone.
  • Media mostly follows you across devices — uploads sync to S3 in the background and the editor falls back to that copy — but a file whose upload never finished stays on the machine you added it from.
  • No scene grouping yet (moving several elements as one unit) — sequencing is done by positioning clips on the timeline.
  • Tests cover the pure engines (scenes, beat detection, migrations, scale…), where the expensive-to-get-wrong logic lives; the React panels lean on typecheck + lint rather than component tests.
Lessons
01

A single mutation path is a superpower — it's what made undo, autosave, and WYSIWYG cheap. Decide *where* data changes before deciding *how*.

02

Store data in the target domain (output resolution, frames), not the view's units — views come and go, the data shouldn't.

03

"No error" isn't "correct" — white-on-white text, the silent Omit-on-union, and every effect rendering as Times all shipped zero warnings.

04

Buy the boring parts (moveable handles, encoding, Lambda rendering) and build the parts that are actually your product: the composition model, the timeline, the animation system.

05

The CLI export was always the wrong tool for real users — they can't run terminal commands. The gap wasn't a missing feature; it was a missing way to get a finished video out.

06

Never trust the client — verify the login server-side first, then the abuse checks, then spend money. Always in that order.

07

Abstractions that hide errors are worse than no abstraction — a swallowed error string sent me down a two-hour path a raw HTTP call answered in ten seconds.

08

Be honest about the seams. The browser export doesn't match the editor perfectly, so the app says so — a warned limitation beats a silent broken file.