LoginSignup
3
1

More than 1 year has passed since last update.

はじめての Zoom Video SDK for Web - 実装編

Last updated at Posted at 2022-12-30

■ 前置き

VideoSDKはZoomの通話品質をそのままにカスタマイズ可能な映像音声コミュニケーションの実装を支援します。 本編ではブラウザで利用可能な「Zoom VideoSDK for Web」を主としてChromeの環境での実装を解説しています。 尚、トークン生成を経て通話が可能になりますので、サーバサイドでのトークン生成に必要なKey/SecretをMarketplaceから事前に取得しておく必要があります。 詳しくはこちらを参照ください。

以下サーバ・サイドでのトークン発行処理とクライアント・サイドでの映像音声処理をそれぞれに分けて解説しています。(2022/12)

■ サーバ・サイドの実装について

以下Node.jsの環境で解説していきます。

1. まずは、サーバ側の準備を始めていきます。ここではポート「3001」を指定しています。

index.js
const express = require('express')
const bodyParser = require('body-parser')
const fs = require('fs')
const cors = require('cors')
const KJUR = require('jsrsasign')

const app = express()
const port = 3001
const path = require('path')
require('dotenv').config({ path: path.join(__dirname, '.env') })

2. 続いてSharedArrayBufferに対応すべく「Cross-Origin Isolation」を有効にしていきます。

app.use(function(req, res, next) {
  res.header("Cross-Origin-Embedder-Policy", "require-corp")
  res.header("Cross-Origin-Opener-Policy", "same-origin")
  next()
})

3. 今回はクライアントからのGETリクエストに対しては「public」フォルダ配下を参照させ、POSTリクエストが来た際にトークンを返す実装を含めていきます。
.envファイルから「ZOOM_VSDK_KEY」、「ZOOM_VSDK_SECRET」を読み込んでJWTフォーマットでトークンを生成しレンスポンスの中で返す仕組みをとっています。

app.use(express.static(path.join(__dirname, 'public')))
app.use(bodyParser.json(), cors());

app.post('/', (req, res) => {

  const iat = Math.floor(new Date().getTime() / 1000)
  const exp = iat + 60 * 60 * 2

  const oHeader = { alg: 'HS256', typ: 'JWT' }

  const oPayload = {
    app_key: process.env.ZOOM_VSDK_KEY,
    tpc: req.body.topic,
    role_type: req.body.role,
    pwd: req.body.password,
    iat: iat,
    exp: exp,
  }

  const sHeader = JSON.stringify(oHeader)
  const sPayload = JSON.stringify(oPayload)
  const signature = KJUR.jws.JWS.sign('HS256', sHeader, sPayload, process.env.ZOOM_VSDK_SECRET)

  res.json({
    signature: signature
  })
})

4. 最後に起動できるようにします。

app.listen(port, () => console.log(`Zoom Video SDK for Web Sample. port: ${port}!`))

5. サーバ側で使用する.envファイルは下記のように記載して同じディレクトリに保存しておきます。

.env
ZOOM_VSDK_KEY=Marketpalceで発行されたSDK Key
ZOOM_VSDK_SECRET=Marketpalceで発行されているSDK Secret

■ クライアント・サイドの実装について

今回のクライアントには映像音声及びPC画面の共有の送受信といった基本機能を含む最低限の実行ボタンやエレメントを最低限のUIデザインで実装していきます。

スクリーンショット 2022-12-30 19.17.17.png

1.今回はCDNを利用するのでindex.html内にURLをバージョンと合わせて指定し、「js」フォルダ内に配置予定の実行ファイルを別途明示しておきます。

  <script src="https://source.zoom.us/videosdk/zoom-video-{VideoSDKのバージョン}.min.js"></script>
...
  <script src="./js/index.js"></script>

「public」フォルダ内にindex.htmlを作成して上記に必要な部位を含めていきます。
*2022年12月現在の最新バージョン「1.5.5」を明示しています。

public/index.html
<html>
  <head>
    <meta charset="utf-8">
    <title>VideoSDK Sample CDN</title>
    <script src="https://source.zoom.us/videosdk/zoom-video-1.5.5.min.js"></script>
    <style>
    .video-canvas {
      background: rgba(0, 0, 0, 1);
      margin: 1px;
      border-radius: 10px;
      border: 1px solid rgba(0, 0, 0, 1);
    }
    </style>
  </head>
  <body>
