Developing an Online Video Editor: Browser-Based Editing and Rendering
When a client requests browser-based video editing, the first hurdle is rendering the final output. Processing a one-minute clip on the client via FFmpeg.wasm works, but a ten-minute video will freeze even on a powerful laptop. We've tackled this on multiple projects and developed an architecture that scales. This article covers key architectural decisions: render type, timeline implementation, and file uploads.
Browser vs Server: Where to Render?
The choice between client-side and server-side rendering defines the entire stack and user experience. Here's a comparison:
| Parameter | Client-side (WebCodecs + FFmpeg.wasm) | Server-side (Remotion + FFmpeg) |
|---|---|---|
| CPU dependency | User's CPU | None |
| Max video duration | ~2 min | No limit |
| Rendering time for 10 min (i7 CPU) | 5–15 min | ~2 min on GPU |
| Scaling | One video at a time | Parallel tasks (Lambda) |
| Browser support | Chrome 94+, Firefox 130+, Safari 16.4+ | Any browser (file via link) |
| Infrastructure cost | Zero | Server costs (up to 40% savings under high load) |
Client-side rendering (WebCodecs API + FFmpeg.wasm): No server power needed; all load on the user's CPU. FFmpeg.wasm renders 10 minutes of video in 5–15 real-time minutes (depends on CPU). WebCodecs API is supported in Chrome 94+, Firefox 130+, and Safari 16.4+. Ideal for short clips up to 2 minutes—no delays, no server costs.
Server-side rendering (Remotion + FFmpeg): Rendering on GPU servers; the user gets a ready file via link. Remotion describes videos as React components, offering flexibility and reusability. Scales via AWS Lambda—rendering in parallel chunks. For a commercial product with videos longer than 2 minutes, this is the only reliable path. In our projects, we usually choose server-side: it gives predictable rendering times and doesn't drain the user's battery. But for a minimal editor for short clips, the client-side option is faster and cheaper.
Remotion official documentation: https://remotion.dev/docs/
How Is the Timeline Structured?
The central UI concept is a timeline with tracks. The data structure looks like this:
interface VideoProject { id: string; duration: number; // seconds fps: number; // 24 | 30 | 60 width: number; height: number; tracks: Track[]; } interface Track { id: string; type: 'video' | 'audio' | 'text' | 'image' | 'effect'; clips: Clip[]; muted: boolean; locked: boolean; volume: number; // 0–1 } interface Clip { id: string; trackId: string; assetId: string; // ссылка на загруженный файл startTime: number; // позиция на таймлайне (секунды) duration: number; // длительность клипа trimStart: number; // обрезка начала исходного файла trimEnd: number; // обрезка конца speed: number; // 0.25 – 4.0 opacity: number; transform?: ClipTransform; filters?: VideoFilter[]; } Data is stored in a state manager (Zustand or Redux) and synced with the database on save. Drag-and-drop is implemented via @dnd-kit—it reliably handles clip movement across tracks.
How to Split a Clip on the Timeline?
Split is a basic operation. Here's the step-by-step algorithm:
- Determine the split point in seconds relative to the timeline (e.g., when dragging the marker).
- Calculate splitPoint = (atTime - clip.startTime) + clip.trimStart.
- Create two new clips: left with trimEnd = splitPoint and right with trimStart = splitPoint.
- Delete the original clip and add the new ones.
Code:
const splitClip = (clipId: string, atTime: number) => { const clip = getClip(clipId); const splitPoint = atTime - clip.startTime + clip.trimStart; const leftClip: Clip = { ...clip, id: uuid(), duration: atTime - clip.startTime, trimEnd: splitPoint }; const rightClip: Clip = { ...clip, id: uuid(), startTime: atTime, duration: clip.startTime + clip.duration - atTime, trimStart: splitPoint, }; removeClip(clipId); addClip(leftClip); addClip(rightClip); }; Browser Preview
For preview, we use HTML5 <video> with synchronization via currentTime. Here's a key hook:
const PreviewPlayer: React.FC = () => { const { currentTime, isPlaying, tracks } = useEditorStore(); const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map()); useEffect(() => { // Синхронизируем все видео-клипы с таймлайном tracks.forEach(track => { track.clips.forEach(clip => { const video = videoRefs.current.get(clip.id); if (!video) return; const clipTime = currentTime - clip.startTime; const isActive = clipTime >= 0 && clipTime <= clip.duration; video.style.display = isActive ? 'block' : 'none'; if (isActive) { const targetTime = clip.trimStart + clipTime * clip.speed; if (Math.abs(video.currentTime - targetTime) > 0.05) { video.currentTime = targetTime; } isPlaying ? video.play() : video.pause(); } else { video.pause(); } }); }); }, [currentTime, isPlaying]); // ... }; Scrubbing on the timeline is done via mousedown/mousemove—calculating the position in seconds from the container width.
Clips: Drag, Trim, Split
Dragging: on drag, calculate the delta and update startTime considering pixels per second. Constrain to project boundaries and adjacent clips.
Trim: implemented similarly to split, but without deleting the original clip—just change trimStart/trimEnd.
Server-Side Rendering with Remotion
Remotion allows describing a video as a React component. We pass the project via inputProps and render on the server:
// Компонент для рендеринга const VideoComposition: React.FC<{ project: VideoProject }> = ({ project }) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const currentTime = frame / fps; return ( <AbsoluteFill style={{ background: '#000' }}> {project.tracks.flatMap(track => track.clips.map(clip => { const clipTime = currentTime - clip.startTime; if (clipTime < 0 || clipTime > clip.duration) return null; return ( <OffthreadVideo key={clip.id} src={clip.assetUrl} startFrom={Math.round(clip.trimStart * fps)} style={{ opacity: clip.opacity }} /> ); }) )} </AbsoluteFill> ); }; We launch rendering via the API using renderMediaOnLambda for scaling. On one project, we reduced the average rendering time for a 10-minute video from 15 minutes (client-side) to under 2 minutes using this approach.
Asset Upload
Large files (video, audio) are uploaded directly to S3 via presigned URLs—bypassing the application server:
// 1. Запрашиваем presigned URL const { uploadUrl, key } = await api.post('/api/editor/upload-url', { filename: file.name, contentType: file.type, size: file.size, }); // 2. Загружаем напрямую в S3 с прогрессом const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { setProgress(Math.round(e.loaded / e.total * 100)); }); xhr.open('PUT', uploadUrl); xhr.setRequestHeader('Content-Type', file.type); xhr.send(file); This approach offloads the server and increases upload speed. Contact us to discuss your project details.
Our Process and Timeframes
We follow a structured process: data collection → audit/analysis → design → estimation → development → testing → launch. The minimum editor (timeline, trim, preview, export via Remotion) takes 13–17 business days. Full functionality with effects, transitions, and audio mixer takes 20–25 days. The final cost is determined after a brief.
What's included in the result:
- Architecture and project code with API documentation.
- Deployment instructions (Docker Compose, .env).
- Repository access and CI/CD.
- 12-month warranty on code and support during implementation.
Here's a breakdown of development stages:
| Stage | Time |
|---|---|
| Timeline (drag, trim, split) | 4–5 days |
| Preview (video sync) | 3–4 days |
| Asset upload (S3 presigned) | 1 day |
| Text overlays, images | 2–3 days |
| Remotion rendering + task status | 3–4 days |
| Audio (volume, mute, fade) | 2 days |
| Effects and filters (brightness, contrast) | 2–3 days |
We have 10+ years of experience and have completed over 40 projects in web development, including several online editors. Request a custom development—we will accommodate all your requirements.







