How to use ffmpeg + canvas to write an online visual audio and video editing tool

Overview

This article will introduce how to use ffmpeg + canvas to write an online visual audio and video editing tool:

  • How to run ffmpeg in the browser to process audio and video;
  • How to use canvas to implement a video track;
  • How to combine tracks, players and ffmpeg to achieve video editing visualization;

Key words

ffmpeg, video, track, visualization, canvas

Effect screenshot

Experience address

videoCut(deployed in vercel, requires scientific Internet access)

code repository

img-generate (I have clicked in, please click a free star for me)

Background of the project

Reasons for making the project

In work and life, we often encounter some audio and video related problems, such as:

  • Want to capture a picture from a video;
  • Want to capture a specific segment from a long video;
  • Format conversion, extract audio from video;
  • Get video information such as frame rate;

If you analyze locally, you must first configure the environment and install analysis tools such as ffmpeg, which requires more preparation work. If you use wasm technology and run ffmpeg directly on the browser, there is no need for local installation and downloading, which will be more friendly to novice users.

Try it yourself - using ffmpeg in the browser

@ffmpeg/ffmpeg

npm address

This is a compiled package. The disadvantage is that it is relatively large and you need to wait a few seconds when loading for the first time; the advantage is that you can use it directly. Here we directly reference this package, and when optimization is needed later, we will consider compiling it ourselves or looking for a more suitable package.

api introduction

Just look at an official demo and with a few lines of script, you can complete a format conversion task.

import { writeFile } from 'fs/promises';
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';

// 设置参数
const ffmpeg = createFFmpeg({ log: true });

(async () => {
  // 加载 ffmpeg.wasm-core 脚本
  await ffmpeg.load();

  // 输入文件需要调用 FS 方法,这样 ffmpeg 才能够进行”消费“
  ffmpeg.FS('writeFile', 'test.avi', await fetchFile('./test.avi'));

  // 执行 ffmpeg 命令
  await ffmpeg.run('-i', 'test.avi', 'test.mp4');
  
  // node 端将生成的文件直接写到文件中
  await fs.promises.writeFile('./test.mp4', ffmpeg.FS('readFile', 'test.mp4'));
  process.exit(0);
})();

The approximate process is as follows

Initialize project

I am using umi here, which can be initialized through the following command

# 新建文件夹
mkdir myapp && cd myapp
# 初始化项目
yarn create @umijs/umi-app
# 安装依赖
yarn start

After completing the above steps, you can see the page effect on the browser.

Introduce @ffmpeg/ffmpeg

The installation package is very simple, just pass npm i @ffmpeg/ffmpeg , but during the introduction process, I encountered some problems

Question 1: Module parse failed: Unexpected token

webpack4

ERROR in ./node_modules/@ffmpeg/ffmpeg/src/browser/defaultOptions.js 7:68
Module parse failed: Unexpected token (7:68)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
|  */
| const corePath = typeof process !== 'undefined' && process.env.NODE_ENV === 'development'
>   ? new URL('/node_modules/@ffmpeg/core/dist/ffmpeg-core.js', import.meta.url).href
|   : `https://unpkg.com/@ffmpeg/core@${devDependencies['@ffmpeg/core'].substring(1)}/dist/ffmpeg-core.js`;
| 
 @ ./node_modules/@ffmpeg/ffmpeg/src/browser/index.js 1:23-50
 @ ./node_modules/@ffmpeg/ffmpeg/src/index.js
 @ ./src/pages/index.tsx
 @ ./src/.umi/core/routes.ts
 @ ./src/.umi/umi.ts
 @ multi ./node_modules/umi/node_modules/@umijs/preset-built-in/bundled/@pmmmwh/react-refresh-webpack-plugin/client/ReactRefreshEntry.js ./src/.umi/umi.ts

Solution 1: Change the import method

import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';

Change to

import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg/dist/ffmpeg.min.js';

At this time, the type will be lost. Just set it forcibly.

import { FFmpeg } from '@ffmpeg/ffmpeg';

const ffmpeg = createFFmpeg({
  ...
}) as FFmpeg;

Solution 2: Switch to webpack 5

umi switching webpack 5 is relatively simple, just configure it directly in .umirc 

 webpack5: {}

Then place the core file under public and set the parameters when creatingFFmpeg.

const ffmpeg = createFFmpeg({
  ...
  corePath: `${location.origin}/static/v0.11.0/ffmpeg-core.js`,
});

Question 2: SharedArrayBuffer is not defined

The cause of the problem is that ffmpeg uses SharedArrayBuffer, but SharedArrayBuffer has some restrictions due to security issues. If you want to use SharedArrayBuffer in the page, you need to set the page for cross-domain isolation; or set the token token as a temporary solution. (The specific reasonThe front-end FFmpeg? may not be ready yetThe introduction is more detailed, you can refer to it)