<p>
  Display Name: <input type="text" id="user_name" maxLength="20" placeholder="Name" value="User01" required>
  Session Name: <input type="text" id="session_topic" maxLength="200" style="width:150px" placeholder="Session Topic" value="vsdkmeeting" required>
  Session Password: <input type="text" id="session_pwd" style="width:150px" maxLength="32" placeholder="Session Password" value="123456">
  <form id="join-role">
    <input type="radio" name="joinRole" value="0" checked> Participant
    <input type="radio" name="joinRole" value="1"> Host
  </form>
</p>
<p>
  Session:
  <button id="join-button">Join/Start</button>
</p>

<hr/>

<p>
  Leave/End:
  <button id="leave-button">Leave/End</button>
</p>
<p>
  Audio:
  <button id="audio-start-button">Audio Start</button>

  <button id="audio-stop-button">Audio Stop</button>

  Microphone:
  <button id="mic-button">Mute/UnMute</button>
</p>
<p>
  Camera:
  <button id="camera-button">Start/Stop</button>

  Switch Camera:
  <button id="camera-switch-button">Switch</button>
</p>
<p>
  ScreenShare:
  <button id="screenshare-button">Start/Stop</button>
</p>
<p>
  EnableQOS:
  <button id="qos-button">Enable/Disable QOS</button>
</p>

<hr/>

  <p>Video:</p>
  <canvas id="far-video-canvas" class="video-canvas" width="640" height="360" style="background-color:black; height:360px; width:640px;"></canvas>
  <canvas id="self-video-canvas" class="video-canvas" width="160" height="90" style="background-color:black; height:90px; width:160px;"></canvas>

  <p>ScreenShare:</p>
  <canvas id="far-screenshare-canvas" class="video-canvas" width="640" height="360" style="background-color:black; height:360px; width:640px;"></canvas>
  <video id="self-ScreenShare-video" class="video-canvas" width="160" height="90" style="background-color:black; height:90px; width:160px;"></video>

  <script src="./js/index.js"></script>

</body>
</html>

2.まず、最初に変数を明示しておきます。

let ZoomVideo
let client
let stream
let videoDecode
let videoEncode
let audioDecode
let audioEncode
let shareDecode
let shareEncode

3.次に各ボタンに対してのイベントリスナーを明示していきます。

document.addEventListener("DOMContentLoaded", function() {
  document.getElementById('user_name').value = "User" + Math.floor(Math.random() * 100)
  document.getElementById('join-button').addEventListener('click', joinSession)
  document.getElementById('leave-button').addEventListener('click', leaveSession)
  document.getElementById('audio-start-button').addEventListener('click', audioStart)
  document.getElementById('audio-stop-button').addEventListener('click', audioStop)
  document.getElementById('mic-button').addEventListener('click', micMuteUnmute)
  document.getElementById('camera-button').addEventListener('click', cameraStartStop)
  document.getElementById('camera-switch-button').addEventListener('click', cameraSwitch)
  document.getElementById('screenshare-button').addEventListener('click', screenShare)
  document.getElementById('qos-button').addEventListener('click', enableQOS)
  console.log('DOMContentLoaded')
})

4. 接続を開始する際に必ずSignatureが必要となります。サーバ側から取得できるようにfunctionを用意しておきます。

// GET SIGNATURE FOR VSDK FOR WEB
function getSignature(topic, role, password) {
    return new Promise(function (resolve, reject) {
        let xhr = new XMLHttpRequest()
        xhr.open('POST', './', true)
        xhr.setRequestHeader('content-type', 'application/json')
        xhr.onload = function () {
            if (this.status >= 200 && this.status < 300) {
                const obj = JSON.parse(xhr.response)
                resolve(obj.signature)
            } else {
                reject({
                    status: this.status,
                    statusText: xhr.statusText
                })
            }
        }
        xhr.onerror = function () {
            reject({
                status: this.status,
                statusText: xhr.statusText
            });
        };
        const body = JSON.parse('{}')
        body["topic"] = topic
        body["role"] = parseInt(role)
        body["password"] = password
        xhr.send(JSON.stringify(body))
    })
}

