😸

[JAWSDAYS 2024]IVSチームのハンズオンを受けてきた

2024/03/24に公開

3/2のJAWS DAYS2024でIVSチームのハンズオンを受けてきました。
簡単にですがやったことをまとめていきます。

資料のリンクはこちらです。
getting-started-with-amazon-ivs-real-time-streaming-jp.md

ハンズオンでの実施内容

  • Webクライアントでの配信画面の作成
    • ステージの作成
    • ステージトークンの発行
    • Webクライアントの作成
  • Webクライアントへの機能追加
    • 参加者の公開/非公開機能
    • デバイス変更機能
    • デバイスのON/OFF機能
  • IVSチャンネルへの配信
    • サーバーサイドコンポジションの利用

Step1: ステージの作成

ステージはStreamyardでいうところの配信者画面のイメージで問題ない。
今回はdemo-stageを作成する。

aws ivs-realtime create-stage --name "demo-stage"

ドキュメントはこちらにある。
ステージの作成 - Amazon IVS

Step2: ステージトークンの発行

実際の配信に参加する参加者がステージに入るためには、ステージトークンが必要になる。

aws ivs-realtime create-participant-token --stage-arn < stage ARN > --capabilities '["PUBLISH", "SUBSCRIBE"]'

ドキュメントは下記に記載。
参加者トークンの配布 - Amazon IVS
Class: AWS.IVSRealTime — AWS SDK for JavaScript

今回は簡単なHTTPサーバーを構築して実装する。

  • /stage-token: ステージトークンを発行する
server.js
import * as http from 'http';
import * as url from 'url';
import * as fs from 'fs';
import * as path from 'path';
import { CreateParticipantTokenCommand, IVSRealTimeClient } from "@aws-sdk/client-ivs-realtime";

const ivsRealtimeClient = new IVSRealTimeClient({ region: 'ap-northeast-1' });

// call createParticipantToken API
const createStageToken = async (stageArn, attributes, capabilities, userId, duration) => {
   const createStageTokenRequest = new CreateParticipantTokenCommand({
    attributes,
    capabilities,
    userId,
    stageArn,
    duration,
  });
  const createStageTokenResponse = await ivsRealtimeClient.send(createStageTokenRequest);
  return createStageTokenResponse;
};

async function handler(req, res){
    console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
    const parsedUrl = url.parse(req.url, true);
    
    // if request path is /stage-token, return stage token to client
    if (parsedUrl.pathname === '/stage-token') {
        let body = '';
        req.on('data', chunk => {
          body += chunk.toString();
        });
        req.on('end', async () => {
          const params = JSON.parse(body);
          res.writeHead(200, { 'Content-type': 'application/json' });
          const tokenResponse = await createStageToken(
            params.stageArn,
            params.attributes,
            params.capabilities,
            params.userId,
            params.duration,
          );
          res.write(JSON.stringify(tokenResponse));
          res.end();
        });
    }
    // if not, return static file to client
    else {
        let filePath = '.' + req.url;
        if (filePath == './') filePath = './index.html';
        let extname = path.extname(filePath);
        let contentType = 'text/html';
        if(extname === '.js') contentType = 'text/javascript';
    
        fs.readFile(filePath, function (error, content) {
          if (error) {
            if (error.code == 'ENOENT') {
              res.writeHead(404, { 'Content-Type': contentType });
              res.end(content, 'utf-8');
            }
            else {
              res.writeHead(500);
              res.end('Error: ' + error.code + ' ..\n');
            }
          }
          else {
            res.writeHead(200, { 'Content-Type': contentType });
            res.end(content, 'utf-8');
          }
        });
    }
}

const server = http.createServer(handler);
server.listen(8080);

作成したらサーバーを起動する。

node server.js

実際にリクエストを送って確認をする。

curl -X POST \
  -H "Content-Type: application/json" \
  -d "{ \
      \"stageArn\": \"[YOUR STAGE ARN]\",
      \"userId\": \"123456\",
      \"capabilities\": [\"PUBLISH\", \"SUBSCRIBE\"],
      \"attributes\": {\"username\": \"todd\"}
    }" \
  localhost:8080/stage-token | jq

下記のようなレスポンスが返ってくればOK。