Solution: Set up cross-domain isolation

CORP can be set by setting two request headers in the page

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

In development, by setting up devServer, the problem can be solved.

 devServer: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },

Sort out the steps and run through the basic process

After successfully introducing the @ffmpeg/ffmpeg package, you can start developing functions. According to the principle of function priority, the functions should be run through first, and the process and UI can be optimized later.

Referenced several demos浏览器 ffmpeg and compiled a more comfortable process.

Upload files to be processed

Get the file to be uploaded and then call the FS method

const props = {
  ...,
  beforeUpload: async (file) => {
      const { name, size, type } = file;
      ffmpeg.FS('writeFile', name, await fetchFile(file));
      ...
      setOpInput(name);
      return false;
    },
}
...
<Upload {...props}>
  <Button icon={<UploadOutlined />}>选择文件</Button>
</Upload>

Excuting an order

After 输入, 输出文件, 执行脚本 and other parameters are filled in, you can merge the parameters and run ffmpeg 命令, the output result is obtained.

await ffmpeg.run(...allArgs.split(' '));

Take a look at the effect, it's no problem

Add a little optimization

The ffmpeg command is actually relatively difficult to remember, and it is not friendly for users to find it by themselves. So the expectation is to record all the more commonly used commands so that users can choose them directly.

export const getOp = (op: string, args?: IGetOp) => {
  // const { out = OUT_DEFAULT, input = IN_DEFAULT, timer } = args || {}
  let output = '';
  let resultOp = '';
  const { rangeLeft, rangeRight, input, out, timer } = args || {};

  switch (op) {
    case OP_NAME.screenshot:
      resultOp = `-i ${input} -ss ${timer}  -vframes 1 ${out}`;
      output = 'out.png';
      break;

    case OP_NAME.getMp3FromVideo:
      resultOp = ` -f mp3 -vn`;
      output = 'out.mp3';
      break;

    case OP_NAME.getInfo:
      resultOp = '';
      output = OUT_DEFAULT;
      break;

    case OP_NAME.custom:
      resultOp = '';
      output = OUT_DEFAULT;
      break;

    case OP_NAME.cutVideo:
      resultOp = `-ss ${rangeLeft} -to ${rangeRight} -i ${input} -c:v copy -c:a copy ${out}`;
      output = OUT_DEFAULT;
      break;

    default:
      resultOp = DEFAULT_ARGS;
      output = OUT_DEFAULT;
  }
  return [resultOp, output];
};

The effect is as shown below

Experience address

ffmpeg(deployed in vercel, requires scientific Internet access)

Video track (visual audio and video segmentation)

In order to implement可视化的音视频分割, a track and player need to be implemented. Set the segmentation points in a precise manner through the track, and see the video effect of the points in real time through the player. Here we refer to the open source project shWave to implement a simple track.

Overall analysis

If the track is split, it is divided into:

  • background
  • scale
  • Time pointer (pointing to the moment when the current video is playing)

Then we implement it in the order of 静态部分 -> 动态部分(参数传递) -> 动态部分(事件响应、缩放条) .

Static partial implementation

Add canvas to page

Tracks are implemented using canvas, so you must first add a canvas to the page

<canvas
    ref={$canvas}
    id="shcanvas"
    style={
   
   {
        height: '100%',
        width: '100%',
        zIndex: 0,
        pointerEvents: 'auto',
    }}
></canvas>

After the canvas is loaded, the drawing of the track begins.

useEffect(() => {
    if (!waveCanvas) { return }
    draw?.()
}, [waveCanvas, draw, currentTime, duration]);
 // 各种场景都有可能触发重新绘制

drawIn the function, the three elements mentioned (background, scale, time pointer) are drawn respectively.

const draw = () => {
    const ctx = waveCanvas && waveCanvas?.getContext("2d");
    if (!waveCanvas || !ctx) return;

    //绘制背景
    drawBackground(waveCanvas, ctx, backgroundColor);

    // 刻度尺
    drawRuler(waveCanvas, ctx, 1, duration)

    // 时间指针
    drawPointer({
        canvas: waveCanvas,
        ctx,
        pixelRatio: 1,
        duration,
        currentTime,
        color: pointerColor,
        pointerWidth,
    })
}

Draw the background (drawBackground)

export const drawBackground = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, backgroundColor = "#529393") => {
    if (!canvas || !ctx) return;
    const { width, height } = canvas;
    //清空上一次渲染
    ctx.clearRect(0, 0, width, height);
    //设置填充绘画的颜色
    ctx.fillStyle = backgroundColor;
    //填充出一个矩形 (绘制背景)
    ctx.fillRect(0, 0, width, height);
};

Show results

