[UPDATE] Amazon IVSでWeb broadcast SDKがサポートされWebブラウザからストリーミングが可能になりました!

Amazon IVSで提供されるSDKでWebブラウザからのストリーミング(映像の打ち上げ)に対応しました。OBSなどのStreaming Softwareを用いずライブ配信が可能です。IngestにWebRTCを利用している点も大きなポイントです。
2022.07.31

はじめに

清水です。本エントリでお届けする情報はこちら!AWSのマネージド型ライブストリーミングソリューションであるAmazon Interactive Video Service (Amazon IVS)でWeb broadcast SDKがサポートされました。(2022/07/21付でポストされたアップデート情報となります。)

このSDKを使ったWebページを構築することで、WebブラウザがStreaming SoftwareとなりPCに接続しているWebカメラの映像などをAmazon IVSでライブ配信することが可能になります。これまでもiOSやAndroid向けのSDKがあり、アプリにStreaming Softwareの機能をもたせることはできました。新たにWebブラウザでのStreamingをサポートすることで、配信側で別途Streaming Softwareを用意することが不要になり、ストリーミング実施の敷居がぐっと下がるのではないでしょうか。

またAmazon IVS側の大きな変更点として、これまでIngestはRTMPのみ対応していましたが、新たにWebRTCでのIngestにも対応したようです。Web broadcast SDKではこのWebRTC用Ingest endpointに対してストリーミングを行います。

本エントリではサンプルコードを用いて、実際にAmazon IVSのWeb broadcast SDKの動作を確認、WebブラウザからのStreamingを行ってみたのでまとめてみたいと思います。

IVS Web broadcast SDKでWebブラウザからStreamingしてみた

関連ドキュメントやサンプルページなどの確認

まずは今回のWeb broadcast SDKサポートについて、関連する情報(ドキュメントやサンプルページ)などを抑えておきましょう。

AWS公式ブログ(AWS Media Blog)でSDKの使い方含めて紹介されています。WebRTCについてもこちらに記述があります。

またivs.rocksではこのWeb broadcast SDKを使ったサンプルページが公開されています。IVS側でChannelを準備すればそのままWebブラウザを用いたストリーミングが開始できそうです。

以下ではWeb broadcast SDKのサンプルコードを実際に編集しながら動作の確認が行えます。

Web broadcast SDKのドキュメント等は以下にまとまっています。

サンプルページの内容を独自ドメインでホスティング

本エントリでは、Amazon IVS Web Broadcast SDK Demoのページのコードを使って、実際にWebブラウザを使ったIVSへのストリーミングを行ってみたいと思います。

このサンプルページ(デモページ)の内容を独自ドメインでホスティングするよう準備します。今回、ホスティング環境はALB+EC2で準備しました。独自ドメインを用意し、HTTPSでアクセスできるようにしておきます。

ホスティング環境のEC2では以下2つのファイルを準備しました。

amazon-ivs-web-broadcast-sdk-demo.jsAmazon IVS Web Broadcast SDK DemoのJavaScript部分をそのままコピーしました。

index.htmlAmazon IVS Web Broadcast SDK DemoのHTML部分に、JavaScriptのコードを読み込む1行(112行目)を追加したものです。

amazon-ivs-web-broadcast-sdk-demo.js

// Possible configurations
const channelConfigs = [
  ["Basic: Landscape", window.IVSBroadcastClient.BASIC_LANDSCAPE],
  ["Basic: Portrait", window.IVSBroadcastClient.BASIC_PORTRAIT],
  ["Standard: Landscape", window.IVSBroadcastClient.STANDARD_LANDSCAPE],
  ["Standard: Portrait", window.IVSBroadcastClient.STANDARD_PORTRAIT]
];

// Set initial config for our broadcast
const config = {
  ingestEndpoint: "https://g.webrtc.live-video.net:4443",
  streamConfig: window.IVSBroadcastClient.BASIC_LANDSCAPE,
  logLevel: window.IVSBroadcastClient.LOG_LEVEL.DEBUG
};

// Error helpers
function clearError() {
  const errorEl = document.getElementById("error");
  errorEl.innerHTML = "";
}

function setError(message) {
  if (Array.isArray(message)) {
    message = message.join("<br/>");
  }
  const errorEl = document.getElementById("error");
  errorEl.innerHTML = message;
}

function getSupportedProperty(object, key) {
  if (key in object) {
    return object[key];
  }

  return "Unsupported";
}

