ImageFlux Live Streamingの配信アーカイブをさくらのAI Engineで自動要約する

はじめに

さくらインターネットでは、ImageFlux Live Streamingというライブ配信PaaSを提供しています。このサービスでは、ライブ配信だけでなく、そのアーカイブをHLS形式で保存することが可能です。

今回は、これにさくらのAI Engineを組み合わせることで、ライブ配信後にアーカイブが保存されたタイミングで、文字起こしと要約を自動生成する仕組みを構築しました。

構成は以下の通りです。

少し複雑ですが、動作機序は以下の通りです。

  1. ImageFlux Live Streamingが配信のアーカイブをオブジェクトストレージに保存する。
  2. ImageFlux Live Streamingがアーカイブ保存完了通知をWebhookでAppRun共用型に送信する。
  3. AppRun共用型はWebhookで受け取ったアーカイブのパスをシンプルMQのメッセージに登録する。
  4. サーバは順次シンプルMQのメッセージを取得する。
  5. サーバがオブジェクトストレージのアーカイブデータを参照し、さくらのAI Engine向けにMP3データを生成する。
  6. サーバがさくらのAI EngineのAPIを呼び出し、文字起こしと要約を出力する。
  7. サーバが文字起こしと要約をオブジェクトストレージに格納する。
  8. サーバがシンプル通知のAPIを呼び出し、利用者に出力完了通知を送信する。

AppRun共用型で直接さくらのAI Engineと通信してもよいのですが、配信が増えたり配信のデータ量が多かったりしても確実に動作するように、シンプルMQを配置して非同期で処理する形にしました。

本記事では、上記の動作機序に従って以下を構築します。

  • 配信終了時にWebhookを受信
  • アーカイブを自動取得
  • さくらのAI Engineで文字起こし
  • さくらのAI Engineで要約
  • 通知メールを送信

事前準備

認証情報の準備

まず、AppRun共用型およびサーバから各種APIを呼べるように、認証情報の準備をします。本記事では「キューのAPIキー」「さくらのAI EngineのAPIキー」「サービスプリンシパル/サービスプリンシパルキーおよびIAM権限」の3種類を使用します。

呼び出すサービス準備するもの
シンプルMQキューのAPIキー
シンプル通知サービスプリンシパル/サービスプリンシパルキーおよびIAM権限
さくらのAI EngineさくらのAI EngineのAPIキー

シンプルMQの作成

さくらのクラウドのコントロールパネルから、シンプルMQを作成します。キューを作成すると、直後にキュー認証用のAPIキーが発行されるので、それを保存しておきます。

サービスプリンシパル(キー)の準備

サービスプリンシパルの詳細についてはこちらをご参照ください。人ではなくシステムからAPIを呼ぶ際に、認証に使われるものです。

手順としては、

  1. あらかじめ利用者がさくらのクラウドにサービスプリンシパルに紐づく公開鍵を登録
  2. システム側からは秘密鍵で署名したJWTをさくらのクラウドに送信
  3. さくらのクラウドが時限トークンをシステムに向けて発行
  4. システム側が時限トークンを用いてAPIを呼ぶ

というものです。当然ですが、利用者がサービスプリンシパルに対して割り当てた権限内でしか、システムはAPIを呼ぶことはできません。

まず、pemのキーペアを作成します。次のコマンドを実行すると、秘密鍵key_for_principal.pemと公開鍵key_for_principal_public.pemが出力されます。

ssh-keygen -t rsa -b 2048 -m PEM -f key_for_principal.pem -N ""; ssh-keygen -f key_for_principal.pem -e -m PEM > key_for_principal_public.pem

次に、さくらのクラウド ホーム画面でサービスプリンシパルを作成します。名前・説明は任意のもので構いません。

続いて、そのサービスプリンシパルに割り当てるサービスプリンシパルキーを作成します。先ほど作成したpem公開鍵を入力し、「作成」を押下します。