5. 次にVideoSDKのクライアントを生成、初期化、接続までを明示します。
この際に通話中のイベントを取得できるように含めることができます。
特にエンコーダー、デコーダーの状態を控えておくことでデバイスやネットワーク遅延が起因して映像音声の開始処理の失敗を防ぐことが可能になります。

//CREATE CLIENT, INIT THEN JOIN
async function joinSession() {

  //CREATE VIDEO SDK CLIENT
  ZoomVideo = window.WebVideoSDK.default
  client = ZoomVideo.createClient()

  //INIT VIDEO SDK CLIENT
  client.init('en-US', 'CDN')

    //LISTEN FOR CONNECTION STATUS
  client.on('connection-change', (payload) => {
   console.log("Connection Change: ", payload)
   if(payload.state == "Closed"){
     location.reload()
   }
  })

  //LISTEN MEDIA ENCODER DECODER STATE
  client.on('media-sdk-change', (payload) => {
      console.log("media-sdk-change: " + JSON.stringify(payload))
      if (payload.type === 'video' && payload.result === 'success') {
        if (payload.action === 'encode') {
          // encode for sending video stream
          videoEncode = true
        } else if (payload.action === 'decode') {
          // decode for receiving video stream
          videoDecode = true
        }
      }
      if (payload.type === 'audio' && payload.result === 'success') {
        if (payload.action === 'encode') {
          // encode for sending audio stream (speak)
          audioEncode = true
        } else if (payload.action === 'decode') {
          // decode for receiving audio stream (hear)
          audioDecode = true
        }
      }
      if (payload.type === 'share' && payload.result === 'success') {
        if (payload.action === 'encode') {
          // encode for sending share stream
          shareEncode = true
        } else if (payload.action === 'decode') {
          // decode for receiving share stream
          shareDecode = true
        }
      }
  })

  //GET PARAMETERS AND JOIN VSDK SESSION
  let topic = document.getElementById('session_topic').value
  let userName = document.getElementById('user_name').value
  let password = document.getElementById('session_pwd').value
  let role = document.getElementById('join-role').elements["joinRole"].value

  let token = await getSignature(topic, role, password)
  console.log(token)

  client.join(topic, token, userName, password).then(() => {
    stream = client.getMediaStream()
  }).catch((error) => {
    console.log(error)
  })

}

6. 以下切断処理についてですが、ホスト(Host)は自身の切断のみまたはセッション(会議)自体を終了できる一方、参加者(Participant)は自身の切断だけが許容となります。
ここではホストが切断する時にはセッション自体を終了する実装としています。

//LEAVE OR END SESSION
function leaveSession() {
  var n = client.getCurrentUserInfo()
  console.log("isHost: " + n.isHost)
  if(n.isHost){
    client.leave(true)
  }else{
    client.leave()
  }
}

7. 音声処理を実行すると自動的に利用可能なマイク、スピーカーを選択し開始されます。ただし、何らか理由でエンコーダー、デコーダーの開始に遅延が発生している場合には処理に失敗してしまうケースが考えられるため、ここではデコーダーが準備できるまで状態を待機する実装を含めています。
*マイクのミュート(muteAudio/unmuteAudio)は個別に実行可能です。

 //AUDIO START
 async function audioStart() {
   if(audioEncode && audioDecode){
     try{
       await stream.startAudio()
       console.log("audioStart")
     } catch (e){
       console.log(e)
     }
   } else {
      console.log('Audio encoder or decoder has not finished initializing')
      waitForAudioDecoder(500)
   }
 }

 //AUDIO STOP
 async function audioStop() {
   try{
     await stream.stopAudio()
   } catch (e){
     console.log(e)
   }
   console.log("audioStop")
 }

 //MIC MUTE UNMUTE
 function micMuteUnmute() {
   if(!stream.isAudioMuted()){
     stream.muteAudio()
   }else{
     stream.unmuteAudio()
   }
   console.log("isAudioMuted: " + stream.isAudioMuted())
 }

 //RECEIVE AUDIO
 async function waitForAudioDecoder(ms){
 let len = 10
  for (let i = 0; i < len; i++) {
   await sleep(ms)
   console.log("Trying to wait for audio decoder: " + i)
    if(audioDecode){
      await stream.startAudio()
      console.log("audioStart")
      break
    }
  }
 }

 //SLEEP(WAIT)
 function sleep(ms) {
   return new Promise((resolve) => {
     setTimeout(resolve, ms)
   })
 }