draw scale

The scale is to loop through, and then draw small rectangles of different lengths, and then under the whole second scale, there will be text displayed:

  • Whole second, longest;
  • 0.5 seconds, second;
  • 0.1 seconds, the shortest;

whole second

length is required秒长度 * 10, then iterate and draw the longest rectangle every 10 * 0.1. Among them, each time it is traversed, what changes is the position of x, and other parameters remain unchanged.

for (let index = 0; index < length; index += 1) {
        //十格间距
        if (index % 10 === 0) {
            // x , y , w, h
            ctx.fillRect(
            index * gap, 
            0, 
            pixelRatio, 
            fontHeight * pixelRatio);
        }
}

0.5 seconds

Everything else remains the same, only the length has changed.

else if (index % 5 === 0) {
    ctx.fillRect(index * gap, 0, pixelRatio, (fontHeight * pixelRatio) / 1.5);
}

0.1 seconds

Again, just shorter in length.

else {
    ctx.fillRect(index * gap, 0, pixelRatio, (fontHeight * pixelRatio) / 3);
}

duration text display text is the moment corresponding to reality,时刻 = begin + second

if (index % 10 === 0) {
    second += 1;

    ctx.fillText(
        // text x y maxWidth
        // time = 开始时间 + 遍历到的秒数
        durationToTime(begin + second).split(".")[0], // s
        gap * index - fontSize * pixelRatio * 2 + pixelRatio, // x
        fontTop * pixelRatio // y
    );
}

Timeline (timer)

The time scale is actually a rectangle, indicating the current moment of video playback, which is the time scale corresponding to currentTime.

const { width, height } = canvas;

const length = getLength(duration);

// 每 0.1 s 所对应的像素宽度。
const gap = getGap(width, length)

// 开始点
const begin = getBegin(currentTime, duration);

ctx.fillRect(
    Number(((currentTime - begin) * 10 * gap).toFixed(3)), // x
    0,  // y
    pointerWidth * pixelRatio, // width
    height, // height
)

By doing this, the effect of a static timeline is already available.

Parameter passing

Next, we pass all the variables in as parameters, so that the timeline can be animated. Here, we use storybook to view the real-time effect.

 argTypes: {
    currentTime: {
      control: {
        type: 'number',
      },
    },
    duration: {
      control: {
        type: 'number',
      },
    },
    backgroundColor: {
      control: {
        type: 'color',
      },
    },
    pointerWidth: {
      control: {
        type: 'number',
      },
    },
    pointerColor: {
      control: {
        type: 'color',
      },
     },
  },

The effect is as shown in the figure. Modify the input parameters below and you will see the real-time effect above.

incident response

zoom bar

shwave is scaled by adjusting the duration. One problem with this solution is that when zooming in, the duration at the back cannot be seen, and only the duration at the front can be zoomed in. I think a more appropriate way would be to add a scaling ratio field to control the length of the track. Add a scroll bar below. After placing it on the track, you can scroll to view the subsequent time.
Use Slider + InputNumber to implement a zoom bar that can be dragged and input.
 

 <Col span={5}>
      <Slider
          min={1}
          max={20}
          onChange={(value: number) => setRatio(value)}
          value={ratio}
          step={0.1} />
  </Col>
  <Col span={8}>
      <InputNumber
          min={1}
          max={20}
          style={
   
   { margin: '0 16px' }}
          step={0.1}
          value={ratio}
          onChange={(value: number | null) => setRatio(value || 1)}
      />
  </Col>

track click

After clicking on the track, you need to calculate the corresponding time of the click, and then reset the currentTime

First, monitor the canvas

useEffect(() => {
  if (waveCanvas === null) {
      return
  }
  //设置canvas点击监听
  waveCanvas.addEventListener("click", onCanavsClick);
  return () => {
      waveCanvas.removeEventListener("click", onCanavsClick);
  }
}, [waveCanvas])

After listening to the event click, directly calculate the corresponding time and set it.

const onCanavsClick = (event: MouseEvent) => {
    const time = computeTimeFromEvent(event);
    if (currentTime !== time) {
        click?.(time);
    }
};

The offset moment is the calculation step:

  • Calculate the pixel size occupied by each;
  • Calculate the offset size of the click position from the left side;
  • Based on the offset, calculate the corresponding moment in the timeline;
const computeTimeFromEvent = (event: MouseEvent) => {
    if (!waveCanvas || !$shwave.current) {
        return 0
    }
    const { clientWidth: width } = waveCanvas;// canvas 实际宽度
    const pixelRatio = window.devicePixelRatio; // 1
    const length = getLength(duration); // 100
    const gap = getGap(width, length); // 0.1 s 所占用的像素 宽度

    // 偏移的宽度
    const left = event.pageX - $shwave.current.offsetLeft / pixelRatio;

    const begin = getBegin(currentTime, duration);

    // left 在 时间中的位置
    const time = clamp(
        ((left / gap) * pixelRatio) / 10 + begin,
        begin,
        begin + duration
    );

    return time;
}