これで、アクセストークン取得に必要なJWT署名・検証の環境が整いました。

サービスプリンシパルのリソースIDとサービスプリンシパルキーID、および対となる秘密鍵は、サーバ側の設定で使用するため、保存しておいてください。

最後に、サービスプリンシパルにアクセス権を付与します。シンプル通知のAPIを呼ぶには「作成・削除」のロールが必要なので(参考)、それを割り当てます。

これでサービスプリンシパルの準備は完了です。

さくらのAI EngineのAPIキー作成

さくらのクラウド ホーム画面からさくらのAI Engineの画面に遷移し、約款に同意、利用プランを選択します。どちらでも構いません。

左ペインの「アカウントトークン」を押下し、アカウントトークンを作成します。トークン名は任意のもので構いません。

アカウントトークンも、サーバの設定で必要なので、保存しておきます。

これで認証情報の準備は完了です。

コンテナレジストリへのイメージアップロード

AppRun共用型で動作させるDockerイメージをビルド、プッシュします。ソースコードはこちらを参照してください。

docker login [コンテナレジストリ名].sakuracr.jp
docker build -t [コンテナレジストリ名].sakuracr.jp/[イメージ名]:[バージョン] .
docker push [コンテナレジストリ名].sakuracr.jp/[イメージ名]:[バージョン]

なお、このアプリケーションのDockerイメージはコンテナレジストリに公開イメージとして格納してありますので、ビルドを省略したい方は下記のイメージをご利用ください。

creatio313-live-streaming.sakuracr.jp/webhook-handler:v1

基盤構築

Terraformで構成図にある残りの基盤を構築していきます。ソースコードは以下の通りです。(ファイル名はmain.tfとします)

data "sakura_archive" "ubuntu" {
  os_type = "ubuntu2404"
}

data "sakura_object_storage_site" "ishikari" {
  display_name = "石狩第1サイト"
}

resource "sakura_apprun_shared" "imageflux_live_streaming_webhook_handler" {
  name = "ImageFlux Live StreamingのWebhookハンドラ"

  max_scale       = 3
  min_scale       = 0
  port            = 8080
  timeout_seconds = 60

  components = [{
    name       = "ImageFlux Live StreamingのWebhookハンドラコンテナ"
    max_cpu    = "0.5"
    max_memory = "1Gi"
    deploy_source = {
      container_registry = {
        image = var.container_registry_image
      }
    }
    env = [{
      key   = "SIMPLEMQ_API_KEY"
      value = var.simple_mq_api_key
      },
      {
        key   = "SIMPLEMQ_QUEUE_NAME"
        value = var.simple_mq_queue_name
    }]
  }]
  traffics = [{
    version_index = 0
    percent       = 100
  }]
}

resource "sakura_disk" "archive_handling_server_disk" {
  name        = "アーカイブ処理サーバーのディスク"
  description = "アーカイブ処理サーバーのディスク。最低限のスペックで構築し、必要に応じてスケールアップする。"

  connector            = "virtio"
  encryption_algorithm = "aes256_xts"
  icon_id              = var.ubuntu_icon
  kms_key_id           = sakura_kms.archive_handling_server_encryption_key.id
  plan                 = "ssd"
  size                 = 20
  source_archive_id    = data.sakura_archive.ubuntu.id
  zone                 = var.zone
}

resource "sakura_kms" "archive_handling_server_encryption_key" {
  name        = "アーカイブ処理サーバー用暗号化キー"
  description = "アーカイブ処理サーバー用の暗号化キー。ディスクの暗号化に使用する。"
  key_origin  = "generated"
}

resource "sakura_object_storage_bucket" "imageflux_live_streaming_archive_bucket" {
  name    = "imageflux-live-streaming-archive-storage-bucket"
  site_id = data.sakura_object_storage_site.ishikari.id
}

