search
React star Featured

FFmpeg.wasm in React: Build a Complete Video Trimmer That Runs in Your Browser

Complete guide to building a professional video trimmer with React and FFmpeg.wasm. Trim, preview, and export videos entirely in the browser with zero backend.

person By Gautam Sharma
calendar_today December 31, 2024
schedule 26 min read
React FFmpeg Video WebAssembly WASM

Build a professional video trimmer application using React and FFmpeg.wasm. Process videos entirely in the browser with no server required. Complete with timeline scrubbing, precise trimming, and video export.

Project Structure

Here’s the complete directory structure for the video trimmer application:

video-trimmer/
├── public/
│   └── index.html
├── src/
│   ├── components/
│   │   ├── VideoUploader.jsx
│   │   ├── VideoPlayer.jsx
│   │   ├── Timeline.jsx
│   │   ├── TrimControls.jsx
│   │   ├── ProgressModal.jsx
│   │   └── ExportOptions.jsx
│   ├── hooks/
│   │   ├── useFFmpeg.js
│   │   ├── useVideoProcessor.js
│   │   └── useTimeline.js
│   ├── utils/
│   │   ├── videoUtils.js
│   │   ├── formatTime.js
│   │   └── fileUtils.js
│   ├── styles/
│   │   ├── VideoUploader.css
│   │   ├── VideoPlayer.css
│   │   ├── Timeline.css
│   │   ├── TrimControls.css
│   │   ├── ProgressModal.css
│   │   └── App.css
│   ├── App.jsx
│   ├── main.jsx
│   └── index.css
├── package.json
└── vite.config.js

Installation and Setup

Install dependencies and create the project structure.

npm create vite@latest video-trimmer -- --template react
cd video-trimmer
npm install

# Install FFmpeg.wasm
npm install @ffmpeg/ffmpeg @ffmpeg/util

# Install additional dependencies
npm install lucide-react

npm run dev

Package Configuration

package.json:

{
  "name": "video-trimmer",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@ffmpeg/ffmpeg": "^0.12.10",
    "@ffmpeg/util": "^0.12.1",
    "lucide-react": "^0.294.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.43",
    "@types/react-dom": "^18.2.17",
    "@vitejs/plugin-react": "^4.2.1",
    "vite": "^5.0.8"
  }
}

Vite Configuration

vite.config.js:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
  },
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp'
    }
  }
});

Utility Functions

Time Formatting Utility

src/utils/formatTime.js:

export const formatTime = (seconds) => {
  if (!seconds || isNaN(seconds)) return '00:00';

  const hours = Math.floor(seconds / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  const secs = Math.floor(seconds % 60);

  if (hours > 0) {
    return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  }

  return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};

export const parseTimeToSeconds = (timeString) => {
  const parts = timeString.split(':').map(Number);

  if (parts.length === 3) {
    return parts[0] * 3600 + parts[1] * 60 + parts[2];
  } else if (parts.length === 2) {
    return parts[0] * 60 + parts[1];
  }

  return 0;
};

export const formatFileSize = (bytes) => {
  if (bytes === 0) return '0 Bytes';

  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));

  return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};

Video Utilities

src/utils/videoUtils.js:

export const getVideoMetadata = (file) => {
  return new Promise((resolve, reject) => {
    const video = document.createElement('video');
    video.preload = 'metadata';

    video.onloadedmetadata = () => {
      URL.revokeObjectURL(video.src);
      resolve({
        duration: video.duration,
        width: video.videoWidth,
        height: video.videoHeight
      });
    };

    video.onerror = () => {
      reject(new Error('Failed to load video metadata'));
    };

    video.src = URL.createObjectURL(file);
  });
};

export const extractVideoFrames = async (videoFile, numFrames = 10) => {
  const video = document.createElement('video');
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  video.src = URL.createObjectURL(videoFile);

  return new Promise((resolve, reject) => {
    video.onloadedmetadata = async () => {
      canvas.width = 160;
      canvas.height = 90;

      const duration = video.duration;
      const interval = duration / numFrames;
      const frames = [];

      for (let i = 0; i < numFrames; i++) {
        video.currentTime = i * interval;

        await new Promise((res) => {
          video.onseeked = () => {
            ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
            frames.push(canvas.toDataURL('image/jpeg', 0.7));
            res();
          };
        });
      }

      URL.revokeObjectURL(video.src);
      resolve(frames);
    };

    video.onerror = reject;
  });
};

File Utilities

src/utils/fileUtils.js:

export const readFileAsArrayBuffer = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (e) => resolve(new Uint8Array(e.target.result));
    reader.onerror = reject;
    reader.readAsArrayBuffer(file);
  });
};

export const downloadBlob = (blob, filename) => {
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
};

export const validateVideoFile = (file) => {
  const validTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];

  if (!validTypes.includes(file.type)) {
    throw new Error('Invalid file type. Please upload MP4, WebM, OGG, or MOV files.');
  }

  const maxSize = 500 * 1024 * 1024; // 500MB
  if (file.size > maxSize) {
    throw new Error('File too large. Maximum size is 500MB.');
  }

  return true;
};

Custom Hooks

FFmpeg Hook

src/hooks/useFFmpeg.js:

import { useState, useRef, useEffect } from 'react';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

