📹

Cloudflare Stream で動画配信するときに詰まったところ

Toshihiro Shimizu2022/12/09に公開

こんにちは。株式会社アイデミーの清水(@meso)です。

アイデミーは、DXリテラシーからAI/機械学習の専門知識までオンラインで学べるラーニングサービス Aidemy を運営しています。教材によってはもちろん動画を見ながら学べるため、その動画をどうやって配信するかというのが技術的な論点の1つになります。

この度、Aidemy の動画配信サービスを Brightcove から Cloudflare Stream に乗り換えることにしました。Cloudflare Stream を使うのは初めてだったので、動画の再生やアップロードなどの機能(API)の使い方を示すために、プロトタイプとして動画管理ツールを作成しました。その過程で詰まったところについて共有しようと思います。

そもそもなぜ動画配信サービスを乗り換えるのか

Brightcove の課金体系が、Aidemy とは相性が悪かったためです。Brightcove の課金体系は公開されてないようなので、あまり詳しいことは書きませんが、教育サービスで用いるには相性の良くない要素が含まれておりました。

そこで、シンプルに

  1. 動画のストレージ容量
  2. 動画の転送量

だけで課金額が決まる Cloudflare Stream に乗り換えることを決断しました。

料金プランだけであれば、他のサービスも同様の課金体系のところはあるのですが、Cloudflare は CDN としてすでに使っていたこともあり Cloudflare Stream に決めました。

メインのインフラは Google Cloud なため、Transcoder API が東京リージョンに対応していればそっちにしていたと思いますが、なんで対応していないんでしょうね。

プロトタイプの技術要素

Cloudflare Stream を使うことが決まり、プロトタイプとして動画管理ツールを使うことにしました。次に考えたのは、どういった技術スタックで動画管理ツールを作るかです。

Cloudflare には Cloudflare Workers やそれをベースにした Cloudflare Pages w/ Functions などの動的なWebサイトをデプロイする仕組みがあります。せっかく Cloudflare Stream を使うのであれば、同じ Cloudflare のインフラ内で動かす方が何かと便利かと思い、今回は Cloudflare Pages w/ Functions を使うことにしました。

また、当時(2022年春ごろ)は Cloudflare Pages w/ Functions で動くフレームワークの選択肢があまりなかったため、正式に対応を謳っていた Remix を採用することにしました。

詰まったところ

Cloudflare Stream も Remix も始めてだった、かつ、新しめのものだったので情報が少なくて(もしくは陳腐化が早くて)苦労しました。なので、ここに書く情報も、当時の情報であって今だと違っている可能性は高いので注意してください。

なお、Cloudflare Stream の API を Cloudflare Pages w/ Functions から呼び出す方法については、この公式ブログと GitHub で公開されてるソースコードが参考になりました。

また、公式ドキュメントも(頼れるのがこれしかないので)めっちゃ読みました。

環境変数の設定

Remix 1.3.4 と Wrangler 0.0.24 の場合、以下のようにするとローカル環境の環境変数を指定&取得することができます。

まず、環境変数を .env というファイルに記述します。ファイルの場所はルートディレクトリ( package.json とかがあるのと同じところで、 npm run dev を実行する場所)です。

.env
API_KEY=hogehoge

次に、Wrangler 実行時にこの環境変数を読み込むように package.json を修正します。

package.json
"dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public -b $(cat .env)",

Remix のコード中で環境変数を使う際には、LoaderFunction の引数 context から取得します。

index.tsx
export const loader: LoaderFunction = async (context) => {
  const env = context.context;
  console.log(env.API_KEY); // hogehoge が出力される
}

注意点としては、LoaderFunction はサーバーサイドで実行されますが、LoaderFunction の戻り値を受け取る userLoaderData() を呼び出す方のコードはクライアントで実行されるので、環境変数に入れてるような機微情報はそのまま受け渡すことのないようにしましょう。

動画の再生

動画の再生は難しくなく、詰まるところはほとんどなかったです。

Cloudflare が用意してくれてる React 用のプレーヤーがあるので、それを使えばよく、ただ再生するだけなら、srcVideoID署名されたIDを書くだけです。

ただ今回は、「10秒進む/戻る」の実装がしたかったので、プレーヤーに外から(プレーヤーは iframe の中にある)アクセスする必要がありました。その場合、streamRef 属性を使うことで実現ができます。

import { useLoaderData } from "@remix-run/react"
import { Stream, StreamPlayerApi } from "@cloudflare/stream-react";
import { useRef } from "react";