player

After the track is completed, a video player must be implemented to see the video effect of the points in real time.

The player here is a video element. In order to realize the linkage between the track and the player, it is necessary to monitor some video events and set the currentTime during playback;

set video

First place a video element in the interface

const videoRef = useRef<HTMLVideoElement | null>(null)

<video controls width={400} height={300} src={url} ref={videoRef}></video>

Then monitor, set duration in canplay event, and set currentTime in ontimeupdate.

useEffect(() => {
    if (!videoRef.current) {
        return
    }
    videoRef.current.ontimeupdate = () => {
        setCurrentTime?.(videoRef.current?.currentTime)
    }
    videoRef.current.addEventListener('canplay', () => {
        setDuration?.(videoRef.current?.duration)
    })
}, [url])

Basic effect display

Visual video editing

After ffmpeg runs in the browser, track and player linkage are completed, we can realize the function of visual video editing.

video segmentation

In the ffmpeg command, to implement the editing function, you need to provide the start point time and end point time.

ffmpeg -ss 00:17:24  -to 02:19:31 -i inputVideo.mp4 -c:v copy -c:a copy outputVideo.mp4

So a complete process should look like this:

Effect screenshot

Video screenshot

After playing the video to the specified location, we can take a screenshot of the video by executing the ffmpeg command,

ffmpeg -i ${input} -ss ${timer}  -vframes 1 ${out}

Effect screenshot

Other details

Save uploaded files locally

After uploading a file, if you refresh the page, the uploaded file will be lost, so it is necessary to add a local cache. This way, after refreshing the page by mistake, you can still get the previously uploaded files. IndexDb is chosen to store files because the storage space is large enough. The more commonly used localStorage storage space is relatively small, with a maximum storage space of only 5M.

Initialization

In the react project, there are some ready-made libraries that can be used directly, the most commonly used one is dexie. We first introduce dexie to define the database table.

// db.ts
import Dexie, { Table } from 'dexie';

export interface FileData {
    id?: number;
    name: string;
    type: string;
    data: File;
}

export class FileDexie extends Dexie {

    files!: Table<FileData>;


    constructor() {
        super('myDatabase');
        this.version(1).stores({
            files: '++id, name, type, data' // Primary key and indexed props
        });
    }
}

export const dbFileDexie = new FileDexie();

Save uploaded file

After indexDb initialization is completed, we start uploading files, and then call the  function in the upload component's onChange function to save the file to in indexDb. db.files.put

 const handleMediaChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        const file = event.target.files?.[0];
        if (file) {
            try {
                const { name } = file;
                db.files.put({ name, type: file.type, data: file })
            } catch (error) {
                console.error('handleMediaChange error', error)
            }
        };
    }
    
 <input type="file" onChange={handleMediaChange} />

Where used, call useLiveQuery to query.

import { useLiveQuery } from 'dexie-react-hooks';
import { dbFileDexie as db } from '@/db'

const mediaList = useLiveQuery(
    () => db.files?.toArray?.()
);

Production environment deployment

Since ffmpeg relies on cross-source isolation, response headers need to be configured, and deployment on github is not possible. So I found another free deployment platform vercel . The disadvantage is that domestic Internet access needs to be scientific. There are many tutorials on vercel deployment, so I won’t go into details. Just share vercel.json, which implements setting response headers and supporting history routing.
 

{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
        { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
      ]
    }
  ]
}

ALL

To achieve this, I have actually just completed a demo. But I feel like there’s still a lot more that can be done. If you further improve this project, it can provide convenience for your future work and life and make it a useful tool.

  • Style optimization
  • Audio visualization, intercepting audio clips
  • Test material generation
  • Support subtitle function (separate, add)
  • Compile ffmpeg by yourself, reduce the package size, and remove the dependency on SharedArrayBuffer.


reference

FFmpeg on the front end? Probably not ready yet

Use ffmpeg.wasm pure front-end to achieve multi-audio and video synthesis

Front-end video frame extraction ffmpeg + Webassembly

Project Reference

github.com/cs8425/ffmp…

github.com/xiguaxigua/…

github.com/Shirtiny/sh…

Original text How to use ffmpeg + canvas to write an online visual audio and video editing tool

★The business card at the end of the article allows you to receive free audio and video development learning materials, including (FFmpeg, webRTC, rtmp, hls, rtsp, ffplay, srs) and audio and video learning roadmap, etc.

See below! ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

 

Guess you like

Origin blog.csdn.net/yinshipin007/article/details/134619447