{
  "$metadata": {
    "httpStatusCode": 200,
    "requestId": "...",
    "cfId": "...",
    "attempts": 1,
    "totalRetryDelay": 0
  },
  "participantToken": {
    "attributes": {
      "username": "todd"
    },
    "capabilities": [
      "PUBLISH",
      "SUBSCRIBE"
    ],
    "expirationTime": "2024-02-06T01:48:35.000Z",
    "participantId": "AXRHIx4L6AnO",
    "token": "eyJhbGciOiJLTV...",
    "userId": "123456"
  }
}

Step3: Webクライアントの作成

今回はWebクライアントでステージに接続する。
1つ前の手順で作成したHTTPサーバーにWebクライアントの機能を追加する。
実装する機能は下記の通り。

  • /stage-tokenエンドポイントをコールするヘルパー関数の作成
    • api.jsで実装する
  • ivs-realtime-utils.jsの作成
    • 配信画面に必要な機能をヘルパーとして実装 (デバイスのアクセス許可、デバイス一覧取得、デバイス選択、デバイスからのメディアストリーム取得)
  • index.htmlの作成
  • index.jsの作成

/stage-tokenエンドポイントをコールするヘルパー関数の作成

1つ前のステージ作成で実施した/stage-tokenへのAPIコールを実装する。