export default function VideoRoute() {
  const video = useLoaderData<VideoData>();
  const ref = useRef<StreamPlayerApi>();
  return (
    <main>
      <div>
        <Stream streamRef={ref} controls src={video.signedId} />
      </div>
      <div>
        <button onClick={() => {
          if (ref.current) {
           const now = ref.current.currentTime;
           ref.current.currentTime = now - 10;
         }}>&#xAB; 10秒戻る</button>
      </div>
    </main>
  )
}

動画のアップロード

一番詰まったのがここ。Cloudflare Stream では、動画をアップロードする方法をいくつか用意しているんですが、(動画管理ツールの)ユーザーに動画をアップロードしてもらうのであれば、Direct creator uploads という方法をとるのが良さそうです。
また、ファイルサイズが200MBを超えるなら、普通にアップロードするのではなく TUS というプロトコルでアップロードしないといけなく、正直めんどくさい感じです。

Direct creator uploads の手順は以下のとおりです。まず、ワンタイムのアップロード URL を取得します。これには API トークンが必要なのでサーバーサイドで実行します。その後、取得したアップロード URL をクライアントサイドに渡して、そのアップロード URL に向けて実際のアップロードを実行してもらう、という感じです。

ただし、TUS の場合はもうちょっとややこしくて、最初のアップロード URL をゲットする部分を API 化して、その API をクライアント側から tus-js-client ライブラリを使って呼び出す、という感じにしないといけません(さもなくば、TUS のクライントライブラリ相当のものを自分で作るか)。

tus-js-client から API が呼ばれる際には、ファイルサイズとかメタデータなどが Requestheaders に入っているので、それを取り出してアップロード URL を取得する API を叩いて、取得したアップロード URL を ResponseLocation ヘッダーに入れて返す、という動作の API(Remixの ActionFunction)を作ります。

upload/upload.tsx
import { json, ActionFunction } from "@remix-run/cloudflare";

export const action: ActionFunction = async ({ context, request }) => {
  const env = context;
  const size = request.headers.get("Upload-Length");
  const metadata = request.headers.get("Upload-Metadata");
  if (!size || typeof size !== "string" || size.length === 0 || 
      !metadata || typeof metadata !== "string" || metadata.length === 0) {
    return json({errors: {title: "File error."}}, {status: 422});
  }

  return new Response(null, {
    headers: {
      'Location': await getOneTimeEndpoint(size, metadata)
    }
  });

  async function getOneTimeEndpoint(size: string, metadata: string) {
    const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}/stream?direct_user=true`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${env.STREAM_API_TOKEN}`,
        'Tus-Resumable': '1.0.0',
        'Upload-Length': size,
        'Upload-Metadata': metadata,
      },
    });
    const endpoint = response.headers.get('Location');
    if (!endpoint) return "";
    return endpoint;
  }
};

この API を呼び出す側は、<input type="file" /> でファイルを取得して、そのファイルとメタデータを添えて tusUpload を呼び出す感じになります。

upload/index.tsx
function upload() {
  const options = {
    endpoint: '/upload/upload',
    chunkSize: 50 * 1024 * 1024,
    metadata: {
      name: file.name,
      maxDurationSeconds: 3600,
      requireSignedURLs: true,
    },
    onError(error: Error) {
      console.log(error);
    },
    onSuccess() {
      console.log("Upload Completed!");
    },
    onProgress(bytesUploaded: number, bytesTotal: number) {
      const percentage = ((bytesUploaded / bytesTotal) * 100).toFix(2) + "%";
      console.log(percentage);
    }
  };
  const upload = new tus.Upload(file, options);
  upload.start();
}

この tus.Upload に渡す optionsendpoint に指定するのが、最初に書いたワンタイムなアップロード URL なのかと勘違いしがち(僕も勘違いしたし、検索するとみんな勘違いしてた)なんですが、そうではなく、上で自分で作った API(ActionFunction)の URL なので注意。

あと、tus-js-client は、Remix の tsx ファイル上で import * as tus from "tus-js-client" したら、起動時にめっちゃエラーがでるので、import せずに <script src="~/tus.min.js" /> で読み込んでます。動くけど VSCode 上では tus が未定義だってエラーがでるけどしょうがない。

まとめ

そんな感じで、Cloudflare Stream を使った動画配信は無事に開発が進んでおります。今月中にはリリース予定です。

Cloudflare Pages w/ Functions や Remix を使った開発、サーバーサイドもクライアントサイドも TypeScript を用いた開発を行うことに興味のある方は、是非ご一緒に働いてみませんか!

お気軽にお声がけください。

https://aidemy.co.jp/recruit/

Aidemy Tech Blog

株式会社アイデミーの技術ブログです。

Discussion

ログインするとコメントできます