FFmpeg Video Clipper & Cropper
import React, { useState, useRef, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
interface CropRegion { x: number; y: number; w: number; h: number }
type HandleType = "move" | "nw" | "ne" | "sw" | "se";
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
const toEven = (n: number) => Math.floor(n / 2) * 2;
const ASPECT_PRESETS: { label: string; ratio: number | null }[] = [
{ label: "Free", ratio: null },
{ label: "1:1", ratio: 1 },
{ label: "16:9", ratio: 16 / 9 },
{ label: "9:16", ratio: 9 / 16 },
{ label: "4:3", ratio: 4 / 3 },
{ label: "3:4", ratio: 3 / 4 },
];
function centeredCrop(ratio: number | null, vw: number, vh: number): CropRegion {
if (!ratio || !vw || !vh) return { x: 0, y: 0, w: 1, h: 1 };
const videoAR = vw / vh;
let w: number, h: number;
if (ratio >= videoAR) { w = 1; h = vw / (ratio * vh); }
else { h = 1; w = (ratio * vh) / vw; }
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}
// --- Crop Overlay ---
const CropOverlay = ({
crop, onChange, containerRef, vw, vh, ar,
}: {
crop: CropRegion;
onChange: (r: CropRegion) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
vw: number; vh: number; ar: number | null;
}) => {
const dragRef = useRef<{ type: HandleType; mx: number; my: number; start: CropRegion } | null>(null);
const [, tick] = useState(0);
const arRef = useRef(ar); arRef.current = ar;
const vwRef = useRef(vw); vwRef.current = vw;
const vhRef = useRef(vh); vhRef.current = vh;
const onChangeRef = useRef(onChange); onChangeRef.current = onChange;
useEffect(() => {
const getRect = () => containerRef.current?.getBoundingClientRect();
const move = (cx: number, cy: number) => {
const d = dragRef.current;
if (!d) return;
const rect = getRect();
if (!rect) return;
const { type, start } = d;
const { x, y, w, h } = start;
const right = x + w, bottom = y + h;
if (type === "move") {
const dx = (cx - d.mx) / rect.width;
const dy = (cy - d.my) / rect.height;
onChangeRef.current({ x: clamp(x + dx, 0, 1 - w), y: clamp(y + dy, 0, 1 - h), w, h });
return;
}
const mouseX = clamp((cx - rect.left) / rect.width, 0, 1);
const mouseY = clamp((cy - rect.top) / rect.height, 0, 1);
let nx: number, ny: number, nw: number, nh: number;
switch (type) {
case "se": nx = x; ny = y; nw = mouseX - x; nh = mouseY - y; break;
case "sw": nw = right - mouseX; nh = mouseY - y; nx = right - nw; ny = y; break;
case "ne": nw = mouseX - x; nh = bottom - mouseY; nx = x; ny = bottom - nh; break;
default: nw = right - mouseX; nh = bottom - mouseY; nx = right - nw; ny = bottom - nh; break;
}
nw = Math.max(0.05, nw); nh = Math.max(0.05, nh);
const _ar = arRef.current, _vw = vwRef.current, _vh = vhRef.current;
if (_ar && _vw > 0 && _vh > 0) {
const cur = (nw * _vw) / (nh * _vh);
if (cur > _ar) { nw = (nh * _ar * _vh) / _vw; }
else { nh = (nw * _vw) / (_ar * _vh); }
switch (type) {
case "sw": nx = right - nw; break;
case "ne": ny = bottom - nh; break;
case "nw": nx = right - nw; ny = bottom - nh; break;
}
}
nx = clamp(nx, 0, 1); ny = clamp(ny, 0, 1);
nw = clamp(nw, 0.05, 1 - nx); nh = clamp(nh, 0.05, 1 - ny);
onChangeRef.current({ x: nx, y: ny, w: nw, h: nh });
};
const onMM = (e: MouseEvent) => move(e.clientX, e.clientY);
const onTM = (e: TouchEvent) => { if (dragRef.current) e.preventDefault(); move(e.touches[0].clientX, e.touches[0].clientY); };
const onEnd = () => { dragRef.current = null; tick(n => n + 1); };
document.addEventListener("mousemove", onMM);
document.addEventListener("mouseup", onEnd);
document.addEventListener("touchmove", onTM, { passive: false });
document.addEventListener("touchend", onEnd);
return () => {
document.removeEventListener("mousemove", onMM);
document.removeEventListener("mouseup", onEnd);
document.removeEventListener("touchmove", onTM);
document.removeEventListener("touchend", onEnd);
};
}, []);
const begin = (type: HandleType, e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault(); e.stopPropagation();
const cx = "touches" in e ? e.touches[0].clientX : e.clientX;
const cy = "touches" in e ? e.touches[0].clientY : e.clientY;
dragRef.current = { type, mx: cx, my: cy, start: { ...crop } };
tick(n => n + 1);
};
const { x, y, w, h } = crop;
const active = dragRef.current !== null;
const handles: { id: HandleType; pos: React.CSSProperties }[] = [
{ id: "nw", pos: { left: -6, top: -6, cursor: "nwse-resize" } },
{ id: "ne", pos: { right: -6, top: -6, cursor: "nesw-resize" } },
{ id: "sw", pos: { left: -6, bottom: -6, cursor: "nesw-resize" } },
{ id: "se", pos: { right: -6, bottom: -6, cursor: "nwse-resize" } },
];
return (
<div className="crop-overlay">
<div className="crop-region"
style={{
left: `${x * 100}%`, top: `${y * 100}%`,
width: `${w * 100}%`, height: `${h * 100}%`,
cursor: active && dragRef.current?.type === "move" ? "grabbing" : "grab",
}}
onMouseDown={e => begin("move", e)}
onTouchStart={e => begin("move", e)}
>
<div className="crop-grid">
{[1, 2].map(i => (
<React.Fragment key={i}>
<div className="crop-grid-v" style={{ left: `${(i * 100) / 3}%` }} />
<div className="crop-grid-h" style={{ top: `${(i * 100) / 3}%` }} />
</React.Fragment>
))}
</div>
{handles.map(({ id, pos }) => (
<div key={id} className="crop-handle" style={pos}
onMouseDown={e => begin(id, e)} onTouchStart={e => begin(id, e)} />
))}
</div>
</div>
);
};
// --- Range Control ---
const RangeControl = ({ label, value, max, onChange }: {
label: string; value: number; max: number; onChange: (v: number) => void;
}) => (
<div style={{ marginBottom: "1rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "0.5rem", fontSize: "0.85rem" }}>
<span style={{ fontWeight: 600 }}>{label}</span>
<span style={{ color: "var(--text-muted)" }}>{value.toFixed(2)}s</span>
</div>
<input type="range" min={0} max={max || 100} step={0.01} value={value}
onChange={e => onChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--accent)" }} />
</div>
);
// --- App ---
const App = () => {
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [log, setLog] = useState<string[]>(["Initializing..."]);
const [inputFile, setInputFile] = useState<File | null>(null);
const [inputUrl, setInputUrl] = useState<string | null>(null);
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [duration, setDuration] = useState(0);
const [startTime, setStartTime] = useState(0);
const [endTime, setEndTime] = useState(3);
const [cropEnabled, setCropEnabled] = useState(false);
const [crop, setCrop] = useState<CropRegion>({ x: 0, y: 0, w: 1, h: 1 });
const [aspectIdx, setAspectIdx] = useState(0);
const [vidSize, setVidSize] = useState({ w: 0, h: 0 });
const [includeAudio, setIncludeAudio] = useState(true);
const [cap1080, setCap1080] = useState(false);
const ffmpegRef = useRef(new FFmpeg());
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const addLog = (msg: string) => setLog(prev => [...prev, msg]);
const curAR = ASPECT_PRESETS[aspectIdx].ratio;
useEffect(() => {
const load = async () => {
const ffmpeg = ffmpegRef.current;
ffmpeg.on("log", ({ message }) => addLog(message));
try {
addLog("Loading FFmpeg WASM...");
await ffmpeg.load({
coreURL: tb.proxy("https://unpkg.com/@ffmpeg/[email protected]/dist/esm/ffmpeg-core.js"),
wasmURL: tb.proxy("https://unpkg.com/@ffmpeg/[email protected]/dist/esm/ffmpeg-core.wasm"),
classWorkerURL: tb.proxy("https://unpkg.com/@ffmpeg/[email protected]/dist/esm/worker.js"),
});
addLog("FFmpeg loaded successfully!");
setLoaded(true);
} catch (err) {
addLog(`Error loading FFmpeg: ${err}`);
}
setLoading(false);
};
load();
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setInputFile(file);
if (file) { addLog(`Selected: ${file.name}`); setInputUrl(URL.createObjectURL(file)); }
setOutputUrl(null);
setCropEnabled(false);
setCrop({ x: 0, y: 0, w: 1, h: 1 });
setAspectIdx(0);
};
const handleMeta = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const v = e.currentTarget;
setDuration(v.duration);
setEndTime(Math.min(v.duration, 3));
setVidSize({ w: v.videoWidth, h: v.videoHeight });
};
const handleSeek = (time: number, isStart: boolean) => {
if (isStart) { setStartTime(time); if (time > endTime) setEndTime(time); }
else { setEndTime(time); if (time < startTime) setStartTime(time); }
if (videoRef.current) videoRef.current.currentTime = time;
};
const selectAspect = (i: number) => {
setAspectIdx(i);
setCrop(centeredCrop(ASPECT_PRESETS[i].ratio, vidSize.w, vidSize.h));
};
const handleClip = async () => {
if (!inputFile || !loaded) return;
setProcessing(true);
addLog("Processing...");
const ffmpeg = ffmpegRef.current;
try {
await ffmpeg.writeFile("input.mp4", await fetchFile(inputFile));
const args = ["-ss", startTime.toString(), "-i", "input.mp4", "-to", (endTime - startTime).toString()];
const filters: string[] = [];
if (cropEnabled && vidSize.w > 0 && vidSize.h > 0) {
let cw = toEven(Math.round(crop.w * vidSize.w));
let ch = toEven(Math.round(crop.h * vidSize.h));
let cx = toEven(Math.round(crop.x * vidSize.w));
let cy = toEven(Math.round(crop.y * vidSize.h));
if (cx + cw > vidSize.w) cw = toEven(vidSize.w - cx);
if (cy + ch > vidSize.h) ch = toEven(vidSize.h - cy);
cw = Math.max(cw, 2); ch = Math.max(ch, 2);
filters.push(`crop=${cw}:${ch}:${cx}:${cy}`);
addLog(`Crop: ${cw}×${ch} at (${cx},${cy})`);
}
if (cap1080) {
filters.push("scale='if(gt(iw,ih),min(1080,iw),-2)':'if(gt(iw,ih),-2,min(1080,ih))'");
}
filters.push("scale=trunc(iw/2)*2:trunc(ih/2)*2");
args.push("-vf", filters.join(","));
args.push("-c:v", "libx264", "-preset", "ultrafast", "-crf", "18",
"-profile:v", "baseline", "-level", "3.1", "-pix_fmt", "yuv420p");
if (includeAudio) {
args.push("-c:a", "aac", "-b:a", "192k");
} else {
args.push("-an");
}
args.push("-avoid_negative_ts", "make_zero",
"-movflags", "+faststart", "output.mp4");
addLog(`Re-encoding${cropEnabled ? " with crop" : ""} for frame-accurate cut...`);
await ffmpeg.exec(args);
const data = await ffmpeg.readFile("output.mp4");
setOutputUrl(URL.createObjectURL(new Blob([data], { type: "video/mp4" })));
addLog(`Done! ${startTime.toFixed(2)}s–${endTime.toFixed(2)}s${cropEnabled ? " (cropped)" : ""}`);
} catch (err) {
addLog(`Error: ${err}`);
}
setProcessing(false);
};
const handleDownload = async () => {
if (!outputUrl) return;
if (navigator.share && navigator.canShare?.({ files: [new File([], "t.mp4", { type: "video/mp4" })] })) {
const resp = await fetch(outputUrl);
const blob = await resp.blob();
await navigator.share({ files: [new File([blob], "clipped.mp4", { type: "video/mp4" })] });
} else {
const a = document.createElement("a");
a.href = outputUrl;
a.download = "clipped.mp4";
a.click();
}
};
const pw = vidSize.w ? toEven(Math.round(crop.w * vidSize.w)) : 0;
const ph = vidSize.h ? toEven(Math.round(crop.h * vidSize.h)) : 0;
const ua = navigator.userAgent;
const isWebView = (/iPhone|iPad|iPod/i.test(ua) && !ua.includes('Safari')) || /; wv\)/.test(ua);
if (isWebView) return (
<div className="container animate-in">
<div className="in-app-banner">
Your in-app browser can't download videos. Please open this page directly in your browser.
<button className="copy-link-btn" onClick={async () => tb.copy(await tb.url())}>
Copy Link
</button>
</div>
</div>
);
return (
<div className="container animate-in">
<header className="main-header">
<h1>Video Clip Crop</h1>
<p className="subtitle">Clip and crop your videos locally in the browser. Nothing leaves your device.</p>
</header>
<div className="upload-section">
<label htmlFor="file-upload"
className={`upload-btn ${inputFile ? "has-file" : ""} ${loading ? "disabled" : ""}`}>
{loading ? <span className="shimmer">Loading FFmpeg...</span> : inputFile ? `Selected: ${inputFile.name}` : "Choose Video File"}
<input id="file-upload" type="file" accept="video/*" onChange={handleFileChange} disabled={loading} />
</label>
</div>
{inputUrl && (
<div className="page-card animate-in" style={{ cursor: "default", marginBottom: "2rem" }}>
<div className="section-label">PREVIEW & EDIT</div>
<div ref={containerRef} className="video-container">
<video ref={videoRef} src={inputUrl} onLoadedMetadata={handleMeta} controls
style={{ width: "100%", display: "block", background: "#000" }} />
{cropEnabled && (
<CropOverlay crop={crop} onChange={setCrop} containerRef={containerRef}
vw={vidSize.w} vh={vidSize.h} ar={curAR} />
)}
</div>
<div className="crop-section">
<div className="crop-toggle-row">
<button className={`toggle-btn ${cropEnabled ? "active" : ""}`}
onClick={() => setCropEnabled(p => !p)}>
<span className="toggle-dot" /> Crop
</button>
<button className={`toggle-btn ${includeAudio ? "active" : ""}`}
onClick={() => setIncludeAudio(p => !p)}>
<span className="toggle-dot" /> Audio
</button>
<button className={`toggle-btn ${cap1080 ? "active" : ""}`}
onClick={() => setCap1080(p => !p)}>
<span className="toggle-dot" /> Cap 1080p
</button>
{cropEnabled && pw > 0 && <span className="crop-dims">{pw} × {ph} px</span>}
</div>
{cropEnabled && (
<div className="aspect-row animate-in">
{ASPECT_PRESETS.map((p, i) => (
<button key={p.label} className={`aspect-btn ${aspectIdx === i ? "active" : ""}`}
onClick={() => selectAspect(i)}>{p.label}</button>
))}
</div>
)}
</div>
<div className="range-controls">
<RangeControl label="Start" value={startTime} max={duration} onChange={v => handleSeek(v, true)} />
<RangeControl label="End" value={endTime} max={duration} onChange={v => handleSeek(v, false)} />
</div>
<div className="toolbar" style={{ position: "relative", top: 0, marginTop: "1rem", boxShadow: "none", border: "none", background: "transparent" }}>
<button className="btn" onClick={handleClip}
disabled={!loaded || !inputFile || processing} style={{ width: "100%" }}>
{processing ? <span className="shimmer">Processing...</span> : `${cropEnabled ? "Crop & " : ""}Clip ${startTime.toFixed(1)}s – ${endTime.toFixed(1)}s`}
</button>
</div>
</div>
)}
{outputUrl && (
<div className="page-card animate-in success-card" style={{ cursor: "default" }}>
<div className="card-label">SUCCESS • READY TO DOWNLOAD</div>
<video controls src={outputUrl} style={{ width: "100%", borderRadius: "8px", background: "#000" }} />
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "center" }}>
<button className="btn" onClick={handleDownload} style={{ width: "100%" }}>
Download Video
</button>
</div>
</div>
)}
<div className="log-container">
<div className="log-title">System Logs</div>
{processing && log.length > 0 && (
<div className="last-log">{log[log.length - 1]}</div>
)}
<details>
<summary>View all logs ({log.length})</summary>
<div className="log">
{log.map((line, i) => <div key={i} style={{ marginBottom: "2px" }}>{line}</div>)}
</div>
</details>
</div>
</div>
);
};
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);