WebAssembly in read world, to get video or audio duration

May 30, 2023

  • webAssembly
  • frontend
有任何問題,我能幫上忙的話都能到 github 發 issue 問我。

WebAssembly in read world

最近為何會使用到 wasm 呢?原因是我們要做音檔上傳,那需要做到音檔長度的限制,所以我們需要讀取檔案內的資訊,聽起來頗簡單的齁,但如果該音檔是瀏覽器無法支援的該怎麼辦呢?

<div>
  <video src='./audio.m4a' controls></video>
</div> 

<script>
  (async () => {
    const video = document.querySelector('video')
  
    video.addEventListener('loadedmetadata', () => {
      console.log(video.duration)
    })
  
    // if browser can't support this video or audio, it will throw an error
    video.addEventListener('error', (e) => {
      console.log(`code: ${e.target.error.code}`)
      console.log(`message: ${e.target.error.message}`)
    })
  })()
</script>

正常情況下我們可以使用 loadedmetadata 事件去抓取影音檔的時間,但如果該檔案是瀏覽器無法支援的,錯誤就會透過 error 事件顯示出來,錯誤範例如下:

code: 4
message: "DEMUXER_ERROR_NO_SUPPORTED_STREAMS: FFmpegDemuxer: no supported streams"

Brainstorming time

所以前端無法使用原生的方式進行影音檔長度驗證,無可奈何之下,前端只能跟後端提說,必須要將檔案上傳到伺服器進行驗證,畢竟後端可以使用 fs…等的方式去解析檔案,聽起來還滿合情合理的齁?但是當檔案大小是好幾 GB 時,這個操作是正確的嗎?也就是客戶上傳等了好幾分鐘,最後伺服器回給客戶講說檔案長度太長,這樣的使用者體驗是不是很差呢?

試想如果能讓前端可以直接讀取檔案內容,並且可以取得檔案長度,重點因為 wasm 的優勢,執行速度相當的快,這樣的話,就可以在客戶上傳前,先驗證檔案長度,如果檔案長度太長,也可以在前端就直接拒絕上傳並告知客戶,不用等待伺服器回應,這樣的體驗是不是很好呢?

It’s showtime

wasm 的優勢就顯現出來了,我們可以使用 ffprobe-wasm 套件,下方是 react 範例:

// pnpm create vite react-wasm --template react-ts
import React, { useEffect, ChangeEvent } from 'react'
import './App.css'
import { FFprobeWorker } from 'ffprobe-wasm' // pnpm i ffprobe-wasm

const worker = new FFprobeWorker()

function App() {
  const [file, setFile] = React.useState<File | null>(null)

  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    setFile(e.target.files?.[0] || null)
  }

  useEffect(() => {
    if (file) {
      const videoEl = document.createElement('video')
      const videoUrl = URL.createObjectURL(file)

      // if error, this method will not trigger
      const handleLoadedMetadata = () => {
        console.log('loaded metadata', videoEl.duration)
      }

      const handleError = async (e: ErrorEvent) => {
        const target = e.target as HTMLVideoElement
        console.log('code', target.error?.code)
        console.log('message', target.error?.message)

        if (target.error?.code === 4) {
          const fileInfo = await worker.getFileInfo(file)
          const duration = fileInfo.format?.duration;
          console.log(duration) // you can get duration here
        }
      }

      videoEl.addEventListener('loadedmetadata', handleLoadedMetadata)
      videoEl.addEventListener('error', handleError)

      videoEl.src = videoUrl

      return () => {
        videoEl.removeEventListener('loadedmetadata', handleLoadedMetadata)
        videoEl.removeEventListener('error', handleError)
      }
    }
  }, [file])

  return (
    <div>
      <input type="file" onChange={handleFileChange} />
    </div>
  )
}

export default App
image

由於該套件需使用到 SharedArrayBuffer,需添加額外的 config,可以參考 MDN

基本上,就是要設定 "crossOriginEmbedderPolicy": "require-corp""crossOriginOpenerPolicy": "same-origin",設定好後,就可以在本地端開發時使用了。

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    {
      name: "configure-response-headers",
      configureServer: (server) => {
        server.middlewares.use((_req, res, next) => {
          res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
          res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
          next();
        });
    },
  }],
})

下方提供 vue-cli 設定 header 的方式,使用 webpack 基本上也是一樣的操作,可以參考一下:

// vue.config.js
'use strict';

module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        "@": resolve("src"),
      },
    },
  },
  devServer: {
    // ref: https://github.com/ffmpegwasm/ffmpeg.wasm#installation to fix SharedArrayBuffer error
    headers: {
      "Cross-Origin-Embedder-Policy": "require-corp",
      "Cross-Origin-Opener-Policy": "same-origin",
    },
  },
};

補充一下,由於 crossOriginOpenerPolicy 要設置成功,必須要是 localhost 或著 https 才行,但是有些公司內部只走 http 會導致問題,這邊要注意一下。

如果後台是用 nodejs express 的話,可以直接安裝套件 helmet,來加入上述 SharedArrayBuffer 所需要的 header。

import express from 'express'
import helmet from 'helmet'

const app = express()

// use helmet middleware
app.use(helmet({
  // crossOriginOpenerPolicy: false, // disable crossOriginOpenerPolicy
}))

// if not using helmet, you can use below code
app.use(function(req, res, next) {
  res.header("Cross-Origin-Embedder-Policy", "require-corp");
  res.header("Cross-Origin-Opener-Policy", "same-origin");

  next();
});

如果是 nginx 的話,範例如下:

# nginx.conf
location / {
  add_header 'Cross-Origin-Embedder-Policy' 'require-corp';
  add_header 'Cross-Origin-Opener-Policy' 'same-origin';
}

Conclusion

我自己認為 wasm 會越來越頻繁並使用在網頁建置上面,以往需要到後端做處理的事情,一部分會慢慢的被前端取代掉,伺服器的負擔也可以有所降低,是工程師樂見的,我自己目前也在慢慢學習 rust yew 相關方面的知識,期許自己持續進步嚕。


Mayvis Chen
你好,我是 Mayvis Chen
住在台北,會點前端,會點後端,熱愛分享新知及愛喝奶茶的全端打雜工程師。