8.続いて映像処理についてになります。
まずは、ローカルカメラの起動と表示を実装していきます。
今回はChrome/Edgeを使用する前提なのでhtmlで指定したCanvasエレメント(self-video-canvas)でレンダリングします。

//LOCAL CAMERA START STOP
async function cameraStartStop() {

  let isVideoOn = await stream.isCapturingVideo()
  console.log("cameraStartStop isCapturingVideo: " + isVideoOn)
  
    let n = client.getCurrentUserInfo()
  console.log("getCurrentUserInfo: ", n)

  let selfId = n.userId
  console.log("selfId: ", selfId)

  if(!isVideoOn){
    toggleSelfVideo(stream, selfId, true)
  }else{
    toggleSelfVideo(stream, selfId, false)
  }

}

//TOGGLE NEAR END VIDEO ON CANVAS
const toggleSelfVideo = async (mediaStream, userId, isVideoOn) => {
    let SELF_VIDEO_CANVAS = document.getElementById('self-video-canvas')
    if (isVideoOn) {
        console.log("toggleSelfVideo start")
        await mediaStream.startVideo()
        await mediaStream.renderVideo(
            SELF_VIDEO_CANVAS,
            userId,
            160,   // Size Width
            90,    // Size Height
            0,     // Starting point x
            0,     // Starting point y
            0      // Video Quality 0:90p, 1:180p, 2:360p, 3:720p
        );
    } else {
        console.log("toggleSelfVideo stop")
        await mediaStream.stopVideo()
        await mediaStream.stopRenderVideo(SELF_VIDEO_CANVAS, userId)
        await mediaStream.clearVideoCanvas(SELF_VIDEO_CANVAS)
    }
}

9.複数カメラが接続されている場合は切り替えることも可能です。ここでは一覧(deviceId)を取得して実行毎に次のカメラを選択するように実装しています。

//SWITCH CAMERA
let currentCamera = 0
async function cameraSwitch() {

  let activeCameraId = stream.getActiveCamera()
  console.log("Current camera source: " + activeCameraId)

  let cameras = stream.getCameraList()
  console.log("Selectable WebCam source: " + JSON.stringify(cameras))

  try{
    console.log("currentCamera: " + currentCamera)
    console.log("cameras.length: " + cameras.length)
    if(currentCamera == 0 || currentCamera+1 < cameras.length){
      currentCamera = currentCamera+1
    }else{
      currentCamera = 0
    }
    await stream.switchCamera(cameras[currentCamera].deviceId)
  } catch (e){
    console.log(e);
  }
}

10. 次は受信映像の処理を含めていきます。映像の受信側は送信側と異なり相手側がいつ送信するのか開始するタイミングが分かりません。そのため接続時(joinSession)に指定可能な「peer-video-state-change」(イベント)をトリガーにしていきます。また、音声受信同様、エンコーダーが準備できていないケースに備えてステータスの待機も含めています。
送信時同様Chrome/Edgeを前提の実装になるので送信用のCanvas(far-video-canvas)にてレンダリングしていきます。ただし、3拠点での接続を許容したいので相手側を表示するエレメントでは左右に分割表示するような実装としています。

  //LISETN TO FAREND VIDEO STATUS
  client.on('peer-video-state-change', (payload) => {
   console.log("peer-video-state-change: " + JSON.stringify(payload))
   if (payload.action === 'Start') {
     if(videoDecode){
       toggleFarVideo(stream, payload.userId, true)
     }else{
       console.log("wait untill videoDecode gets enabled")
       waitForVideoDecoder(500, payload.userId)
     }

   } else if (payload.action === 'Stop') {
     toggleFarVideo(stream, payload.userId, false)
   }
  })