export const useFFmpeg = () => {
  const ffmpegRef = useRef(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    loadFFmpeg();
  }, []);

  const loadFFmpeg = async () => {
    if (ffmpegRef.current) return;

    setIsLoading(true);
    setError(null);

    try {
      const ffmpeg = new FFmpeg();

      ffmpeg.on('log', ({ message }) => {
        console.log(message);
      });

      ffmpeg.on('progress', ({ progress: prog }) => {
        setProgress(Math.round(prog * 100));
      });

      const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';

      await ffmpeg.load({
        coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
        wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
      });

      ffmpegRef.current = ffmpeg;
      setIsLoaded(true);
    } catch (err) {
      console.error('Failed to load FFmpeg:', err);
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  };

  const trimVideo = async (videoFile, startTime, endTime, outputName = 'output.mp4') => {
    if (!ffmpegRef.current) {
      throw new Error('FFmpeg not loaded');
    }

    const ffmpeg = ffmpegRef.current;
    setProgress(0);

    try {
      const inputName = 'input.mp4';

      // Write input file
      await ffmpeg.writeFile(inputName, await fetchFile(videoFile));

      // Calculate duration
      const duration = endTime - startTime;

      // Execute trim command
      await ffmpeg.exec([
        '-i', inputName,
        '-ss', startTime.toString(),
        '-t', duration.toString(),
        '-c:v', 'libx264',
        '-c:a', 'aac',
        '-preset', 'ultrafast',
        outputName
      ]);

      // Read output file
      const data = await ffmpeg.readFile(outputName);

      // Clean up
      await ffmpeg.deleteFile(inputName);
      await ffmpeg.deleteFile(outputName);

      return new Blob([data.buffer], { type: 'video/mp4' });
    } catch (err) {
      console.error('Trim error:', err);
      throw new Error('Failed to trim video: ' + err.message);
    }
  };

  const convertVideo = async (videoFile, format = 'mp4', quality = 'medium') => {
    if (!ffmpegRef.current) {
      throw new Error('FFmpeg not loaded');
    }

    const ffmpeg = ffmpegRef.current;
    setProgress(0);

    const inputName = 'input.mp4';
    const outputName = `output.${format}`;

    const qualitySettings = {
      low: { crf: '28', preset: 'ultrafast' },
      medium: { crf: '23', preset: 'fast' },
      high: { crf: '18', preset: 'slow' }
    };

    const settings = qualitySettings[quality];

    try {
      await ffmpeg.writeFile(inputName, await fetchFile(videoFile));

      await ffmpeg.exec([
        '-i', inputName,
        '-c:v', 'libx264',
        '-crf', settings.crf,
        '-preset', settings.preset,
        '-c:a', 'aac',
        '-b:a', '128k',
        outputName
      ]);

      const data = await ffmpeg.readFile(outputName);

      await ffmpeg.deleteFile(inputName);
      await ffmpeg.deleteFile(outputName);

      return new Blob([data.buffer], { type: `video/${format}` });
    } catch (err) {
      console.error('Convert error:', err);
      throw new Error('Failed to convert video: ' + err.message);
    }
  };

  return {
    isLoaded,
    isLoading,
    error,
    progress,
    trimVideo,
    convertVideo
  };
};

Video Processor Hook

src/hooks/useVideoProcessor.js:

import { useState } from 'react';
import { getVideoMetadata } from '../utils/videoUtils';
import { validateVideoFile } from '../utils/fileUtils';

export const useVideoProcessor = () => {
  const [videoFile, setVideoFile] = useState(null);
  const [videoURL, setVideoURL] = useState(null);
  const [metadata, setMetadata] = useState(null);
  const [isProcessing, setIsProcessing] = useState(false);
  const [error, setError] = useState(null);

  const loadVideo = async (file) => {
    try {
      setError(null);
      setIsProcessing(true);

      validateVideoFile(file);

      const meta = await getVideoMetadata(file);
      const url = URL.createObjectURL(file);

      setVideoFile(file);
      setVideoURL(url);
      setMetadata(meta);
    } catch (err) {
      setError(err.message);
      console.error('Load video error:', err);
    } finally {
      setIsProcessing(false);
    }
  };

  const clearVideo = () => {
    if (videoURL) {
      URL.revokeObjectURL(videoURL);
    }
    setVideoFile(null);
    setVideoURL(null);
    setMetadata(null);
    setError(null);
  };

  return {
    videoFile,
    videoURL,
    metadata,
    isProcessing,
    error,
    loadVideo,
    clearVideo
  };
};

Timeline Hook

src/hooks/useTimeline.js:

import { useState, useCallback } from 'react';

export const useTimeline = (duration = 0) => {
  const [startTime, setStartTime] = useState(0);
  const [endTime, setEndTime] = useState(duration);
  const [currentTime, setCurrentTime] = useState(0);

  const updateStartTime = useCallback((time) => {
    const newStart = Math.max(0, Math.min(time, endTime - 0.1));
    setStartTime(newStart);
    if (currentTime < newStart) {
      setCurrentTime(newStart);
    }
  }, [endTime, currentTime]);

  const updateEndTime = useCallback((time) => {
    const newEnd = Math.min(duration, Math.max(time, startTime + 0.1));
    setEndTime(newEnd);
    if (currentTime > newEnd) {
      setCurrentTime(newEnd);
    }
  }, [duration, startTime, currentTime]);

  const resetTimeline = useCallback((newDuration) => {
    setStartTime(0);
    setEndTime(newDuration);
    setCurrentTime(0);
  }, []);

  const getTrimDuration = useCallback(() => {
    return endTime - startTime;
  }, [startTime, endTime]);

  return {
    startTime,
    endTime,
    currentTime,
    setCurrentTime,
    updateStartTime,
    updateEndTime,
    resetTimeline,
    getTrimDuration
  };
};

