When you build a browser-based video export pipeline with Canvas 2D + MediaRecorder, you'll quickly hit a problem: you also need a live preview — playing the video with captions overlaid in real time, so users can verify what the export will look like before it starts.
The naive solution is to write the caption-drawing code twice: once for the export loop and once for the preview loop. But there's a much better way — expose your draw functions and share them.
The Setup: Shared Draw Functions
The key is to export your rendering primitives from a single module that both the export pipeline and the preview component can import:
// useClientExport.ts — your export hook
export function drawVideoFrame(
ctx: CanvasRenderingContext2D,
video: HTMLVideoElement,
cw: number,
ch: number
): void {
// cover-fit the video frame to the canvas
const vAspect = video.videoWidth / video.videoHeight;
const cAspect = cw / ch;
let sx = 0, sy = 0, sw = video.videoWidth, sh = video.videoHeight;
if (vAspect > cAspect) {
sw = video.videoHeight * cAspect;
sx = (video.videoWidth - sw) / 2;
} else {
sh = video.videoWidth / cAspect;
sy = (video.videoHeight - sh) / 2;
}
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, cw, ch);
}
export function drawCaptions({
ctx, captions, currentTime, captionStyle, cw, ch,
}: DrawCaptionsOptions): void {
const active = captions.find(
(c) => currentTime >= c.startInSeconds && currentTime < c.endInSeconds
);
if (!active) return;
// ... measure, wrap, draw background box, draw text
}
export function getDimensions(aspectRatio: string): { w: number; h: number } {
// ...
}
These three functions are pure — they take a canvas context and data, and they draw. No component state, no hooks. That's the secret to sharing them.
The Export Loop (MediaRecorder)
Your actual export captures canvas frames using captureStream() and MediaRecorder:
const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, { mimeType: "video/webm;codecs=vp9" });
let rafId: number;
function frame() {
if (video.currentTime >= endSec) {
recorder.stop();
return;
}
drawVideoFrame(ctx, video, cw, ch);
drawCaptions({ ctx, captions, currentTime: video.currentTime - startSec, ... });
rafId = requestAnimationFrame(frame);
}
recorder.start();
video.play();
rafId = requestAnimationFrame(frame);
The Preview Loop (No Recording)
The preview modal is structurally identical — same canvas, same draw calls — minus the MediaRecorder:
// ClipPreviewModal.tsx
import { drawVideoFrame, drawCaptions, getDimensions } from "@/hooks/useClientExport";
function usePreviewCanvas(vRef, cRef, clip, captions) {
const rafRef = useRef<number>(0);
const startLoop = useCallback(() => {
const v = vRef.current;
const c = cRef.current;
if (!v || !c) return;
const ctx = c.getContext("2d")!;
const { w: cw, h: ch } = getDimensions(clip.aspect_ratio);
function frame() {
if (!v || v.paused || v.ended) return;
drawVideoFrame(ctx, v, cw, ch);
drawCaptions({
ctx,
captions,
currentTime: v.currentTime - clip.start_sec,
captionStyle: clip.caption_style,
cw,
ch,
});
if (v.currentTime >= clip.end_sec) {
v.pause();
return;
}
rafRef.current = requestAnimationFrame(frame);
}
rafRef.current = requestAnimationFrame(frame);
}, [vRef, cRef, clip, captions]);
const stopLoop = useCallback(
() => cancelAnimationFrame(rafRef.current),
[]
);
return { startLoop, stopLoop };
}
The preview loop drives off requestAnimationFrame — just like the export — but it never calls recorder.start(). The canvas renders at 60fps while the video plays, and users see captions exactly as the exporter would apply them.
The textBaseline = "top" Detail
One subtle thing: when you're drawing a background box behind text, set ctx.textBaseline = "top" before measuring and before drawing the fill rect. It makes box sizing consistent across fonts:
ctx.textBaseline = "top";
const lines = wrapText(ctx, text, maxTextWidth);
const totalH = lines.length * lineHeight - (lineHeight - fontSize);
const boxH = totalH + padV * 2;
// Now fill and draw text — the box will fit exactly
With alphabetic (the default), the y-coordinate is the baseline, so the box extends above where you think it starts. top anchors to the top of the em square, which is what you want when computing bounding boxes.
Cross-Browser roundRect
ctx.roundRect() isn't available in all browsers yet. Roll your own using quadraticCurveTo:
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
Scaling Font Sizes from a Reference Resolution
Don't hardcode pixel values. Pick a reference resolution (1080p is a natural choice) and scale everything else:
const scale = ch / 1080;
const fontSize = Math.round(cfg.fontSize * scale); // e.g., 42px at 1080p → 19px at 480p
const padH = Math.round(cfg.boxPadH * scale);
const radius = Math.round(cfg.boxRadius * scale);
This means your caption configs stay as readable 1080p values (30px, 44px, 52px…) and the renderer handles the rest.
Why This Pattern Pays Off
- Zero drift: The preview is byte-for-byte the same rendering logic as the export. What you see is literally what you get.
-
One place to fix bugs: Update
drawCaptions, and both preview and export get the fix. -
Testable: You can unit-test
drawCaptionsby passing a mockCanvasRenderingContext2D— no component needed.
If you're building any kind of browser-based video editor or export tool, getting your draw functions into a pure, importable module early is one of the highest-leverage architectural decisions you can make.
Top comments (0)