....

  //WAIT FOR VIDEO DECODER
  async function waitForVideoDecoder(ms, userid){
  let len = 10
   for (let i = 0; i < len; i++) {
    await sleep(ms)
    console.log("waiting for video decoder: " + i)
     if(videoDecode){
       toggleFarVideo(stream, userid, true)
       break
     }
   }
  }

  //SLEEP(WAIT)
  function sleep(ms) {
    return new Promise((resolve) => {
      setTimeout(resolve, ms)
    })
  }

const toggleFarVideo = async (mediaStream, userId, isVideoOn) => {
  var FAR_VIDEO_CANVAS = document.getElementById('far-video-canvas')
  let participants = client.getAllUser()
  console.log("participants: ", participants)
  console.log("participants.length: ", participants.length)

  if (isVideoOn) {
    if(participants.length == 2) {
      if(participants[1].bVideoOn){
        await mediaStream.renderVideo(FAR_VIDEO_CANVAS,participants[1].userId,640/2,360/2,0,90,2)
      }
    }
    if(participants.length == 3) {
      if(participants[1].bVideoOn){
        await mediaStream.renderVideo(FAR_VIDEO_CANVAS,participants[1].userId,640/2,360/2,0,90,2)
      }
      if(participants[2].bVideoOn){
        await mediaStream.renderVideo(FAR_VIDEO_CANVAS,participants[2].userId,640/2,360/2,320,90,2)
      }
    }
      console.log("toggleFarVideo start")
  } else {
      console.log("toggleFarVideo stop")
      await mediaStream.stopRenderVideo(FAR_VIDEO_CANVAS, userId)
      await mediaStream.clearVideoCanvas(FAR_VIDEO_CANVAS)
  }
}

11.続いて画面共有の実装になります。 
まずは自身からの開始ですが、ChromeではレンダリングにVideoエレメントを使用します。

//START STOP SCREENSHARE
let isSharing = false
async function screenShare() {
  console.log("screenShare Click")
  let shareVideo = document.getElementById('self-ScreenShare-video') // use video element for Chrome, Edge (use Canvas element for Forefox)
  try {
    console.log("isSharing: " + isSharing)
    if(!isSharing){
      isSharing = true;
      stream.startShareScreen(shareVideo)
    }else{
      stream.stopShareScreen()
      isSharing = false;
    }
  } catch (e) {
      console.error('Error cannot start/stop screensharing', e)
  }
}

12.画面共有の受信側については、カメラ映像の受信時と同様イベントをトリガーにレンダリングに繋げます。接続時(joinSession)に指定可能な「active-share-change」(イベント)をトリガーにできます。
また、音声受信同様、エンコーダーが準備できていないケースに備えてステータスの待機も含めています。

  //LISETEN FAREND SCREENSHARE STATUS
  client.on('active-share-change', (payload) => {
      console.log(`ScreenShare: active-share-change`, payload)
      console.log("ScreenShare active-share-change state: " + payload.state)
      if(payload.state == "Active"){
        console.log("state: " + payload.state)
        console.log("userId: " + payload.userId)
        if(shareDecode){
          let shareCanvas = document.getElementById('far-screenshare-canvas')
          stream.startShareView(shareCanvas, payload.userId)
        }else{
          console.log("wait untill shareDecode gets enabled")
          waitForShareDecoder(500, payload.userId)
        }
      }else if (payload.state == "Inactive"){
        stream.stopShareView();
        let shareCanvas = document.getElementById('far-screenshare-canvas')
        stream.clearVideoCanvas(shareCanvas)
      }
  });

...
  //WAIT FOR SHARE DECODER
  async function waitForShareDecoder(ms, userid){
  let len = 10
   for (let i = 0; i < len; i++) {
    await sleep(ms)
    console.log("Trying to wait for share decoder: " + i)
     if(shareDecode){
       let shareCanvas = document.getElementById('screenshare-canvas')
       stream.startShareView(shareCanvas, userid)
       break
     }
   }
  }

  //SLEEP(WAIT)
  function sleep(ms) {
    return new Promise((resolve) => {
      setTimeout(resolve, ms)
    })
  }

