Разработка онлайн-редактора видео
Браузерный видеоредактор — одна из технически тяжёлых задач во фронтенд-разработке. Ключевое решение на старте: где происходит рендеринг финального видео — в браузере или на сервере. От этого зависит весь стек.
Браузер vs Сервер: где рендерить
Client-side рендеринг (WebCodecs API + FFmpeg.wasm):
- Не требует серверных мощностей для рендеринга
- Ограничен производительностью CPU пользователя
- FFmpeg.wasm: 10 минут видео рендерятся ~5–15 минут
- Поддержка WebCodecs: Chrome 94+, Firefox 130+, Safari 16.4+
Server-side рендеринг (Remotion + FFmpeg):
- Рендеринг на мощных серверах (GPU опционально)
- Пользователь не ждёт — получает готовый файл по ссылке
- Remotion умеет рендерить React-компоненты в видео
- Хорошо масштабируется через AWS Lambda
Для коммерческого продукта с продолжительными видео — серверный рендеринг. Для лёгкого инструмента (короткие клипы до 2 минут) — клиентский.
Временная шкала (Timeline)
Центральная UI-концепция — таймлайн с дорожками (треками). Структура данных:
interface VideoProject {
id: string;
duration: number; // секунды
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[];
}
Предпросмотр в браузере
Для предпросмотра до рендеринга используем HTML5 <video> с синхронизацией через currentTime:
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]);
// ...
};
Скрабbing по таймлайну:
const Timeline: React.FC = () => {
const { setCurrentTime, duration } = useEditorStore();
const railRef = useRef<HTMLDivElement>(null);
const handleMouseDown = (e: React.MouseEvent) => {
const handleMove = (e: MouseEvent) => {
const rect = railRef.current!.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
setCurrentTime(pct * duration);
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', handleMove);
}, { once: true });
};
return (
<div ref={railRef} className="timeline-rail" onMouseDown={handleMouseDown}>
<TimelinePlayhead />
{/* clips... */}
</div>
);
};
Клипы: drag, trim, split
Drag по таймлайну через @dnd-kit или кастомный drag:
const handleClipDrag = (clipId: string, deltaX: number, pxPerSecond: number) => {
const deltaSeconds = deltaX / pxPerSecond;
updateClip(clipId, clip => ({
...clip,
startTime: Math.max(0, clip.startTime + deltaSeconds),
}));
};
Split (разрезать клип):
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);
};
Серверный рендеринг через Remotion
Remotion позволяет описывать видео как React-компоненты:
// Компонент для рендеринга
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>
);
};
Запуск рендеринга через API:
// Backend: POST /api/render
import { renderMedia, selectComposition } from '@remotion/renderer';
const render = async (projectData: VideoProject) => {
const composition = await selectComposition({
serveUrl,
id: 'VideoComposition',
inputProps: { project: projectData },
});
await renderMedia({
composition,
serveUrl,
codec: 'h264',
outputLocation: `out/${projectData.id}.mp4`,
imageFormat: 'jpeg',
});
};
Для масштабирования — Remotion Lambda рендерит параллельно чанками:
import { renderMediaOnLambda } from '@remotion/lambda';
const { renderId } = await renderMediaOnLambda({
functionName: 'remotion-render',
region: 'eu-central-1',
serveUrl,
composition: 'VideoComposition',
inputProps: { project: projectData },
codec: 'h264',
});
Загрузка ассетов
Крупные видеофайлы загружаем напрямую в S3 через presigned URL (минуя сервер приложения):
// 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);
Сроки
| Этап | Время |
|---|---|
| Таймлайн (drag, trim, split) | 4–5 дней |
| Предпросмотр (синхронизация видео) | 3–4 дня |
| Загрузка ассетов (S3 presigned) | 1 день |
| Текстовые оверлеи, изображения | 2–3 дня |
| Remotion рендеринг + статус задачи | 3–4 дня |
| Аудио (громкость, mute, fade) | 2 дня |
| Эффекты и фильтры (brightness, contrast) | 2–3 дня |
Минимальный редактор (без эффектов и сложных фильтров): 13–17 рабочих дней.







