Ever recorded something and realized there is a weird reflection in the corner? Or shot a landscape video that needs to become a vertical TikTok? Cropping sounds simple — until you realize most online tools make you guess pixel coordinates or upload your file to who-knows-where.
We built something different. A video cropper that shows you exactly what you are cutting. Drag a box over your video, resize the corners, pick a preset ratio if you want, and hit go. Your video never leaves your browser.
Why Do This Locally?
Video cropping is oddly personal. You are deciding what stays in the frame and what gets thrown away. Sending that footage to a server feels unnecessary:
-
Visual feedback: See the crop area in real time instead of typing
x: 120, y: 80into a form and hoping for the best. - No upload delay: A 500MB video takes forever to upload just to shave 100 pixels off the edge.
- Privacy: Your footage stays on your machine. We never see it.
- Instant results: No queue, no processing estimate, no refreshing the page.
- Touch-friendly: Works on tablets and phones, not just desktops with a mouse.
The limitation is the same as all browser-based video tools — very long or very high-res videos can eat a lot of RAM. But for typical clips, it is perfectly fine.
How the Whole Thing Works
Here is the flow from upload to cropped download:
The Data Model
We track the crop area separately from the video file:
interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
interface VideoFile {
id: string;
file: File;
previewUrl: string;
outputUrl?: string;
outputFileName?: string;
error?: string;
processing?: boolean;
progress?: number;
duration: number;
videoWidth: number;
videoHeight: number;
}
The CropArea stores coordinates in the video's native pixel space, not screen pixels. That is important because the video might be displayed at 800px wide on screen while its actual resolution is 1920px wide. We do the coordinate conversion so the crop is precise.
The Interactive Crop Box
This is where most of the code lives. We render a semi-transparent overlay on top of the video that users can drag and resize.
Mouse Interaction
type DragMode = 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' | null;
When you click inside the crop box, it moves. When you click one of the four corner handles, it resizes from that corner. We figure out which mode you are in by checking where you clicked relative to the crop area:
const handleMouseDown = (e: React.MouseEvent) => {
const pos = getMousePosition(e.clientX, e.clientY);
const handleSize = 12 / calculateDisplayScale();
const isResizeNW = pos.x >= cropArea.x && pos.x <= cropArea.x + handleSize &&
pos.y >= cropArea.y && pos.y <= cropArea.y + handleSize;
const isResizeNE = pos.x >= cropArea.x + cropArea.width - handleSize && pos.x <= cropArea.x + cropArea.width &&
pos.y >= cropArea.y && pos.y <= cropArea.y + handleSize;
// ... similar for SW and SE
if (isResizeNW) setDragMode('resize-nw');
else if (isResizeNE) setDragMode('resize-ne');
// ... etc
else if (pos.x >= cropArea.x && pos.x <= cropArea.x + cropArea.width &&
pos.y >= cropArea.y && pos.y <= cropArea.y + cropArea.height) {
setDragMode('move');
}
};
The getMousePosition helper converts screen coordinates back to video pixel coordinates by accounting for the display scale:
const getMousePosition = (clientX: number, clientY: number) => {
const rect = videoContainerRef.current.getBoundingClientRect();
const scale = calculateDisplayScale();
return {
x: (clientX - rect.left) / scale,
y: (clientY - rect.top) / scale
};
};
Moving the Crop Area
When dragging in move mode, we clamp the position so the crop box never leaves the video boundaries:
if (dragMode === 'move') {
newCropArea.x = Math.max(0, Math.min(
selectedFile.videoWidth - cropArea.width,
dragStart.cropX + deltaX
));
newCropArea.y = Math.max(0, Math.min(
selectedFile.videoHeight - cropArea.height,
dragStart.cropY + deltaY
));
}
Resizing from Corners
Resizing is trickier because dragging a corner changes both position and size. For example, dragging the northwest corner moves the top-left position while also changing the width and height:
if (dragMode.includes('e')) {
newCropArea.width = Math.max(minSize, Math.min(
selectedFile.videoWidth - cropArea.x,
dragStart.cropW + deltaX
));
}
if (dragMode.includes('w')) {
const newWidth = Math.max(minSize, Math.min(
dragStart.cropW - deltaX,
dragStart.cropX + dragStart.cropW
));
const widthDiff = newCropArea.width - newWidth;
newCropArea.width = newWidth;
newCropArea.x = Math.max(0, cropArea.x + widthDiff);
}
The minimum size is clamped to 50 pixels so users do not accidentally shrink the crop box to nothing.
Touch Support
We also support touch devices, though touch interaction is simplified to just moving the crop box (no corner resizing on touch, to avoid accidentally triggering browser gestures):
const handleTouchStart = (e: React.TouchEvent) => {
const touch = e.touches[0];
const pos = getMousePosition(touch.clientX, touch.clientY);
if (pos.x >= cropArea.x && pos.x <= cropArea.x + cropArea.width &&
pos.y >= cropArea.y && pos.y <= cropArea.y + cropArea.height) {
setDragMode('move');
setDragStart({ x: pos.x, y: pos.y, cropX: cropArea.x, cropY: cropArea.y, ... });
setIsDragging(true);
}
};
Aspect Ratio Presets
For common use cases, we offer one-click presets:
[
{ label: "16:9", w: 1920, h: 1080 },
{ label: "4:3", w: 1280, h: 960 },
{ label: "1:1", w: 1080, h: 1080 },
{ label: "9:16", w: 1080, h: 1920 },
]
Clicking a preset fills in the width and height fields, then the crop box is centered on the video. If the preset dimensions are larger than the source video, we clamp them to fit. This makes it trivial to turn a landscape video into a square for Instagram or a vertical for TikTok.
The FFmpeg Crop: More Than Just Coordinates
Once the user is happy with the crop area, we hand it off to FFmpeg. But it is not as simple as passing the raw coordinates. Video encoding has quirks.
The Even-Number Problem
H.264 video typically uses the yuv420p pixel format, which requires both width and height to be divisible by 2. Odd dimensions cause encoding failures. So we sanitize the crop values:
let safeX = Math.max(0, Math.min(maxWidth - 2, Math.round(cropArea.x)));
let safeY = Math.max(0, Math.min(maxHeight - 2, Math.round(cropArea.y)));
let safeWidth = Math.max(2, Math.min(maxWidth - safeX, Math.round(cropArea.width)));
let safeHeight = Math.max(2, Math.min(maxHeight - safeY, Math.round(cropArea.height)));
// Make dimensions even (required for yuv420p)
safeWidth = Math.floor(safeWidth / 2) * 2;
safeHeight = Math.floor(safeHeight / 2) * 2;
safeX = Math.floor(safeX / 2) * 2;
safeY = Math.floor(safeY / 2) * 2;
// Re-validate after making even
if (safeX + safeWidth > maxWidth) {
safeWidth = Math.floor((maxWidth - safeX) / 2) * 2;
}
if (safeY + safeHeight > maxHeight) {
safeHeight = Math.floor((maxHeight - safeY) / 2) * 2;
}
This rounds every dimension down to the nearest even number, then re-checks that the crop area still fits inside the video bounds. Without this step, FFmpeg throws cryptic errors about invalid arguments.
The FFmpeg Command
const cropFilter = `crop=${safeWidth}:${safeHeight}:${safeX}:${safeY}`;
await ffmpeg.exec([
"-i", inputName,
"-vf", cropFilter,
"-c:v", "libx264",
"-preset", "ultrafast",
"-profile:v", "baseline",
"-level", "3.0",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-an",
"-y",
outputName
]);
Breaking this down:
-
-vf crop=w:h:x:y: The actual crop filter. Cuts out a rectangle from the source. -
-c:v libx264: Encode the output as H.264. -
-preset ultrafast: Prioritize speed over file size. Since we are only cropping, users want it done quickly. -
-profile:v baseline -level 3.0: Use the most compatible H.264 profile. This ensures the output plays on older devices and browsers. -
-pix_fmt yuv420p: Standard pixel format. Required for broad compatibility. -
-movflags +faststart: Reorganize the MP4 so playback can start before the entire file downloads. -
-an: Drop the audio. This is a crop tool — the audio stream would be identical anyway, and stripping it simplifies the output.
Handling Different Data Types
FFmpeg.wasm sometimes returns a string instead of a Uint8Array, so we handle both:
let uint8Data: Uint8Array;
if (data instanceof Uint8Array) {
uint8Data = data;
} else if (typeof data === 'string') {
uint8Data = new TextEncoder().encode(data);
} else {
throw new Error(`Unexpected data type: ${typeof data}`);
}
This defensive coding prevents mysterious failures when the underlying library behavior varies.
Loading FFmpeg on Demand
As always, we load FFmpeg lazily:
// utils/ffmpegLoader.ts
import { fetchFile, toBlobURL } from "@ffmpeg/util";
let ffmpeg: any = null;
let fetchFileFn: any = null;
export async function loadFFmpeg() {
if (ffmpeg) return { ffmpeg, fetchFile: fetchFileFn };
const { FFmpeg } = await import("@ffmpeg/ffmpeg");
ffmpeg = new FFmpeg();
fetchFileFn = fetchFile;
const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd";
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
}, {
corePath: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
});
return { ffmpeg, fetchFile };
}
Dynamic import keeps the initial bundle small. Blob URLs avoid CORS issues. And caching the instance means you do not pay the startup tax twice.
Progress and Cleanup
We track FFmpeg progress:
ffmpeg.on("progress", ({ progress }: { progress: number }) => {
setSelectedFile(prev => prev ? { ...prev, progress: Math.round(progress * 100) } : null);
});
And clean up after ourselves:
const reset = useCallback(() => {
if (selectedFile) {
URL.revokeObjectURL(selectedFile.previewUrl);
if (selectedFile.outputUrl) URL.revokeObjectURL(selectedFile.outputUrl);
}
setSelectedFile(null);
setCropWidth("");
setCropHeight("");
setCropArea({ x: 0, y: 0, width: 0, height: 0 });
setError(null);
}, [selectedFile]);
Plus filesystem cleanup after each crop:
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
Error Handling
We catch and translate FFmpeg errors into human-readable messages:
if (errorMessage.includes("crop") || errorMessage.includes("Invalid argument")) {
setError("Invalid crop dimensions. Please try different crop settings.");
} else if (errorMessage.includes("Failed to inject frame")) {
setError("Failed to apply crop filter. The crop area may be invalid.");
} else if (errorMessage.includes("empty") || errorMessage.includes("failed to create")) {
setError("Video processing failed. The crop area may extend beyond video boundaries or the video format is not supported.");
} else {
setError("Failed to crop video. Please try again.");
}
Even if processing fails, we attempt cleanup so the virtual filesystem does not get cluttered with half-finished files.
What We Learned
Building an interactive cropper in the browser taught us a few things:
- Coordinate math is deceptively tricky: Converting between screen pixels, video pixels, and scaled display pixels sounds easy until you start dealing with resize handles. Getting the northwest corner to drag correctly — moving both position and size in opposite directions — took more debugging than expected.
- yuv420p even-dimension requirements are non-negotiable: We spent a while chasing "Invalid argument" errors from FFmpeg before realizing the pixel format requires even widths and heights. Now we round everything down to the nearest even number before passing it to FFmpeg.
- Baseline profile is worth it: We started with the default H.264 profile, and some mobile browsers refused to play the output. Switching to baseline profile fixed compatibility across the board.
-
-ansimplifies everything: We initially tried to preserve audio during cropping, but it complicated the command and caused codec compatibility issues. Since cropping does not change the audio at all, stripping it and letting users recombine later if needed turned out to be the cleanest approach. - Touch and mouse need different UX: Resize handles work great with a mouse but are nearly impossible to grab accurately on a touchscreen. We simplified touch interaction to just moving the crop box, which feels natural on phones and tablets.
Give It a Try
Got a video with something you want to cut out of the frame? Or need to reframe a landscape clip for vertical social media? You can crop it right now — visually, with instant feedback.
Upload your video, drag the crop box, pick a preset if you want, and download the result. Your footage never leaves your browser.

Top comments (0)