resource "sakura_object_storage_permission" "imageflux_live_streaming_archive_bucket_r_permission" {
  name = "ImageFlux Live StreamingアーカイブバケットRead権限"
  bucket_controls = [{
    bucket    = sakura_object_storage_bucket.imageflux_live_streaming_archive_bucket.name
    can_read  = true
    can_write = false
  }]
  site_id = data.sakura_object_storage_site.ishikari.id
}

resource "sakura_object_storage_permission" "imageflux_live_streaming_archive_bucket_rw_permission" {
  name = "ImageFlux Live StreamingアーカイブバケットReadWrite権限"
  bucket_controls = [{
    bucket    = sakura_object_storage_bucket.imageflux_live_streaming_archive_bucket.name
    can_read  = true
    can_write = true
  }]
  site_id = data.sakura_object_storage_site.ishikari.id
}

resource "sakura_packet_filter" "minimum_filter" {
  name        = "最低限のパケットフィルタ"
  description = "アーカイブ処理サーバー用の最低限のパケットフィルタ"
  zone        = var.zone
}

resource "sakura_packet_filter_rules" "rules" {
  packet_filter_id = sakura_packet_filter.minimum_filter.id
  zone             = var.zone

  expression = [
    {
      description      = "Allow SSH access. Limit source IP addresses, if needed."
      destination_port = "22"
      protocol         = "tcp"
      source_network   = "0.0.0.0/0"
    },
    {
      protocol       = "udp"
      source_port    = "123"
      source_network = "0.0.0.0/0"
    },
    {
      protocol         = "udp"
      destination_port = "68"
    },
    {
      protocol = "icmp"
    },
    {
      protocol         = "tcp"
      destination_port = "32768-61000"
    },
    {
      protocol         = "udp"
      destination_port = "32768-61000"
    },
    {
      protocol = "fragment"
    },
    {
      protocol    = "ip"
      allow       = false
      description = "Deny all except above rules."
    }
  ]
}

resource "sakura_script" "ffmpeg_golang_install_script" {
  name    = "FFmpegとGoのインストールスクリプト"
  class   = "shell"
  content = file("scripts/install_ffmpeg_golang.sh")
  icon_id = var.ubuntu_icon
}

resource "sakura_server" "archive_handling_server" {
  name        = "アーカイブ処理サーバ"
  description = "アーカイブ処理サーバ。アーカイブの文字起こし、要約、通知を行う。"

  core             = 1
  disks            = [sakura_disk.archive_handling_server_disk.id]
  icon_id          = var.ubuntu_icon
  interface_driver = "virtio"
  memory           = 2
  tags             = ["@keyboard-us"]
  zone             = var.zone

  disk_edit_parameter = {
    hostname            = "ubuntuhost"
    password_wo         = var.os_password
    password_wo_version = 1
    disable_pw_auth     = true

    ssh_key_ids = [sakura_ssh_key.archive_handling_server_sshkey.id]
    script = [{
      id = sakura_script.ffmpeg_golang_install_script.id
    }]
  }

  network_interface = [{
    upstream         = "shared"
    packet_filter_id = sakura_packet_filter.minimum_filter.id
  }]
}

# アーカイブ処理サーバーのSSHログイン用秘密鍵を生成
resource "tls_private_key" "temporary_ssh_key" {
  algorithm = "RSA"
  rsa_bits  = 4096
}
// 秘密鍵をファイルに保存
resource "local_sensitive_file" "private_key_file" {
  content  = tls_private_key.temporary_ssh_key.private_key_pem
  filename = ".ssh/id_rsa.pem"
}
// 生成したSSH公開鍵をさくらのクラウドのSSHキーリソースに登録
resource "sakura_ssh_key" "archive_handling_server_sshkey" {
  name        = "アーカイブ処理サーバーSSHキー"
  description = "アーカイブ処理サーバー用のSSHキー。.ssh/ディレクトリに保存された秘密鍵とペア。"
  public_key  = tls_private_key.temporary_ssh_key.public_key_openssh
}

