指定 bucket の google.storage.object.finalize をトリガにしてmp4ファイルをHLS形式に変換するサンプルです。

このサンプルでは bucket の /mp4 ディレクトリにmp4ファイルが何処からかアップロードされた時に /hls 以下にmp4ファイル単位のディレクトリを作りつつHLS形式ファイル一式を保存しています。

ただし、実運用ではmp4用とHLS用の bucket を分けた方が良いと思います。理由等は後述します。

package.json

{
  "dependencies": {
    "@google-cloud/storage": "^4.3.0",
    "ffmpeg-static": "^3.0.0",
    "fluent-ffmpeg": "^2.1.2"
  }
}

yarn.lock もコミットに含めておくと良さそうです。 https://cloud.google.com/functions/docs/writing/specifying-dependencies-nodejs

Node.js 8 以降のランタイムでは、yarn.lock ファイルが存在する場合は、Cloud Functions では代わりに yarn install コマンドが使用されます。

index.js

const ffmpeg = require('fluent-ffmpeg')()
const ffmpegPath = require('ffmpeg-static')
const fs = require('fs')
const os = require('os')
const path = require('path')
const process = require('child_process')
const storage = require('@google-cloud/storage')

const isValid = (data, context) => {
  return data.contentType === 'video/mp4' &&
    data.name.endsWith('.mp4') &&
    context.eventType === 'google.storage.object.finalize'
}

exports.mp4ToHls = async (data, context, callback) => {
  console.log(`data: ${JSON.stringify(data)}`)
  console.log(`context: ${JSON.stringify(context)}`)

  if (!isValid(data, context)) {
    console.log('invalid data or invalid context.')

    callback()

    return
  }

  const gcs = new storage.Storage()

  const info = {}

  info.mp4DirectoryName = 'mp4'
  info.hlsDirectoryName = 'hls'
  info.baseName = path.basename(data.name, '.mp4')
  info.keyFileName = `${info.baseName}.key`
  info.iv = process.execSync('openssl rand -hex 16').toString()

  info.bucketMp4Directory = path.join(info.mp4DirectoryName, info.baseName)
  info.bucketHlsDirectory = path.join(info.hlsDirectoryName, info.baseName)
  info.bucketHlsKeyFilePath = path.join(info.bucketHlsDirectory, info.keyFileName)

  info.localMp4Directory = path.join(os.tmpdir(), info.bucketMp4Directory)
  info.localHlsDirectory = path.join(os.tmpdir(), info.bucketHlsDirectory)
  info.localHlsKeyFilePath = path.join(info.localHlsDirectory, info.keyFileName)
  info.localMp4FilePath = path.join(info.localMp4Directory, path.basename(data.name))
  info.localHlsFilePath = path.join(info.localHlsDirectory, `${info.baseName}.m3u8`)
  info.localKeyInfoPath = path.join(os.tmpdir(), 'keyinfo')

  console.log(`info: ${JSON.stringify(info)}`)

  fs.mkdirSync(info.localMp4Directory, { recursive: true })
  fs.mkdirSync(info.localHlsDirectory, { recursive: true })

  process.execSync(`openssl rand 16 > ${info.localHlsKeyFilePath}`)

  fs.writeFileSync(info.localKeyInfoPath, [info.keyFileName, info.localHlsKeyFilePath, info.iv].join('\n'))

  await gcs.bucket(data.bucket).file(data.name).download({ destination: info.localMp4FilePath })

  ffmpeg
    .setFfmpegPath(ffmpegPath)
    .input(info.localMp4FilePath)
    .output(info.localHlsFilePath)
    .outputOptions([
      '-codec: copy',
      '-hls_time 10',
      '-hls_list_size 0',
      `-hls_key_info_file ${info.localKeyInfoPath}`,
    ])
    .on('end', (error, stdout) => {
      console.log(stdout)

      fs.readdirSync(info.localHlsDirectory).forEach(async (fileName) => {
        const src = path.join(info.localHlsDirectory, fileName)
        const dest = path.join(info.bucketHlsDirectory, fileName)

        console.log(`upload "${src}" to "${dest}"`)

        // TODO: Better to run in parallel
        // TODO: It might be better to upload to another bucket
        await gcs.bucket(data.bucket).upload(src, { destination: dest })
      })

      callback()
    })
    .on('error', (error, stdout, stderr) => {
      console.log(error)
      console.log(stdout)
      console.log(stderr)

      callback()
    })
    .run()
}

次のコマンドでデプロイできます。 YOUR_TRIGGER_BUCKET_NAME は置換して下さい。

gcloud functions deploy mp4ToHls --region asia-northeast1 --runtime nodejs10 --trigger-resource YOUR_TRIGGER_BUCKET_NAME --trigger-event google.storage.object.finalize

作成されたファイル一式は次のコマンドで手元にDLできます。

gsutil -m cp -R gs://YOUR_TRIGGER_BUCKET_NAME/hls ./dest

macのSafari等で再生確認もできました。アクセス制限を要する場合は署名付きURLを介することになるんだろうと思います。

https://cloud.google.com/storage/docs/gsutil/commands/signurl

欠点

このサンプルでは同一 bucket へHLS形式ファイル一式をアップロードしているので、それらのタイミングでも google.storage.object.finalize がトリガされてしまいます。ですので、冒頭で述べたように bucket を分けた方が良さそうです。

アップロード先の bucket 名に環境変数を用いれば staging/production 等の切り分けもできそうです。

https://cloud.google.com/functions/docs/env-var

今回使用したサンプルは以下に保存してあります。

https://github.com/mamor/gcp-sandbox/tree/master/functions