Video Clip Crop

FFmpeg Video Clipper & Cropper

code.tsx

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 />);

Markdown source · More bulbs by samples · Typebulb home