// Get available audio/video inputs
async function initializeDeviceSelect() {
  const videoSelectEl = document.getElementById("video-devices");

  videoSelectEl.disabled = false;
  const { videoDevices, audioDevices } = await getDevices();
  videoDevices.forEach((device, index) => {
    videoSelectEl.options[index] = new Option(device.label, device.deviceId);
  });

  const audioSelectEl = document.getElementById("audio-devices");

  audioSelectEl.disabled = false;
  audioSelectEl.options[0] = new Option("None", "None");
  audioDevices.forEach((device, index) => {
    audioSelectEl.options[index + 1] = new Option(
      device.label,
      device.deviceId
    );
  });
}

async function getCamera(deviceId, maxWidth, maxHeight) {
  let media;
  const videoConstraints = {
    deviceId: deviceId ? { exact: deviceId } : null,
    width: {
      max: maxWidth
    },
    height: {
      max: maxHeight
    }
  };
  try {
    // Let's try with max width and height constraints
    media = await navigator.mediaDevices.getUserMedia({
      video: videoConstraints,
      audio: true
    });
  } catch (e) {
    // and fallback to unconstrained result
    delete videoConstraints.width;
    delete videoConstraints.height;
    media = await navigator.mediaDevices.getUserMedia({
      video: videoConstraints
    });
  }
  return media;
}

// Handle video device retrieval
async function handleVideoDeviceSelect() {
  const id = "camera";
  const videoSelectEl = document.getElementById("video-devices");
  const { videoDevices: devices } = await getDevices();
  if (window.client.getVideoInputDevice(id)) {
    window.client.removeVideoInputDevice(id);
  }

  // Get the option's video
  const selectedDevice = devices.find(
    (device) => device.deviceId === videoSelectEl.value
  );
  const deviceId = selectedDevice ? selectedDevice.deviceId : null;
  const { width, height } = config.streamConfig.maxResolution;
  const cameraStream = await getCamera(deviceId, width, height);

  // Add the camera to the top
  await window.client.addVideoInputDevice(cameraStream, id, {
    index: 0
  });
}

// Handle audio/video device enumeration
async function getDevices() {
  const devices = await navigator.mediaDevices.enumerateDevices();
  const videoDevices = devices.filter((d) => d.kind === "videoinput");
  if (!videoDevices.length) {
    setError("No video devices found.");
  }
  const audioDevices = devices.filter((d) => d.kind === "audioinput");
  if (!audioDevices.length) {
    setError("No audio devices found.");
  }

  return { videoDevices, audioDevices };
}

// Handle audio device retrieval
async function handleAudioDeviceSelect() {
  const id = "microphone";
  const audioSelectEl = document.getElementById("audio-devices");
  const { audioDevices: devices } = await getDevices();
  if (window.client.getAudioInputDevice(id)) {
    window.client.removeAudioInputDevice(id);
  }
  if (audioSelectEl.value.toLowerCase() === "none") return;
  const selectedDevice = devices.find(
    (device) => device.deviceId === audioSelectEl.value
  );
  // Unlike video, for audio we default to "None" instead of the first device
  if (selectedDevice) {
    const microphoneStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        deviceId: selectedDevice.deviceId
      }
    });
    await window.client.addAudioInputDevice(microphoneStream, id);
  }
}

// Setup the stream configuration options
async function initializeStreamConfigSelect() {
  const streamConfigSelectEl = document.getElementById("stream-config");
  streamConfigSelectEl.disabled = false;

  channelConfigs.forEach(([configName], index) => {
    streamConfigSelectEl.options[index] = new Option(configName, index);
  });
}

// Handle setting the stream config
async function handleStreamConfigSelect() {
  const streamConfigSelectEl = document.getElementById("stream-config");
  const selectedConfig = streamConfigSelectEl.value;
  config.streamConfig = channelConfigs[selectedConfig][1];

  await createClient();
}

/**
 * Validates the form's input elements. Returns empty array if
 * valid else the list of errors.
 */
function validate() {
  const streamKey = document.getElementById("stream-key").value;
  const ingestUrl = document.getElementById("ingest-endpoint").value;
  const errors = [];

  if (!ingestUrl) {
    errors.push("Please provide an ingest endpoint");
  }

  if (!streamKey) {
    errors.push("Please provide a stream key");
  }

  return errors;
}

async function handleIngestEndpointChange(e) {
  handleValidationErrors(validate());

  try {
    client.config.ingestEndpoint = e.target.value;
  } catch {
    handleValidationErrors(["Incorrect Ingest Url"]);
  }
}

function handleStreamKeyChange(e) {
  handleValidationErrors(validate());
}