api.js
export const getStageToken = async (username) => {
  const stageTokenRequest = await fetch(`/stage-token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      'stageArn': '[YOUR STAGE ARN]',
      'userId': Date.now().toString(),
      'capabilities': ['PUBLISH', 'SUBSCRIBE'],
      'attributes': {
        username,
      }
    }),
  });
  const stageTokenResponse = await stageTokenRequest.json();
  return stageTokenResponse.participantToken.token;
};

ivs-realtime-utils.jsの作成

今回のハンズオンでは配信画面に必要な機能をヘルパー関数として実装する。

ivs-realtime-utils.js
if (typeof IVSBroadcastClient === 'undefined') throw new Error('IVSBroadcastClient not found. You must include the Amazon IVS Web Broadcast SDK before this file.');
const { SubscribeType, StreamType } = IVSBroadcastClient;

class StageStrategy {
  audioStream;
  videoStream;

  constructor(audioStream, videoStream) {
    this.audioStream = audioStream;
    this.videoStream = videoStream;
  }

  // wrap `updateTracks()` with a friendlier name
  updateStreams(newAudioStream, newVideoStream) {
    this.updateTracks(newAudioStream, newVideoStream);
  }

  updateTracks(newAudioStream, newVideoStream) {
    this.audioStream = newAudioStream;
    this.videoStream = newVideoStream;
  }

  stageStreamsToPublish() {
    return [this.audioStream, this.videoStream];
  }

  shouldPublishParticipant(participant) {
    return true;
  }

  shouldSubscribeToParticipant(participant) {
    return SubscribeType.AUDIO_VIDEO;
  }
};

class StageUtils {
  static REAL_TIME_VIDEO_LANDSCAPE = {
    width: { max: 1280 },
    height: { max: 720 },
  };

  static handlePermissions = async (needAudio, needVideo) => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ video: needVideo, audio: needAudio });
      for (const track of stream.getTracks()) {
        track.stop();
      }
      return { video: needVideo, audio: needAudio };
    }
    catch (err) {
      console.error(err.message);
      return { video: false, audio: false };
    }
  };

  static listVideoDevices = async () => {
    const devices = await navigator.mediaDevices.enumerateDevices();
    return devices.filter((d) => d.kind === 'videoinput');
  };

  static listAudioDevices = async () => {
    const devices = await navigator.mediaDevices.enumerateDevices();
    return devices.filter((d) => d.kind === 'audioinput');
  };

  static getVideoStream = async (deviceId) => {
    return await navigator.mediaDevices.getUserMedia({
      video: {
        deviceId: {
          exact: deviceId,
        },
        width: this.REAL_TIME_VIDEO_LANDSCAPE.width,
        height: this.REAL_TIME_VIDEO_LANDSCAPE.height,
      },
    });
  };

  static getAudioStream = async (deviceId) => {
    return await navigator.mediaDevices.getUserMedia({
      audio: {
        deviceId: {
          exact: deviceId,
        },
      },
    });
  };

  static addParticipantStreams = (element, participant, streams) => {
    let streamsToDisplay = streams;
    if (participant.isLocal) {
      streamsToDisplay = streams.filter((stream) => stream.streamType === StreamType.VIDEO);
    }
    const mediaStream = element.srcObject || new MediaStream();
    streamsToDisplay.forEach((stream) => {
      mediaStream.addTrack(stream.mediaStreamTrack);
    });
    return mediaStream;
  };

  static removeParticipantStreams = (element, streams) => {
    const mediaStream = element.srcObject;
    const newStream = new MediaStream();
    mediaStream.getTracks().forEach((track) => {
      if (!streams.find(t => t.id === track.id)) {
        newStream.addTrack(track);
      }
    });
    element.srcObject = newStream;
    return newStream;
  };

  static generateParticipantVideoElement = (participant, streams) => {
    const participantVideoEl = document.createElement('video');
    participantVideoEl.setAttribute('autoplay', 'autoplay');
    participantVideoEl.setAttribute('playsinline', 'playsinline');
    participantVideoEl.srcObject = new MediaStream();
    return participantVideoEl;
  };

  static generateCameraSelect = async () => {
    const el = document.createElement('select');
    const videoDevices = await this.listVideoDevices();
    videoDevices.forEach((device) => {
      const option = document.createElement('option');
      option.value = device.deviceId;
      option.innerHTML = device.label;
      el.appendChild(option);
    });
    return el;
  };

  static generateMicrophoneSelect = async () => {
    const el = document.createElement('select');
    const audioDevices = await this.listAudioDevices();
    audioDevices.forEach((device) => {
      const option = document.createElement('option');
      option.value = device.deviceId;
      option.innerHTML = device.label;
      el.appendChild(option);
    });
    return el;
  };
}
var IvsStageUtils = {
  StageStrategy,
  StageUtils
};

index.htmlの作成

index.htmlには下記を記載する。

  • Amazon IVS Web Broadcast SDK
  • ivs-realtime-utils.js
  • index.js
  • div要素
    • 参加者がステージに参加する時に参加者の<video>要素をレンダリングするためとのこと
index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Amazon IVS Real-Time Demo</title>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
  <script src="https://web-broadcast.live-video.net/1.9.0/amazon-ivs-web-broadcast.js"></script>
  <script src="ivs-realtime-utils.js"></script>
  <script src="index.js" type="module"></script>
  <style>
    video {
      width: 640px;
      height: 360px;
      border-radius: 5px;
      background-color: black;
    }

    #controls {
      width: 500px;
    }

    body {
      padding-top: 10px !important;
    }
  </style>
</head>

<body class="container">
  <div id="participants"></div>
</body>

</html>

index.jsの作成

Webクライアントの実処理を作成する。先ほどの作成したヘルパー関数を使いながら実装する。

  • Stageインスタンスの作成
    • ステージトークンの取得
    • デバイス権限の取得、一覧表示
    • デバイスからのメディアストリーム取得
    • StageStrategyインスタンス生成
    • Stageインスタンスの生成
  • 参加/退出時のイベントハンドラー追加
    • ストリーム追加/削除に合わせて<video>要素も追加/削除する
  • ステージへの参加処理

関連ドキュメント:
公開とサブスクライブ - Amazon IVS
Enumeration: StageEvents | IVS Web Broadcast

index.js
// load helper module and Amazon Web Broadcast SDK
import { getStageToken } from './api.js';
const { StageStrategy, StageUtils } = IvsStageUtils;
const { Stage, LocalStageStream, StageEvents } = IVSBroadcastClient;

// retrieve stage token from /stage-token
const stageToken = await getStageToken('[your name]');

// retrieve device permission and list devices
await StageUtils.handlePermissions(true, true);

const videoDevices = await StageUtils.listVideoDevices();
const audioDevices = await StageUtils.listAudioDevices();

// retrieve media streams from devices
const camStream = await StageUtils.getVideoStream(videoDevices[0].deviceId);
const micStream = await StageUtils.getAudioStream(audioDevices[0].deviceId);

// create LocalStageStream instances from video/audio tracks
let localVideoStream = new LocalStageStream(camStream.getVideoTracks()[0]);
let localAudioStream = new LocalStageStream(micStream.getAudioTracks()[0]);

// Create StageStrategy from localVideoStream and localAudioStream
const strategy = new StageStrategy(localAudioStream, localVideoStream);

// Create stage from stageToken and strategy
const stage = new Stage(stageToken, strategy);

// remote participant joined the stage
stage.on(StageEvents.STAGE_PARTICIPANT_JOINED, (participant, streams) => {
  console.log('STAGE_PARTICIPANT_JOINED');
});

// stream(s) have been added for a participant
stage.on(StageEvents.STAGE_PARTICIPANT_STREAMS_ADDED, (participant, streams) => {
  console.log('STAGE_PARTICIPANT_STREAMS_ADDED');
  const participantId = `participant-${participant.id}`;
  let participantVideoEl = document.getElementById(participantId);
  if (!participantVideoEl) {
    participantVideoEl = StageUtils.generateParticipantVideoElement(participant, streams);
    participantVideoEl.setAttribute('id', participantId);
    document.getElementById('participants').appendChild(participantVideoEl);
  }
  StageUtils.addParticipantStreams(participantVideoEl, participant, streams);
});

// stream(s) have been removed for a participant
stage.on(StageEvents.STAGE_PARTICIPANT_STREAMS_REMOVED, (participant, streams) => {
  console.log('STAGE_PARTICIPANT_STREAMS_REMOVED');
  const participantVideoEl = document.getElementById(`participant-${participant.id}`);
  StageUtils.removeParticipantStreams(participantVideoEl, streams);
});

// participant left the stage
stage.on(StageEvents.STAGE_PARTICIPANT_LEFT, (participant, streams) => {
  console.log('STAGE_PARTICIPANT_LEFT');
  document.getElementById(`participant-${participant.id}`).remove();
});

// join stages
await stage.join();

動作確認

すべてのファイルの編集が完了したらサーバーを起動する。

node server.js

ブラウザ画面でカメラが表示されればOK。複数タブで開くと横にそのまま追加される。

Step4: Webクライアントへの機能追加

先ほどの手順で作成したWebクライアントへデバイスのON/OFF機能を追加する。

  • 参加者の公開/非公開機能
  • デバイス変更機能
  • イベントハンドラーの実装

作業前にindex.htmlを編集する。(変更部分のみ抜粋)

index.html
<body class="container">
  <div id="controls">
    <div>
      <input type="checkbox" name="isPublishing" id="isPublishing" checked />
      <label class="label-inline" for="isPublishing">Publish?</label>
    </div>
    <div id="audioDevices"></div>
    <div id="videoDevices"></div>
    <div>
      <button id="muteMic">Mute Mic</button>
      <button id="muteCam">Mute Cam</button>
    </div>
  </div>
  <div id="participants"></div>
</body>

参加者の公開/非公開機能

今回はすでに実装済みのstageStrategyクラスを拡張する形で実装する。
具体的にはshouldPublishParticipant関数の返り値をisPublishingで上書きする。(長いので抜粋した形で掲載)

このままだと切り替わらないため、変更イベントを追従するイベントハンドラーを作成します。
関連ドキュメント:
shouldPublishParticipant

index.js
// extended class for implementing device on/off
let isPublishing = true;
class CustomStageStrategy extends StageStrategy {
  shouldPublishParticipant() {
    return isPublishing;
  }
};

// Create StageStrategy from localVideoStream and localAudioStream
// Use custom extended class
const strategy = new CustomStageStrategy(localAudioStream, localVideoStream);

// Add Event handler to listen changes of isPublishing
document.getElementById('isPublishing').addEventListener('change', () => {
  isPublishing = !isPublishing;
  stage.refreshStrategy();
});

この時点で、左上のPublishボタンを触ることで自身の公開/非公開が設定できるようになりました。

デバイス変更機能

デバイスを変更できるようにselect要素を追加、イベントハンドラーを使用したstrategyのストリーム更新、strategyのリフレッシュを実装します。

index.js
// Add select element to change camera devices
const camSelectEl = await StageUtils.generateCameraSelect();
camSelectEl.setAttribute('id', 'cameras');
document.getElementById('videoDevices').appendChild(camSelectEl);

// event handler of camera device changes
camSelectEl.addEventListener('change', async (evt) => {
  const videoStream = await StageUtils.getVideoStream(evt.target.value);
  strategy.videoStream = new LocalStageStream(videoStream.getVideoTracks()[0]);
  stage.refreshStrategy();
});

// Add select element to change mic devices
const micSelectEl = await StageUtils.generateMicrophoneSelect();
micSelectEl.setAttribute('id', 'microphones');
document.getElementById('audioDevices').appendChild(micSelectEl);

// event handler of mic device changes
micSelectEl.addEventListener('change', async (evt) => {
  const audioStream = await StageUtils.getAudioStream(evt.target.value);
  strategy.audioStream = new LocalStageStream(audioStream.getAudioTracks()[0]);
  stage.refreshStrategy();
});

実装後はカメラ、マイクの選択ができるようになります。

デバイスのON/OFF機能

デバイスのON/OFF機能を追加します。
先ほどと同じようにclickイベントに追従するようにイベントハンドラーを作成します。

index.js
let isAudioMuted = false;
let isVideoMuted = false;

document.getElementById('muteMic').addEventListener('click', (evt) => {
  isAudioMuted = !isAudioMuted;
  evt.currentTarget.innerHTML = isAudioMuted ? 'Unmute Mic' : 'Mute Mic';
  localAudioStream.setMuted(isAudioMuted);
});

document.getElementById('muteCam').addEventListener('click', (evt) => {
  isVideoMuted = !isVideoMuted;
  evt.currentTarget.innerHTML = isVideoMuted ? 'Unmute Cam' : 'Mute Cam';
  localVideoStream.setMuted(isVideoMuted);
});

作成後はデバイスのON/OFFができるようになります。

Step5: IVSチャンネルへの配信

IVSのサーバーサイドコンポジションという機能を使うことでIVSチャンネルへの配信ができます。
IVSチャンネルを使用することで1万人以上の視聴者に配信ができるようになります。

サーバーサイドコンポジションの詳細は下記ドキュメントを参照してください。
サーバーサイドコンポジション (リアルタイムストリーミング) - Amazon IVS

IVS低レイテンシーチャンネルを作成します。
CLI の手順 - Amazon Interactive Video Service

aws ivs create-channel --name ivs-demo-channel

作成したら下記コマンドを使用してエンコーダー設定を作成します。

aws ivs-realtime create-encoder-configuration --name "demo-encoder-configuration" --video "bitrate=2500000,height=720,width=1280,framerate=30"

作成したらコンポジションを開始します。

aws ivs-realtime start-composition --stage-arn "<StageのARN>" --destinations  '[{"channel": {"channelArn": "<ChannelのARN>", "encoderConfigurationArn": "<エンコーダー設定のARN>"}}]'

開始後AWSコンソールから作成したIVSチャンネルを選択すると、映像と音声が配信されていることが確認できる。

おまけ: JAWS PANKRATION 2024の配信基盤

JAWSDAYS 2024のセッションC-9では、JAWS PANKRATION 2024で使う配信アーキテクチャについてのセッションがありました。
JAWS DAYS 2024 C-9 - Speaker Deck

PANKRATIONの配信基盤にはAmazon IVSが使用されています。
前回のPANKRATIONでは翻訳部分にPocetalkを使用していましたが、これを別のアーキテクチャに置き換えできないかセッション内でアイデアが出ていました。
具体的にはIVSのTimed metadataという、動画内に文字列を埋め込む機能を使いこなせないか検討していました。
動画ストリーム内にメタデータを埋め込む - Amazon Interactive Video Service

ここではセッション内で出ていたアイデアを箇条書きで記載します。

  • IVSチーム
    • 今回のケースだと文字起こしと翻訳の2つに大きく分かれる
    • 文字起こしをクライアントサイドでやる場合
      • OpenAi Whisper modelを使用する
        • Web assembly版があり、それを各登壇者の端末にダウンロードする
      • 遅延を少なくできるのはメリット
      • デメリット
        • モデルファイルを各自のPCにダウンロードする必要があり、NW帯域や使用できるデバイスに制限が出る可能性がある
        • 文字起こししたものを翻訳する必要性もある
    • 文字起こしと翻訳の両方をサーバーサイドでやる場合
      • ここではEC2にffmpegを入れて情報を引っ張った後に、翻訳をするような形式を想定
      • サーバーにモデルをダウンロードするため、大規模なモデルも使用でき翻訳の精度も上がる
      • デメリット
        • 文字起こししたものをどう各クライアントに送るかが課題になる
  • その他の意見
    • テレビ局勤務のAさん
      • 動画を全部つくるのは現実的ではない
      • アーカイブ、オンデマンド配信だとVTTファイルで読み込ませていた
        • ただライブキャプションで動かなかった記憶が‥
    • Bさん
      • IVSでもvideo captionとして字幕を入れられるのではないか?
    • Cさん
      • Amazon Connectを活用できないか?
        • 裏側ではKinesis Video Streamが動いているとのこと

Discussion