13.通話中の品質確認が必要な場合があります。接続時(joinSession)に指定可能な「audio-statistic-data-change/video-statistic-data-change」(イベント)が発火された際にコンソールログに書き出す実装を含めています。
イベントを有効にするためには、以下のように「qos-button」(ボタン)を明示的にクリック後、「subscribeAudioStatisticData/subscribeVideoStatisticData」をサブスクライブする必要があります。

  //STRIGGER WHEN EVER QOS STATISTIC GETS ENABLED(SUBSCRIBED)
  client.on('audio-statistic-data-change', (payload) => {
     console.log('audio-statistic-data-change', payload)
  })
  client.on('video-statistic-data-change', (payload) => {
     console.log('video-statistic-data-change', payload)
  })

...

  //ENABLE DISABLE QOS STATISTICS
  var isEnableQOS = false
  async function enableQOS() {
    if(!isEnableQOS){
      try{
        await stream.subscribeAudioStatisticData()
        await stream.subscribeVideoStatisticData()
        isEnableQOS = true
      } catch (error)  {
        console.log(error)
      }
    }else{
      try{
        await stream.unsubscribeAudioStatisticData()
        await stream.unsubscribeVideoStatisticData()
        isEnableQOS = false
      } catch (error)  {
      c  onsole.log(error)
      }
    }
    console.log("isenableQOS",isEnableQOS)
  };

■ 実装にあたっての補足事項

◆SharedArrayBufferの利用について
iOS(iPhone/iPad)/Androidのデバイスの中にはSharedArrayBufferが有効の環境で映像音声が正常に処理できない場合があります。
それぞれの環境を個別のサイトで用意すること以外に、以下の紹介されている実装内で含めることも考えられます。
https://github.com/gzuidhof/coi-serviceworker

◆SharedArrayBufferが有効であるかの確認方法
ブラウザの開発コンソールに以下いずれかを直接入力することで確認できます。

typeof SharedArrayBuffer === 'function'; // returns true or false
crossOriginIsolated; // returns true or false

◆ローカルカメラ映像をVideoエレメントで処理するには
SharedArrayBufferを無効にしてローカルカメラ映像をVideoエレメントに表示させる必要がある場合の例になります。

let localVideoTrack = ZoomVideo.createLocalVideoTrack()
let isVideoOn = false

  if(navigator.userAgent.includes("Firefox") || navigator.userAgent.includes("Chrome")){
    console.log("UserAgent: ", navigator.userAgent)
    if(!isVideoOn){
      let selfVideo = document.getElementById('self-view-video')
      await localVideoTrack.start(selfVideo)
      await stream.startVideo({videoElement: selfVideo})
      isVideoOn = true
    }else{
      await localVideoTrack.stop()
      await stream.stopVideo()
      isVideoOn = false
    }
    return
  }

  if(navigator.userAgent.includes("Safari") && !navigator.userAgent.includes("Chrome")){
    console.log("UserAgent: ", navigator.userAgent)
    if(!isVideoOn){
      let selfVideo = document.getElementById('self-view-video')
      await localVideoTrack.start(selfVideo)
      await stream.startVideo()
      isVideoOn = true
    }else{
      await localVideoTrack.stop()
      await stream.stopVideo()
      isVideoOn = false
    }
    return
  }

◆ローカル画面共有映像をVideoエレメントで処理するには
SharedArrayBufferを無効にしてローカル画面共有映像をVideoエレメントに表示させる必要がある場合の例になります。

let isSharing = false
async function screenShare() {
  console.log("screenShare Click")
  let shareVideo = document.getElementById('self-ScreenShare-video')
  let shareCanvas = document.getElementById('self-ScreenShare-canvas')
  try {
    console.log("isSharing: " + isSharing)
    if(!isSharing){
      isSharing = true
      if(!!window.chrome) {
        console.log("screenShare : chrome or edge")
        // if desktop Chrome and Edge (Chromium)
        stream.startShareScreen(shareVideo)
      } else {
        // all other browsers
        console.log("screenShare : other")
        stream.startShareScreen(shareCanvas)
      }
    }else{
      stream.stopShareScreen()
      isSharing = false
    }
  } catch (e) {
      console.error('Error cannot start/stop screensharing', e)
  }
}

■ サンプル・ドキュメント類について

今回のサンプル:

ドキュメント類 (オフィシャル・ドキュメント):

モバイルブラウザ向け:

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1