resource "sakura_simple_notification_destination" "notification_target_for_imageflux_live_streaming_archive_notifier" {
  name        = "通知先メールアドレス"
  description = "ImageFlux Live Streamingアーカイブ・要約完了時の通知先メールアドレス"
  type        = "email"
  value       = var.email_address
}

resource "sakura_simple_notification_group" "notification_group_for_imageflux_live_streaming_archive_notifier" {
  name         = "ImageFlux Live Streamingアーカイブ通知グループ"
  description  = "ImageFlux Live Streamingアーカイブ・要約完了時の通知グループ"
  destinations = [sakura_simple_notification_destination.notification_target_for_imageflux_live_streaming_archive_notifier.id]
}

resource "sakura_webaccel" "imageflux_live_streaming_archive_domain" {
  name             = "ImageFlux Live Streamingアーカイブ配信ドメイン"
  domain_type      = "subdomain"
  request_protocol = "https-redirect"
  cors_rules = [
    {
      allow_all = true
    }
  ]

  origin_parameters = {
    type                   = "bucket"
    access_key_wo          = sakura_object_storage_permission.imageflux_live_streaming_archive_bucket_r_permission.access_key
    secret_access_key_wo   = sakura_object_storage_permission.imageflux_live_streaming_archive_bucket_r_permission.secret_key
    bucket_name            = sakura_object_storage_bucket.imageflux_live_streaming_archive_bucket.name
    credentials_wo_version = 1
    use_document_index     = true
    endpoint               = join("", ["s3.", data.sakura_object_storage_site.ishikari.endpoint])
    region                 = data.sakura_object_storage_site.ishikari.region
  }

  logging = {
    enabled                = true
    bucket_name            = sakura_object_storage_bucket.imageflux_live_streaming_archive_bucket.name
    access_key_wo          = sakura_object_storage_permission.imageflux_live_streaming_archive_bucket_rw_permission.access_key
    secret_access_key_wo   = sakura_object_storage_permission.imageflux_live_streaming_archive_bucket_rw_permission.secret_key
    credentials_wo_version = 1
    endpoint               = join("", ["s3.", data.sakura_object_storage_site.ishikari.endpoint])
    region                 = data.sakura_object_storage_site.ishikari.region
  }
  default_cache_ttl = 334
  normalize_ae      = "gzip"
}
resource "sakura_webaccel_activation" "imageflux_live_streaming_archive_domain_activation" {
  site_id = sakura_webaccel.imageflux_live_streaming_archive_domain.id
  enabled = true
}

サーバは最小構成としながらも、MP3エンコードで負荷がかかった場合に備え、メモリは2GBに設定してあります。

変数ファイル(terraform.tfvars)にこれまでの設定値を入力し、以下のコマンドを実行することで、残りの全基盤が構築できます。

Terraform init
Terraform apply

構築が終わると、サーバのIPアドレスほか、サーバの設定に必要な値が出力されるので、これをメモしておきます。

アプリケーションの動作準備

シンプル通知の認証

シンプル通知先のメールアドレスに、本登録URLが届くので、開いて本登録を完了させます。これでシンプル通知を受け取ることが可能になります。

AppRun共用型のログ有効化(任意)

AppRun共用型のログを有効化すると、WebhookやAPI呼び出しのログが見られるようになります。確認したい場合は有効にしておきましょう。

サーバ準備

ファイルの準備

まず、config.jsonにこれまで控えた値を記入します。これがサーバ側のロジックが読み込む設定ファイルに相当します。本記事では説明を簡潔にするため設定ファイルへ直接記載していますが、本番環境ではシークレットマネージャや環境変数による管理を推奨します。

さくらのAI Engineで使用するモデルもここで設定します。設定ファイル(config.json)にある通り、文字起こしについてはwhisper-large-v3-turbo、要約についてはllm-jp-3.1-8x13b-instruct4というモデルを使用しています。

