I'm excited to share my journey of integrating Electron and React to develop a user-friendly interface for FFMPEG, a powerful multimedia framework. Typically, FFMPEG operates through a command-line interface, which can be daunting for many users. My goal was to create an application that makes FFMPEG accessible to those who prefer a graphical interface.

Setting Up the Environment

For a swift and efficient setup, I began by cloning the Electron-React Boilerplate. This boilerplate provided a pre-configured combination of Electron and React, along with Redux and Webpack, setting a solid foundation for the project. This choice allowed me to focus on building the unique features of my application, leveraging the boilerplate's stable and community-tested framework.

Main Process in Electron

Electron applications operate on two types of processes: the main process and renderer processes. In my app, the main process is responsible for managing the application's lifecycle, creating browser windows, and handling system events. I utilized Electron's BrowserWindow to create the application window and ipcMain for communication between the main and renderer processes.

Designing the Slider Interface

The slider was implemented as part of the React front-end. It's a graphical slider component that lets users choose a quality level ranging from 1 (highest quality) to 100 (lowest quality). This design choice was driven by the goal to make the application accessible to users who may not be familiar with the technical details of video encoding.

Quality slider UI

Translating Universal Quality to Codec-Specific Settings

The core functionality of this feature lies in its ability to translate a universal quality rating into codec-specific requirements. This is where the translateUniversalQualityToFormat function in the Electron backend comes into play. Depending on the selected output format (e.g., MP4, MKV, WebM), the function maps the quality level chosen on the slider to the appropriate settings for that particular format.

Here's how I did it:

function translateUniversalQualityToFormat(
  quality: number,
  format: T_VideoFormat,
): number {
  let min_value: number;
  let max_value: number;
  switch (format) {
    case 'mp4':
    case 'mkv':
      min_value = 0; // Best for H.264
      max_value = 51; // Worst for H.264
      break;
    case 'webm':
      min_value = 4; // Best for VP8
      max_value = 63; // Worst for VP8
      break;
    case 'mpg':
    case 'wmv':
    case 'flv':
      min_value = 500; // Lowest bitrate for lowest quality
      max_value = 100000; // Highest bitrate for highest quality
      break;
    case 'avi':
      min_value = 1; // Best for MPEG-2 and MPEG-4
      max_value = 31; // Worst for MPEG-2 and MPEG-4
      break;
    case 'mov':
      min_value = 0; // Best for H.264 in MOV
      max_value = 51; // Worst for H.264 in MOV
      break;
    default:
      throw new Error('Unknown format');
  }
  if (format === 'wmv') {
    const translated_value =
      min_value + ((max_value - min_value) * (100 - quality)) / 99;
    return Math.round(translated_value);
  }
  // Linear interpolation
  const translated_value =
    min_value + ((max_value - min_value) * (quality - 1)) / 99;
  // Round the value to get an integer
  return Math.round(translated_value);
}

Codec-Specific Quality Mapping

  • H.264 (MP4, MKV, MOV): For formats using the H.264 codec, the quality level is mapped to a CRF (Constant Rate Factor) value, where a lower CRF means higher quality. The scale is adjusted so that the slider's value translates effectively into the CRF range used by H.264.
  • VP8 (WebM): For WebM videos, the quality level adjusts the CRF and bitrate settings suitable for the VP8 codec. Special handling is implemented for two-pass encoding, which is a common practice for this format to achieve better quality and file size.
  • MPEG-4 (AVI): The MPEG-4 codec, often used in AVI containers, has its own quality metrics. The slider's value is mapped to this codec's quantizer scale.
  • Others (MPG, WMV, FLV): For other formats, the translation focuses on adjusting the bitrate, which directly influences the video quality and file size.

Building the FFMPEG Command

The function convertVideo in the Electron backend is where the FFmpeg command is constructed. The command is dynamically built based on several parameters passed from the front end, such as the file path, desired output format, output directory, and quality setting:

export const convertVideo = async (props: I_ConvertVideoProps) => {
  const { mainWindow = null, filePath, format, outputDir, quality = 1 } = props;
  const useFFmpeg = getFFmpegPath();
  const progressTempFilePath = getProgressTempFilePath();
  try {
    // Make sure format is in the list
    if (!formats.includes(format)) {
      throw new Error('Invalid format');
    }
    // Delete existing progress file
    try {
      await fs.promises.unlink(progressTempFilePath);
    } catch (err) {
      // Do nothing
    }
    const { newFilePath } = makeFilePaths(filePath, outputDir, format);
    const translatedQuality = translateUniversalQualityToFormat(
      quality,
      format,
    );
    let command = '';
    switch (format) {
      case 'mp4':
      case 'mkv':
        command = `${useFFmpeg} -i "${filePath}" -c:v libx264 -crf ${translatedQuality} -progress "${progressTempFilePath}" -c:a aac -y "${newFilePath}"`;
        break;
      case 'webm':
        {
          // First pass
          const passLogfile = path.join(os.tmpdir(), 'ffmpeg2pass.log');
          const firstPassCmd = `${useFFmpeg} -i "${filePath}" -c:v libvpx -b:v 1M -crf ${translatedQuality} -pass 1 -an -f webm -y -progress "${progressTempFilePath}" -passlogfile "${passLogfile}" ${getDiscardPath()}`;
          exec(firstPassCmd, (error) => {
            if (error) {
              console.error(`exec error on first pass: ${error}`);
              convertError(mainWindow, error.message);
              return;
            }
            // Second pass
            const secondPassCmd = `${useFFmpeg} -i "${filePath}" -c:v libvpx -b:v 1M -crf ${translatedQuality} -pass 2 -c:a libvorbis -y -progress "${progressTempFilePath}" -passlogfile "${passLogfile}" "${newFilePath}"`;
            exec(secondPassCmd, (error) => {
              if (error) {
                console.error(`exec error on second pass: ${error}`);
                convertError(mainWindow, error.message);
                return;
              }
              convertSuccess(mainWindow, newFilePath);
            });
          });
        }
        break;
      case 'mpg':
      case 'avi':
        command = `${useFFmpeg} -i "${filePath}" -c:v mpeg4 -q:v ${translatedQuality} -progress "${progressTempFilePath}" -c:a mp3 -y "${newFilePath}"`;
        break;
      case 'wmv':
        command = `${useFFmpeg} -i "${filePath}" -c:v wmv2 -b:v ${translatedQuality}k -progress "${progressTempFilePath}" -c:a wmav2 -y "${newFilePath}"`;
        break;
      case 'flv':
        command = `${useFFmpeg} -i "${filePath}" -c:v flv -b:v ${translatedQuality}k -progress "${progressTempFilePath}" -c:a mp3 -y "${newFilePath}"`;
        break;
      case 'mov':
        command = `${useFFmpeg} -i "${filePath}" -c:v libx264 -crf ${translatedQuality} -progress "${progressTempFilePath}" -c:a aac -y "${newFilePath}"`;
        break;
      default:
        return null;
    }

Special attention was given to the conversion process for WebM format videos, where I implemented a two-pass encoding strategy. This approach was chosen due to the VP8 codec's characteristics, which is commonly used in WebM files. The two-pass process is vital for achieving optimal balance between video quality and file size, a key concern in video encoding.

In the first pass, FFmpeg analyzes the video without producing an output file, gathering data on how best to allocate bits for efficient compression. This pass generates a log file that contains information about the video's complexity. In the second pass, this data is used to actually encode the video, allowing FFmpeg to optimize bit allocation across the entire video. This results in a higher quality video at a given bitrate compared to a single-pass encode.

Progress File Polling

A crucial part of tracking the conversion progress is the polling of the progress file. This is done via a setInterval function, which regularly reads the contents of the progress file. The getPercentageFromProgressData function parses this data to extract the current progress as a percentage. This percentage is then used to update the LinearProgress component from Material-UI, providing a visual representation of the conversion progress:

  // ########### Progress file polling ###########
  useEffect(() => {
    function getPercentageFromProgressData(progressData: string): number {
      if (!progressData) {
        return 0;
      }
      const totalDurationInSeconds = parseFloat(
        loadedFile!.info.streams[0].duration!,
      );
      // Get all matches
      const matches = [
        ...progressData.matchAll(/out_time=(\d+:\d+:\d+\.\d+)/g),
      ];
      // Extract the last match
      const lastMatch = matches[matches.length - 1];
      if (lastMatch && lastMatch[1]) {
        const [hours, minutes, seconds] = lastMatch[1]
          .split(':')
          .map(parseFloat);
        const currentDurationInSeconds = hours * 3600 + minutes * 60 + seconds;
        return (currentDurationInSeconds / totalDurationInSeconds!) * 100;
      }
      return 0;
    }
    const readProgressFile = async () => {
      const data = await window.electron.ipcRenderer.readFile(progressFile!);
      const percentage = getPercentageFromProgressData(data);
      setProgress(percentage);
    };
    if (!window.electron) return;
    let timer: NodeJS.Timeout | null = null;
    let timer2: NodeJS.Timeout | null = null;
    if (isConverting) {
      // Polling timer
      timer = setInterval(() => {
        readProgressFile();
      }, 1000);
      // Create progress for "preparing file" state
      timer2 = setInterval(() => {
        setPrepProgress((prev) => {
          if (prev < 100) {
            return prev + 1;
          }
          return 0;
        });
      }, 500);
    } else {
      if (timer) {
        clearInterval(timer);
      }
      if (timer2) {
        clearInterval(timer2);
      }
      setPrepProgress(0);
    }
    // Cleanup function
    return () => {
      if (timer) {
        clearInterval(timer);
      }
      if (timer2) {
        clearInterval(timer2);
      }
    };
  }, [isConverting]);
  // ########## End of progress file polling ##########

Here's the result:

Progress bar

Managing conversion progress in the UI was a critical part of creating a user-friendly application. By effectively utilizing React's state management and Electron's IPC, the application provides a seamless and responsive experience, keeping users informed and engaged throughout the video conversion process.


Try it Out for Yourself

You can download Spectravert for Windows x86, MacOS x86 or ARM64 and Linux x86 (App Image). I could do more but those are the only platforms I own. It's completely free, so enjoy!


Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.