Component Files

Video Uploader Component

src/components/VideoUploader.jsx:

import React, { useRef } from 'react';
import { Upload, Film } from 'lucide-react';
import '../styles/VideoUploader.css';

const VideoUploader = ({ onVideoLoad, isProcessing }) => {
  const fileInputRef = useRef(null);

  const handleFileChange = (e) => {
    const file = e.target.files?.[0];
    if (file) {
      onVideoLoad(file);
    }
  };

  const handleDrop = (e) => {
    e.preventDefault();
    const file = e.dataTransfer.files?.[0];
    if (file && file.type.startsWith('video/')) {
      onVideoLoad(file);
    }
  };

  const handleDragOver = (e) => {
    e.preventDefault();
  };

  return (
    <div className="video-uploader">
      <div
        className="upload-area"
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        onClick={() => fileInputRef.current?.click()}
      >
        <input
          ref={fileInputRef}
          type="file"
          accept="video/*"
          onChange={handleFileChange}
          style={{ display: 'none' }}
          disabled={isProcessing}
        />

        <div className="upload-content">
          {isProcessing ? (
            <>
              <div className="spinner"></div>
              <p>Loading video...</p>
            </>
          ) : (
            <>
              <div className="upload-icon">
                <Upload size={48} />
                <Film size={48} className="film-icon" />
              </div>
              <h3>Drop video here or click to upload</h3>
              <p className="upload-hint">
                Supports MP4, WebM, OGG, MOV (max 500MB)
              </p>
            </>
          )}
        </div>
      </div>
    </div>
  );
};

export default VideoUploader;

src/styles/VideoUploader.css:

.video-uploader {
  width: 100%;
  max-width: 800px;
  margin: 0 auto;
}

.upload-area {
  border: 3px dashed #3b82f6;
  border-radius: 16px;
  padding: 60px 40px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s ease;
  background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
  position: relative;
  overflow: hidden;
}

.upload-area:hover {
  border-color: #2563eb;
  background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
  transform: translateY(-2px);
  box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
}

.upload-area::before {
  content: '';
  position: absolute;
  top: -50%;
  left: -50%;
  width: 200%;
  height: 200%;
  background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
  animation: pulse 3s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); opacity: 0.5; }
  50% { transform: scale(1.1); opacity: 0.8; }
}

.upload-content {
  position: relative;
  z-index: 1;
}

.upload-icon {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 20px;
  margin-bottom: 24px;
  color: #3b82f6;
}

.film-icon {
  color: #8b5cf6;
}

.upload-area h3 {
  margin: 0 0 12px 0;
  color: #1e293b;
  font-size: 22px;
  font-weight: 700;
}

.upload-hint {
  color: #64748b;
  font-size: 14px;
  margin: 0;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 4px solid #e0f2fe;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 20px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

Video Player Component

src/components/VideoPlayer.jsx:

import React, { useRef, useEffect } from 'react';
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
import { formatTime } from '../utils/formatTime';
import '../styles/VideoPlayer.css';

const VideoPlayer = ({
  videoURL,
  currentTime,
  onTimeUpdate,
  startTime,
  endTime
}) => {
  const videoRef = useRef(null);
  const [isPlaying, setIsPlaying] = React.useState(false);

  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.currentTime = currentTime;
    }
  }, [currentTime]);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const handleTimeUpdate = () => {
      const time = video.currentTime;

      if (time >= endTime) {
        video.currentTime = startTime;
        video.pause();
        setIsPlaying(false);
      }

      onTimeUpdate(time);
    };

    video.addEventListener('timeupdate', handleTimeUpdate);
    return () => video.removeEventListener('timeupdate', handleTimeUpdate);
  }, [startTime, endTime, onTimeUpdate]);

  const togglePlay = () => {
    const video = videoRef.current;
    if (!video) return;

    if (video.paused) {
      if (video.currentTime >= endTime || video.currentTime < startTime) {
        video.currentTime = startTime;
      }
      video.play();
      setIsPlaying(true);
    } else {
      video.pause();
      setIsPlaying(false);
    }
  };

  const jumpTo = (offset) => {
    const video = videoRef.current;
    if (!video) return;

    const newTime = Math.max(startTime, Math.min(endTime, video.currentTime + offset));
    video.currentTime = newTime;
  };

  return (
    <div className="video-player">
      <div className="video-wrapper">
        <video
          ref={videoRef}
          src={videoURL}
          className="video-element"
        />

        <div className="video-overlay">
          <div className="time-display">
            {formatTime(currentTime)}
          </div>
        </div>
      </div>

      <div className="player-controls">
        <button onClick={() => jumpTo(-5)} className="control-btn">
          <SkipBack size={20} />
          <span>5s</span>
        </button>

        <button onClick={togglePlay} className="control-btn play-btn">
          {isPlaying ? <Pause size={28} /> : <Play size={28} />}
        </button>

        <button onClick={() => jumpTo(5)} className="control-btn">
          <SkipForward size={20} />
          <span>5s</span>
        </button>
      </div>
    </div>
  );
};

export default VideoPlayer;

src/styles/VideoPlayer.css:

.video-player {
  width: 100%;
  max-width: 900px;
  margin: 0 auto;
  background: #000;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

.video-wrapper {
  position: relative;
  width: 100%;
  background: #000;
}

.video-element {
  width: 100%;
  display: block;
  max-height: 500px;
  object-fit: contain;
}

.video-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  pointer-events: none;
  display: flex;
  align-items: flex-start;
  justify-content: flex-end;
  padding: 20px;
}