{
  "service_principal": {
    "key_kid": "サービスプリンシパルキーのKID",
    "resource_id": "サービスプリンシパルのリソースID",
    "private_key_pem_path": "サービスプリンシパルキーに紐づいた秘密鍵のパス"
  },
  "simplemq": {
    "api_key": "シンプルMQのキュー認証APIキー",
    "queue_name": "キュー名称",
    "poll_interval": "5s",
    "error_retry_interval": "60s"
  },
  "archive": {
    "http_domain": "配信録画をホストするさくらのウェブアクセラレータのドメイン"
  },
  "ai_engine": {
    "api_key": "さくらのAI EngineのAPIキー",
    "transcription_model": "whisper-large-v3-turbo",
    "summary_model": "llm-jp-3.1-8x13b-instruct4"
  },
  "object_storage": {
    "endpoint": "s3.isk01.sakurastorage.jp",
    "access_key": "アクセスキーID",
    "secret_key": "シークレットアクセスキー",
    "bucket": "バケット名",
    "region": "jp-north-1"
  },
  "notification": {
    "group_id": "シンプル通知のグループID"
  }
}

次に、Terraformが生成した秘密鍵を用いてサーバに入り、ロジック資源を格納します。あわせて、サービスプリンシパルキーに紐づいた秘密鍵も格納します。私はWinSCPを用いましたが、任意の方法で構いません。

ファイルの準備ができたら、アプリケーションを起動します。

アプリケーション起動

秘密鍵を用いて、サーバにSSHでログインします。

ssh -i ./.ssh/id_rsa.pem ubuntu@[サーバのIPアドレス]

スタートアップスクリプトを用いてGoとFFmpegはインストール済みであるため、アプリケーションのビルド・サービス登録だけ行います。

cd server/
# Goのビルド
go build -o archive-handler ./cmd/server
# ファイル編集。下記参照
sudo vi /etc/systemd/system/archive-handler.service
# サービス起動
sudo systemctl daemon-reload
sudo systemctl start archive-handler
# 動作確認
journalctl -u archive-handler -f
# 再起動でも動くように登録
sudo systemctl enable archive-handler

サービスファイルは以下の内容で設定します。本番環境では、運用ポリシーに応じた配置を推奨します。

[Unit]
Description=Archive Handler Service
After=network.target