function handleValidationErrors(errors, doNotDisplay) {
  const start = document.getElementById("start");
  const stop = document.getElementById("stop");

  clearError();
  if (errors && errors.length) {
    // Display errors
    if (!doNotDisplay) {
      setError(errors);
    }

    // Disable start and stop buttons
    start.disabled = true;
    stop.disabled = true;
    return;
  }

  start.disabled = false;
}

// Start the broadcast
async function startBroadcast() {
  const streamKeyEl = document.getElementById("stream-key");
  const endpointEl = document.getElementById("ingest-endpoint");
  const start = document.getElementById("start");

  try {
    start.disabled = true;
    await window.client.startBroadcast(streamKeyEl.value, endpointEl.value);
  } catch (err) {
    start.disabled = false;
    setError(err.toString());
  }
}

// Stop the broadcast
async function stopBroadcast() {
  try {
    await window.client.stopBroadcast();
  } catch (err) {
    setError(err.toString());
  }
}

// Handle the enabling/disabling of buttons
function onActiveStateChange(active) {
  const start = document.getElementById("start");
  const stop = document.getElementById("stop");
  const streamConfigSelectEl = document.getElementById("stream-config");
  const inputEl = document.getElementById("stream-key");
  inputEl.disabled = active;
  start.disabled = active;
  stop.disabled = !active;
  streamConfigSelectEl.disabled = active;
}

// Helper to create an instance of the AmazonIVSBroadcastClient
async function createClient() {
  if (window.client) {
    window.client.delete();
  }

  window.client = window.IVSBroadcastClient.create(config);

  window.client.on(
    window.IVSBroadcastClient.BroadcastClientEvents.ACTIVE_STATE_CHANGE,
    (active) => {
      onActiveStateChange(active);
    }
  );

  const previewEl = document.getElementById("preview");
  window.client.attachPreview(previewEl);

  await handleVideoDeviceSelect();
  await handleAudioDeviceSelect();
}

// Initialization function
async function init() {
  try {
    const videoSelectEl = document.getElementById("video-devices");
    const audioSelectEl = document.getElementById("audio-devices");
    const streamConfigSelectEl = document.getElementById("stream-config");
    const ingestEndpointInputEl = document.getElementById("ingest-endpoint");
    const streamKeyInputEl = document.getElementById("stream-key");

    await initializeStreamConfigSelect();

    videoSelectEl.addEventListener("change", handleVideoDeviceSelect, true);
    audioSelectEl.addEventListener("change", handleAudioDeviceSelect, true);
    streamConfigSelectEl.addEventListener(
      "change",
      handleStreamConfigSelect,
      true
    );
    ingestEndpointInputEl.addEventListener(
      "input",
      handleIngestEndpointChange,
      true
    );
    streamKeyInputEl.addEventListener("input", handleStreamKeyChange, true);

    // Get initial values from the text fields.  Changes to these will re-create the client.
    const selectedConfig = streamConfigSelectEl.value;
    config.streamConfig = channelConfigs[selectedConfig][1];
    config.ingestEndpoint = ingestEndpointInputEl.value;

    await createClient();

    await initializeDeviceSelect();

    handleValidationErrors(validate(), true);
  } catch (err) {
    setError(err.message);
  }
}

init();

index.html

<!--
/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */
-->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Broadcast To IVS</title>
  <!-- Google Fonts -->
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic" />
  <!-- CSS Reset -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css" />
  <!-- Milligram 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.0.0/amazon-ivs-web-broadcast.js"></script>

  <style>
    html,
    body {
      width: 100%;
    }

    #error {
      color: red;
    }

    table {
      display: table;
    }

    #preview {
      margin-bottom: 1.5rem;
      background: green;
      width: 100%;
      height: 300;
    }
  </style>
</head>