.time-display {
  background: rgba(0, 0, 0, 0.8);
  color: white;
  padding: 8px 16px;
  border-radius: 8px;
  font-family: 'Courier New', monospace;
  font-size: 18px;
  font-weight: 600;
  backdrop-filter: blur(10px);
}

.player-controls {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 12px;
  padding: 20px;
  background: linear-gradient(180deg, #1a1a1a 0%, #0a0a0a 100%);
}

.control-btn {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 12px 20px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 10px;
  color: white;
  cursor: pointer;
  transition: all 0.2s;
  font-size: 14px;
  font-weight: 600;
}

.control-btn:hover {
  background: rgba(255, 255, 255, 0.2);
  transform: translateY(-2px);
}

.play-btn {
  padding: 16px 24px;
  background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
  border-color: transparent;
}

.play-btn:hover {
  background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
}

Timeline Component

src/components/Timeline.jsx:

import React, { useRef, useState, useEffect } from 'react';
import { Scissors } from 'lucide-react';
import { formatTime } from '../utils/formatTime';
import '../styles/Timeline.css';

const Timeline = ({
  duration,
  currentTime,
  startTime,
  endTime,
  onStartTimeChange,
  onEndTimeChange,
  onSeek,
  frames = []
}) => {
  const timelineRef = useRef(null);
  const [isDraggingStart, setIsDraggingStart] = useState(false);
  const [isDraggingEnd, setIsDraggingEnd] = useState(false);
  const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);

  const getTimeFromPosition = (clientX) => {
    const rect = timelineRef.current.getBoundingClientRect();
    const position = (clientX - rect.left) / rect.width;
    return Math.max(0, Math.min(duration, position * duration));
  };

  const handleMouseDown = (e, type) => {
    e.preventDefault();
    if (type === 'start') setIsDraggingStart(true);
    else if (type === 'end') setIsDraggingEnd(true);
    else if (type === 'playhead') setIsDraggingPlayhead(true);
  };

  const handleMouseMove = (e) => {
    if (!isDraggingStart && !isDraggingEnd && !isDraggingPlayhead) return;

    const time = getTimeFromPosition(e.clientX);

    if (isDraggingStart) {
      onStartTimeChange(Math.min(time, endTime - 1));
    } else if (isDraggingEnd) {
      onEndTimeChange(Math.max(time, startTime + 1));
    } else if (isDraggingPlayhead) {
      onSeek(time);
    }
  };

  const handleMouseUp = () => {
    setIsDraggingStart(false);
    setIsDraggingEnd(false);
    setIsDraggingPlayhead(false);
  };

  useEffect(() => {
    if (isDraggingStart || isDraggingEnd || isDraggingPlayhead) {
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
      return () => {
        document.removeEventListener('mousemove', handleMouseMove);
        document.removeEventListener('mouseup', handleMouseUp);
      };
    }
  }, [isDraggingStart, isDraggingEnd, isDraggingPlayhead, startTime, endTime]);

  const handleTimelineClick = (e) => {
    if (e.target.closest('.trim-handle') || e.target.closest('.playhead')) {
      return;
    }
    const time = getTimeFromPosition(e.clientX);
    onSeek(time);
  };

  const startPercent = (startTime / duration) * 100;
  const endPercent = (endTime / duration) * 100;
  const currentPercent = (currentTime / duration) * 100;

  return (
    <div className="timeline-container">
      <div className="timeline-header">
        <div className="trim-info">
          <Scissors size={18} />
          <span>Trim: {formatTime(startTime)}{formatTime(endTime)}</span>
          <span className="duration-badge">
            {formatTime(endTime - startTime)}
          </span>
        </div>
      </div>

      <div
        ref={timelineRef}
        className="timeline"
        onClick={handleTimelineClick}
      >
        {frames.length > 0 && (
          <div className="timeline-frames">
            {frames.map((frame, index) => (
              <div key={index} className="frame-thumb">
                <img src={frame} alt={`Frame ${index}`} />
              </div>
            ))}
          </div>
        )}

        <div className="timeline-track">
          <div
            className="trim-area"
            style={{
              left: `${startPercent}%`,
              width: `${endPercent - startPercent}%`
            }}
          >
            <div className="trim-overlay"></div>
          </div>

          <div
            className="trim-handle trim-start"
            style={{ left: `${startPercent}%` }}
            onMouseDown={(e) => handleMouseDown(e, 'start')}
          >
            <div className="handle-grip"></div>
            <div className="handle-time">{formatTime(startTime)}</div>
          </div>

          <div
            className="trim-handle trim-end"
            style={{ left: `${endPercent}%` }}
            onMouseDown={(e) => handleMouseDown(e, 'end')}
          >
            <div className="handle-grip"></div>
            <div className="handle-time">{formatTime(endTime)}</div>
          </div>

          <div
            className="playhead"
            style={{ left: `${currentPercent}%` }}
            onMouseDown={(e) => handleMouseDown(e, 'playhead')}
          >
            <div className="playhead-line"></div>
            <div className="playhead-handle"></div>
          </div>
        </div>

        <div className="timeline-markers">
          {[0, 0.25, 0.5, 0.75, 1].map((pos) => (
            <div key={pos} className="marker" style={{ left: `${pos * 100}%` }}>
              <div className="marker-line"></div>
              <div className="marker-time">
                {formatTime(duration * pos)}
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

export default Timeline;

src/styles/Timeline.css:

.timeline-container {
  width: 100%;
  max-width: 900px;
  margin: 30px auto;
  background: white;
  border-radius: 16px;
  padding: 24px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}

.timeline-header {
  margin-bottom: 20px;
}

.trim-info {
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 16px;
  color: #1e293b;
  font-weight: 600;
}

.trim-info svg {
  color: #3b82f6;
}

.duration-badge {
  margin-left: auto;
  background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
  color: white;
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 14px;
}

.timeline {
  position: relative;
  height: 120px;
  background: #f1f5f9;
  border-radius: 12px;
  overflow: hidden;
  cursor: pointer;
  user-select: none;
}

.timeline-frames {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 80px;
  display: flex;
  gap: 2px;
}

.frame-thumb {
  flex: 1;
  overflow: hidden;
}

.frame-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.7;
}

.timeline-track {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}

.trim-area {
  position: absolute;
  top: 0;
  bottom: 0;
  background: rgba(59, 130, 246, 0.2);
  border: 2px solid #3b82f6;
  pointer-events: none;
}

.trim-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: linear-gradient(180deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
}

.trim-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 20px;
  margin-left: -10px;
  cursor: ew-resize;
  z-index: 10;
}

.handle-grip {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 4px;
  margin-left: -2px;
  background: linear-gradient(180deg, #3b82f6 0%, #8b5cf6 100%);
  box-shadow: 0 0 8px rgba(59, 130, 246, 0.5);
}

.handle-grip::before,
.handle-grip::after {
  content: '';
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: 16px;
  height: 16px;
  background: white;
  border: 3px solid #3b82f6;
  border-radius: 50%;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}

.handle-grip::before {
  top: -8px;
}

.handle-grip::after {
  bottom: -8px;
}

.handle-time {
  position: absolute;
  bottom: -30px;
  left: 50%;
  transform: translateX(-50%);
  background: #1e293b;
  color: white;
  padding: 4px 10px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
  font-family: 'Courier New', monospace;
}

.playhead {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  margin-left: -1px;
  cursor: ew-resize;
  z-index: 5;
}

.playhead-line {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 2px;
  background: #ef4444;
  box-shadow: 0 0 8px rgba(239, 68, 68, 0.6);
}

.playhead-handle {
  position: absolute;
  top: -6px;
  left: 50%;
  transform: translateX(-50%);
  width: 14px;
  height: 14px;
  background: #ef4444;
  border: 2px solid white;
  border-radius: 50%;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

.timeline-markers {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 30px;
  pointer-events: none;
}

.marker {
  position: absolute;
  bottom: 0;
  transform: translateX(-50%);
}

.marker-line {
  width: 1px;
  height: 8px;
  background: #94a3b8;
  margin: 0 auto 4px;
}

.marker-time {
  font-size: 11px;
  color: #64748b;
  font-family: 'Courier New', monospace;
  white-space: nowrap;
}

Trim Controls Component

src/components/TrimControls.jsx:

import React, { useState } from 'react';
import { Scissors, Download, RotateCcw } from 'lucide-react';
import { formatTime } from '../utils/formatTime';
import '../styles/TrimControls.css';

const TrimControls = ({
  startTime,
  endTime,
  duration,
  onTrim,
  onReset,
  isProcessing
}) => {
  const [quality, setQuality] = useState('medium');

  const trimDuration = endTime - startTime;
  const trimPercentage = ((trimDuration / duration) * 100).toFixed(1);

  return (
    <div className="trim-controls">
      <div className="controls-grid">
        <div className="control-group">
          <label>Quality</label>
          <select
            value={quality}
            onChange={(e) => setQuality(e.target.value)}
            disabled={isProcessing}
          >
            <option value="low">Low (Fast, Smaller)</option>
            <option value="medium">Medium (Balanced)</option>
            <option value="high">High (Slow, Larger)</option>
          </select>
        </div>

        <div className="stats-group">
          <div className="stat-item">
            <span className="stat-label">Original</span>
            <span className="stat-value">{formatTime(duration)}</span>
          </div>
          <div className="stat-item">
            <span className="stat-label">Trimmed</span>
            <span className="stat-value highlight">{formatTime(trimDuration)}</span>
          </div>
          <div className="stat-item">
            <span className="stat-label">Kept</span>
            <span className="stat-value">{trimPercentage}%</span>
          </div>
        </div>
      </div>

      <div className="action-buttons">
        <button
          onClick={onReset}
          disabled={isProcessing}
          className="btn-secondary"
        >
          <RotateCcw size={20} />
          Reset
        </button>

        <button
          onClick={() => onTrim(quality)}
          disabled={isProcessing || trimDuration < 0.1}
          className="btn-primary"
        >
          {isProcessing ? (
            <>
              <div className="spinner-small"></div>
              Processing...
            </>
          ) : (
            <>
              <Scissors size={20} />
              Trim & Export
            </>
          )}
        </button>
      </div>
    </div>
  );
};

export default TrimControls;

src/styles/TrimControls.css:

.trim-controls {
  width: 100%;
  max-width: 900px;
  margin: 0 auto;
  background: white;
  border-radius: 16px;
  padding: 24px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}

.controls-grid {
  display: grid;
  grid-template-columns: 250px 1fr;
  gap: 24px;
  margin-bottom: 24px;
}

.control-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #1e293b;
  font-size: 14px;
}

.control-group select {
  width: 100%;
  padding: 12px;
  border: 2px solid #e2e8f0;
  border-radius: 8px;
  font-size: 14px;
  background: white;
  cursor: pointer;
  transition: all 0.2s;
}

.control-group select:hover:not(:disabled) {
  border-color: #3b82f6;
}

.control-group select:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.stats-group {
  display: flex;
  gap: 24px;
  align-items: center;
  padding: 12px 0;
}

.stat-item {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.stat-label {
  font-size: 12px;
  color: #64748b;
  text-transform: uppercase;
  font-weight: 600;
  letter-spacing: 0.5px;
}

.stat-value {
  font-size: 20px;
  font-weight: 700;
  color: #1e293b;
  font-family: 'Courier New', monospace;
}

.stat-value.highlight {
  color: #3b82f6;
}

.action-buttons {
  display: grid;
  grid-template-columns: 150px 1fr;
  gap: 12px;
}

.action-buttons button {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 16px 24px;
  border: none;
  border-radius: 10px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-secondary {
  background: #f1f5f9;
  color: #475569;
  border: 2px solid #e2e8f0;
}

.btn-secondary:hover:not(:disabled) {
  background: #e2e8f0;
  border-color: #cbd5e1;
}

.btn-primary {
  background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
  color: white;
  box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}

.btn-primary:hover:not(:disabled) {
  background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}

.action-buttons button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

.spinner-small {
  width: 20px;
  height: 20px;
  border: 3px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

@media (max-width: 768px) {
  .controls-grid {
    grid-template-columns: 1fr;
  }

  .stats-group {
    justify-content: space-around;
  }

  .action-buttons {
    grid-template-columns: 1fr;
  }
}

Progress Modal Component

src/components/ProgressModal.jsx:

import React from 'react';
import { CheckCircle, AlertCircle } from 'lucide-react';
import '../styles/ProgressModal.css';

const ProgressModal = ({ isOpen, progress, status, onClose }) => {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div className="modal-content">
        {status === 'processing' && (
          <>
            <div className="progress-circle">
              <svg viewBox="0 0 100 100">
                <circle
                  cx="50"
                  cy="50"
                  r="45"
                  className="progress-bg"
                />
                <circle
                  cx="50"
                  cy="50"
                  r="45"
                  className="progress-bar"
                  style={{
                    strokeDasharray: `${progress * 2.827} 282.7`
                  }}
                />
              </svg>
              <div className="progress-text">
                {progress}%
              </div>
            </div>
            <h3>Processing Video</h3>
            <p>Trimming and encoding your video...</p>
          </>
        )}

        {status === 'complete' && (
          <>
            <div className="status-icon success">
              <CheckCircle size={64} />
            </div>
            <h3>Export Complete!</h3>
            <p>Your video has been processed successfully.</p>
            <button onClick={onClose} className="btn-close">
              Done
            </button>
          </>
        )}

        {status === 'error' && (
          <>
            <div className="status-icon error">
              <AlertCircle size={64} />
            </div>
            <h3>Export Failed</h3>
            <p>Something went wrong during processing.</p>
            <button onClick={onClose} className="btn-close">
              Close
            </button>
          </>
        )}
      </div>
    </div>
  );
};

export default ProgressModal;

src/styles/ProgressModal.css:

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.7);
  backdrop-filter: blur(8px);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  animation: fadeIn 0.2s ease;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

.modal-content {
  background: white;
  border-radius: 20px;
  padding: 48px;
  max-width: 400px;
  text-align: center;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  animation: slideUp 0.3s ease;
}

@keyframes slideUp {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.progress-circle {
  position: relative;
  width: 150px;
  height: 150px;
  margin: 0 auto 24px;
}

.progress-circle svg {
  transform: rotate(-90deg);
  width: 100%;
  height: 100%;
}

.progress-bg {
  fill: none;
  stroke: #e2e8f0;
  stroke-width: 8;
}

.progress-bar {
  fill: none;
  stroke: url(#gradient);
  stroke-width: 8;
  stroke-linecap: round;
  transition: stroke-dasharray 0.3s ease;
}

.progress-circle::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
  border-radius: 50%;
  opacity: 0.1;
}

.progress-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 36px;
  font-weight: 700;
  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

.status-icon {
  width: 120px;
  height: 120px;
  margin: 0 auto 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
}

.status-icon.success {
  color: #10b981;
  background: linear-gradient(135deg, #d1fae5, #a7f3d0);
}

.status-icon.error {
  color: #ef4444;
  background: linear-gradient(135deg, #fee2e2, #fecaca);
}

.modal-content h3 {
  margin: 0 0 12px 0;
  font-size: 24px;
  color: #1e293b;
}

.modal-content p {
  margin: 0 0 24px 0;
  color: #64748b;
  font-size: 16px;
}

.btn-close {
  padding: 14px 48px;
  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
  color: white;
  border: none;
  border-radius: 10px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-close:hover {
  background: linear-gradient(135deg, #2563eb, #7c3aed);
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}

Main Application Component

src/App.jsx:

import React, { useState, useEffect } from 'react';
import { Film, Info } from 'lucide-react';
import VideoUploader from './components/VideoUploader';
import VideoPlayer from './components/VideoPlayer';
import Timeline from './components/Timeline';
import TrimControls from './components/TrimControls';
import ProgressModal from './components/ProgressModal';
import { useFFmpeg } from './hooks/useFFmpeg';
import { useVideoProcessor } from './hooks/useVideoProcessor';
import { useTimeline } from './hooks/useTimeline';
import { extractVideoFrames } from './utils/videoUtils';
import { downloadBlob } from './utils/fileUtils';
import './styles/App.css';

function App() {
  const {
    isLoaded: ffmpegLoaded,
    isLoading: ffmpegLoading,
    progress: ffmpegProgress,
    trimVideo
  } = useFFmpeg();

  const {
    videoFile,
    videoURL,
    metadata,
    isProcessing: videoLoading,
    error: videoError,
    loadVideo,
    clearVideo
  } = useVideoProcessor();

  const {
    startTime,
    endTime,
    currentTime,
    setCurrentTime,
    updateStartTime,
    updateEndTime,
    resetTimeline,
    getTrimDuration
  } = useTimeline(metadata?.duration || 0);

  const [frames, setFrames] = useState([]);
  const [isExporting, setIsExporting] = useState(false);
  const [exportStatus, setExportStatus] = useState(null);
  const [showModal, setShowModal] = useState(false);

  useEffect(() => {
    if (metadata?.duration) {
      resetTimeline(metadata.duration);
    }
  }, [metadata, resetTimeline]);

  useEffect(() => {
    if (videoFile && metadata) {
      extractVideoFrames(videoFile, 20)
        .then(setFrames)
        .catch(console.error);
    }
  }, [videoFile, metadata]);

  const handleVideoLoad = async (file) => {
    clearVideo();
    setFrames([]);
    await loadVideo(file);
  };

  const handleTrim = async (quality) => {
    if (!videoFile) return;

    setIsExporting(true);
    setExportStatus('processing');
    setShowModal(true);

    try {
      const trimmedBlob = await trimVideo(
        videoFile,
        startTime,
        endTime,
        'trimmed.mp4'
      );

      const fileName = `trimmed-${Date.now()}.mp4`;
      downloadBlob(trimmedBlob, fileName);

      setExportStatus('complete');
    } catch (error) {
      console.error('Export error:', error);
      setExportStatus('error');
    } finally {
      setIsExporting(false);
    }
  };

  const handleReset = () => {
    if (metadata?.duration) {
      resetTimeline(metadata.duration);
    }
  };

  const handleModalClose = () => {
    setShowModal(false);
    setExportStatus(null);
  };

  const handleNewVideo = () => {
    clearVideo();
    setFrames([]);
  };

  return (
    <div className="app">
      <header className="app-header">
        <div className="header-content">
          <div className="logo">
            <Film size={32} />
            <h1>Video Trimmer</h1>
          </div>
          <div className="header-info">
            {ffmpegLoading && (
              <span className="status-badge loading">Loading FFmpeg...</span>
            )}
            {ffmpegLoaded && (
              <span className="status-badge ready">FFmpeg Ready</span>
            )}
          </div>
        </div>
      </header>

      <main className="app-main">
        {!ffmpegLoaded && (
          <div className="loading-screen">
            <div className="spinner-large"></div>
            <h2>Loading Video Processor</h2>
            <p>Initializing FFmpeg WebAssembly...</p>
          </div>
        )}

        {ffmpegLoaded && !videoFile && (
          <div className="upload-section">
            <VideoUploader
              onVideoLoad={handleVideoLoad}
              isProcessing={videoLoading}
            />

            <div className="info-box">
              <Info size={20} />
              <div>
                <h4>How it works</h4>
                <ul>
                  <li>Upload your video file (MP4, WebM, MOV)</li>
                  <li>Drag the timeline handles to select trim points</li>
                  <li>Preview your selection in real-time</li>
                  <li>Export the trimmed video instantly</li>
                </ul>
                <p className="note">
                  All processing happens in your browser. No uploads required!
                </p>
              </div>
            </div>
          </div>
        )}

        {ffmpegLoaded && videoFile && metadata && (
          <div className="editor-section">
            <div className="editor-header">
              <h2>{videoFile.name}</h2>
              <button onClick={handleNewVideo} className="btn-new">
                Load New Video
              </button>
            </div>

            <VideoPlayer
              videoURL={videoURL}
              currentTime={currentTime}
              onTimeUpdate={setCurrentTime}
              startTime={startTime}
              endTime={endTime}
            />

            <Timeline
              duration={metadata.duration}
              currentTime={currentTime}
              startTime={startTime}
              endTime={endTime}
              onStartTimeChange={updateStartTime}
              onEndTimeChange={updateEndTime}
              onSeek={setCurrentTime}
              frames={frames}
            />

            <TrimControls
              startTime={startTime}
              endTime={endTime}
              duration={metadata.duration}
              onTrim={handleTrim}
              onReset={handleReset}
              isProcessing={isExporting}
            />
          </div>
        )}

        {videoError && (
          <div className="error-banner">
            <AlertCircle size={24} />
            <p>{videoError}</p>
          </div>
        )}
      </main>

      <ProgressModal
        isOpen={showModal}
        progress={ffmpegProgress}
        status={exportStatus}
        onClose={handleModalClose}
      />

      <svg style={{ display: 'none' }}>
        <defs>
          <linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
            <stop offset="0%" stopColor="#3b82f6" />
            <stop offset="100%" stopColor="#8b5cf6" />
          </linearGradient>
        </defs>
      </svg>
    </div>
  );
}

export default App;

src/styles/App.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.app {
  min-height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.app-header {
  background: rgba(255, 255, 255, 0.98);
  backdrop-filter: blur(10px);
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
  position: sticky;
  top: 0;
  z-index: 100;
}

.header-content {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px 24px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.logo {
  display: flex;
  align-items: center;
  gap: 12px;
  color: #1e293b;
}

.logo svg {
  color: #3b82f6;
}

.logo h1 {
  font-size: 24px;
  font-weight: 700;
}

.header-info {
  display: flex;
  gap: 12px;
}

.status-badge {
  padding: 8px 16px;
  border-radius: 20px;
  font-size: 14px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}

.status-badge.loading {
  background: #fef3c7;
  color: #92400e;
}

.status-badge.ready {
  background: #d1fae5;
  color: #065f46;
}

.app-main {
  max-width: 1200px;
  margin: 0 auto;
  padding: 40px 24px;
}

.loading-screen {
  text-align: center;
  padding: 100px 20px;
  color: white;
}

.spinner-large {
  width: 60px;
  height: 60px;
  border: 5px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 24px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.loading-screen h2 {
  margin-bottom: 12px;
  font-size: 28px;
}

.loading-screen p {
  font-size: 16px;
  opacity: 0.9;
}

.upload-section {
  display: flex;
  flex-direction: column;
  gap: 30px;
}

.info-box {
  background: white;
  border-radius: 16px;
  padding: 24px;
  display: flex;
  gap: 20px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}

.info-box svg {
  color: #3b82f6;
  flex-shrink: 0;
  margin-top: 4px;
}

.info-box h4 {
  margin-bottom: 12px;
  color: #1e293b;
  font-size: 18px;
}

.info-box ul {
  margin: 0 0 16px 20px;
  color: #475569;
}

.info-box li {
  margin-bottom: 8px;
  line-height: 1.6;
}

.info-box .note {
  margin: 0;
  padding: 12px;
  background: #f0f9ff;
  border-left: 4px solid #3b82f6;
  border-radius: 4px;
  color: #1e40af;
  font-size: 14px;
  font-weight: 600;
}

.editor-section {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.editor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 24px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.editor-header h2 {
  color: #1e293b;
  font-size: 20px;
  font-weight: 600;
}

.btn-new {
  padding: 10px 20px;
  background: #f1f5f9;
  border: 2px solid #e2e8f0;
  border-radius: 8px;
  color: #475569;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-new:hover {
  background: #e2e8f0;
  border-color: #cbd5e1;
}

.error-banner {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 20px;
  background: #fee2e2;
  border: 2px solid #fecaca;
  border-radius: 12px;
  color: #991b1b;
}

@media (max-width: 768px) {
  .header-content {
    flex-direction: column;
    gap: 16px;
  }

  .editor-header {
    flex-direction: column;
    gap: 12px;
    align-items: flex-start;
  }

  .btn-new {
    width: 100%;
  }
}

Entry Point

src/main.jsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

src/index.css:

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

#root {
  width: 100%;
}

Usage Instructions

  1. Start Development Server:

    npm run dev
  2. Upload a Video:

    • Click or drag-drop a video file
    • Supported formats: MP4, WebM, MOV, OGG
  3. Trim Video:

    • Drag blue handles on timeline to set start/end points
    • Or drag the red playhead to scrub through
    • Preview your selection in the player
  4. Export:

    • Choose quality settings (low/medium/high)
    • Click “Trim & Export”
    • Download starts automatically
  5. Build for Production:

    npm run build
    npm run preview

Key Features

  • Zero Backend: All processing in browser with FFmpeg.wasm
  • Visual Timeline: Drag handles with frame thumbnails
  • Real-time Preview: See exactly what you’re trimming
  • Quality Options: Choose between speed and file size
  • Progress Tracking: Live progress during export
  • Responsive Design: Works on desktop and tablet
  • No Upload Required: Privacy-focused local processing

Performance Tips

  1. Use medium quality for balanced speed/quality
  2. Shorter clips process faster
  3. Avoid extremely large files (>500MB)
  4. Close other browser tabs during processing
  5. Use Chrome/Edge for best performance

Browser Support

  • Chrome 94+
  • Edge 94+
  • Firefox 93+
  • Safari 16.4+

Requires SharedArrayBuffer support (COOP/COEP headers).

Troubleshooting

FFmpeg won’t load:

  • Check console for COOP/COEP header errors
  • Ensure you’re using the Vite dev server
  • Try clearing browser cache

Video won’t upload:

  • Verify file format is supported
  • Check file size < 500MB
  • Ensure video isn’t corrupted

Export fails:

  • Try lower quality setting
  • Check available RAM
  • Reduce trim length

Conclusion

You now have a complete video trimmer running entirely in the browser. FFmpeg.wasm provides powerful video processing without servers. The visual timeline makes precise trimming intuitive, and the modern UI ensures a great user experience.

All code is production-ready with proper error handling, loading states, and responsive design. Customize the styling and features to match your needs!

Gautam Sharma

About Gautam Sharma

Full-stack developer and tech blogger sharing coding tutorials and best practices

Related Articles

React

React Webcam: Capture Photos and Record Videos in Browser

Complete guide to using webcam in React. Take photos, record videos, download captures, and handle camera permissions with working examples.

December 31, 2024
React

Getting Started with React Hooks in 2025

Learn how to use React Hooks effectively in your modern React applications with practical examples and best practices.

December 15, 2024
React

How to integrate jsPDF Library in React to Edit PDF in Browser

Quick guide to using jsPDF in React applications for creating and editing PDF documents directly in the browser.

December 29, 2024