[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/server

ExecStart=/home/ubuntu/server/archive-handler -config /home/ubuntu/server/config.json

Restart=always
RestartSec=5

LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

ここまでできたら、構築完了です。

動作検証

実際に動作検証をしてみます。ImageFlux Live StreamingはPaaSとして提供されているため、基本的にはAPIを呼ぶことで配信チャンネル・URLを作成し、自身で構築したアプリケーションから配信を行う仕様になっています。

ただし、この配信アプリを最初から作っていると大変なので、今回は作成済みのデモアプリを使用します。その構築記事はこちらにまとまっていますので、よければご参照ください。

ImageFluxのコンソールUIからAPIトークンを発行し、

Terraformで作成したオブジェクトストレージのバケットをアーカイブ保存先として登録します。

そのうえで、WebRTC to HLSチャンネルを作成します(RTMP to HLSでも構いません)。

アーカイブ保存先には先ほどのバケット、イベントWebhook送信先にはAppRun共用型の公開URLを指定します。

URLが発行できたら、実際に配信をしてみます。今回は片方向配信で、さくらインターネットの会社紹介を音読してみました。

音読後、配信を終了します。

配信を終了すると処理が走り、文字起こし・要約ファイルのデータが格納され次第シンプル通知(メール)が届きます。

安全のため黒塗りしていますが、.m3u8のホストURLおよび文字起こし・要約のファイルパスが記述されています。

せっかくなので、アーカイブ動画とその文字起こし・要約をまとめて表示できるページを作成してみました。(ファイル名はindex.htmlとします)

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>S3 HLS Player</title>
    <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
    <style>
      body {
        font-family: sans-serif;
        margin: 20px;
        background-color: #000000;
      }
      h2 {
        color: #d7003a;
      }
      .container {
        max-width: 800px;
        margin: 0 auto;
        background: #0d0015;
        padding: 20px;
        border-radius: 8px;
      }
      .input-group {
        display: flex;
        gap: 10px;
        margin-bottom: 20px;
      }
      input[type="text"] {
        flex: 1;
        padding: 10px;
      }
      button {
        padding: 10px 20px;
        background-color: #d7003a;
        color: #fff;
        border: none;
      }
      video {
        width: 100%;
        margin-bottom: 20px;
      }
      .text-block {
        background: #111;
        color: #eee;
        padding: 15px;
        margin-top: 15px;
        border-radius: 6px;
        white-space: pre-wrap;
      }
      .section-title {
        color: #ff5a7a;
        margin-top: 20px;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h2>S3 HLS Player</h2>

      <div class="input-group">
        <input type="text" id="m3u8-url" placeholder="path/master.m3u8" />
        <button onclick="playVideo()">再生</button>
      </div>

      <video id="video" controls></video>

      <div id="summary-area"></div>
      <div id="transcript-area"></div>
    </div>

    <script>
      var hls = null;

      function getBasePath(url) {
        return url.substring(0, url.lastIndexOf("/") + 1);
      }

      async function fetchTextIfExists(url) {
        try {
          const res = await fetch(url);
          if (!res.ok) return null;
          return await res.text();
        } catch {
          return null;
        }
      }

      async function loadTextFiles(basePath) {
        const summaryArea = document.getElementById("summary-area");
        const transcriptArea = document.getElementById("transcript-area");

        summaryArea.innerHTML = "";
        transcriptArea.innerHTML = "";

        const summary = await fetchTextIfExists(basePath + "summary.txt");
        if (summary) {
          summaryArea.innerHTML = `
            <div class="section-title">要約</div>
            <div class="text-block">${escapeHtml(summary)}</div>
          `;
        }

        const transcript = await fetchTextIfExists(basePath + "transcript.txt");
        if (transcript) {
          transcriptArea.innerHTML = `
            <div class="section-title">文字起こし</div>
            <div class="text-block">${escapeHtml(transcript)}</div>
          `;
        }
      }

      function escapeHtml(text) {
        return text
          .replace(/&/g, "&amp;")
          .replace(/</g, "&lt;")
          .replace(/>/g, "&gt;")
      }

      async function playVideo() {
        var video = document.getElementById("video");
        var url = document.getElementById("m3u8-url").value;

        if (!url) {
          alert("m3u8ファイルのURLを入力してください");
          return;
        }

        if (hls) {
          hls.destroy();
        }

        if (Hls.isSupported()) {
          hls = new Hls();
          hls.loadSource(url);
          hls.attachMedia(video);
        } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
          video.src = url;
        } else {
          alert("HLS再生に対応していません");
        }

        const basePath = getBasePath(url);
        loadTextFiles(basePath);
      }
    </script>
  </body>
</html>

そのページでシンプル通知で届いた.m3u8のURLをブラウザに入力して読み込むと……

無事録画データと、さくらのAI Engineが出力した文字起こしおよび要約データが出力されました。かなり忠実に起こせています。

まとめ

今回は、ImageFlux Live Streaming、さくらのクラウド、さくらのAI Engineを活用して、ライブ配信アーカイブの自動要約機能を実装してみました。

本記事では最小構成で実装しましたが、データベースやシークレットマネージャを組み合わせることで、実運用にも耐える構成へ発展させられます。ImageFlux Live StreamingとさくらのAI Engineを組み合わせたアプリケーション開発の参考になれば幸いです。