<body>
  <!-- Introduction -->
  <header class="container">
    <h1>Broadcast To IVS</h1>

    <p>
      This sample extends the `Capture Webcam Video`. A user should have the ability to capture their device
      camera and broadcast it to IVS.
    </p>
  </header>

  <hr />

  <!-- Error alert -->
  <section class="container">
    <h3 id="error"></h3>
  </section>

  <!-- Compositor preview -->
  <section class="container">
    <canvas id="preview"></canvas>
  </section>

  <!--  Select -->
  <section class="container">
    <label for="video-devices">Select Webcam</label>
    <select disabled id="video-devices">
      <option selected disabled>Choose Option</option>
    </select>

    <label for="audio-devices">Select Microphone</label>
    <select disabled id="audio-devices">
      <option selected disabled>Choose Option</option>
    </select>

    <label for="stream-config">Select Channel Config</label>
    <select disabled id="stream-config">
      <option selected disabled>Choose Option</option>
    </select>
  </section>

  <!-- Ingest Endpoint input -->
  <section class="container">
    <label for="ingest-endpoint">Ingest Endpoint</label>
    <input type="text" id="ingest-endpoint" value="" />
  </section>

  <!-- Stream Key input -->
  <section class="container">
    <label for="stream-key">Stream Key</label>
    <input type="text" id="stream-key" value="" />
  </section>

  <!-- Broadcast buttons -->
  <section class="container">
    <button class="button" id="start" disabled onclick="startBroadcast()">Start Broadcast</button>
    <button class="button" id="stop" disabled onclick="stopBroadcast()">Stop Broadcast</button>
  </section>

  <hr />

  <!-- Data table -->
  <section class="container">
    <table id="data">
      <tbody></tbody>
    </table>
  </section>

  <!-- Read JavaScript -->
  <script src="./amazon-ivs-web-broadcast-sdk-demo.js"></script>

</body>

</html>

IVS Channelの作成

Web broadcast SDKを使ったWebページをホスティングする準備が整いました。続いて、IVSのChannelリソースを作成します。今回はIVSのコントロールプレーンにオレゴンリージョン(us-west-2)を利用しました。設定はデフォルト(Channel typeはStandard、Video latencyはUltra-low)としています。

作成後の画面です、ポイントとして、Ingest serveのRTMPSプロトコルのURLを用いるのではなく、その下にOther ingest optionsの項目を確認します。デフォルトでは項目が閉じているので、クリックして展開しましょう。Ingest endpointが現れますので、この値を使用します。Stream keyも使用するので、この2点を控えておきます。

Web broadcast SDKを使ったページで実際にStreaming

ホスティング環境ならびにIVS Channelの準備ができましたので、実際にWeb broadcast SDKを使ったページでStreamingをしてみましょう。ホスティング環境の独自ドメインhttps://broadcast-to-ivs.example.net/をWebブラウザで開きます。最新版のGoogle Chromeを利用しました。

ページを開くと、カメラ・マイクの利用許可のダイアログが現れますので許可します。

許可後、Webカメラの映像が表示されました!PCに接続されているWebカメラ、マイクがそれぞれ利用可能ですので、使用するものを選択します。

Select Channel Configでは以下4項目から選択できます。Basci/StandardはIVSのChannel Typeを示します。Landscape/Portraitは映像を横長で扱うか、縦長で扱うかの選択となります。スマホ向けなどではPortraitを使うのが良いかと思います。今回はStandard: Landscapeで進めました。

  • Basic: Landscape
  • Basic: Portrait
  • Standard: Landscape
  • Standard: Portrait

Ingest Endpointの項目には先ほどIVSのマネジメントコンソールで確認したEndpointを入力します。続くStream Keyについても同様です。

準備ができたら[START BOADCAST]ボタンでストリーミングを開始します。マネジメントコンソールのIVS Playerからライブ配信の視聴確認をしてみました。Amazon IVSの超低遅延という特徴を活かしたライブストリーミングが、Webブラウザからのストリーミングでも実現できていますね!

なお、2022/07/31に確認した限りですが、東京リージョンをIVSのコントロールプレーンとしてChannelを作成した場合、以下のようなinvalid stream keyエラーが発生して、ストリーミングを行うことができませんでいした。Stream Keyはコピペしているので間違うこともないと思うのですが。同様の手順でバージニア、フランクフルトリージョンなどで確認した限りは問題なく動作しました。また東京リージョンでもRTMPS endpointを利用した場合は問題なく動作しました。WebRTC用のIngest endpointが東京リージョンのみ一時的に不調になっているのかな、などと推測しています。(今回は検証目的ということで、バージニアリージョン等で動作したのでよしとしています。)

まとめ

Amazon Interactive Video Service (Amazon IVS)で新たにサポートされたWeb broadcast SDKのサンプルコードを使って、実際にWebブラウザからPCに接続したWebカメラを使ったライブストリーミングを行ってみました。これまでライブ動画配信を行う場合、インフラ面の準備もありますが、グラウンド側Streaming Softwareの準備もなかなか大変だったかと思います。OSB Studioなど気軽に使えるStreaming Softwareもありますが、例えばWebミーティングのようにWebブラウザのみで済ませられるならそれに越したことはない、という場面は多くなったのではないでしょうか。Amazon IVSのWeb broadcast SDKを使えば、このようなWebブラウザとWebカメラのみで簡単に配信が行なえます。IVS ChannelのIngest EndpointのWebRTC対応含め、大変魅力的なアップデートだと思いました。