AWS LambdaでFFmpeg/FFprobeを使ってみた

AWS LambdaでFFmpeg/FFprobeを使ってみた

こんにちは、豊島です。

はじめに

Lambdaで動画/音声ファイルを処理したいシーンで有効なffmpeg-lambda-layerを紹介します。
今回は動画のフォーマット再生時間を取得することを目的としますが、
FFmpegには他にも

  • 動画ファイルの合成、リサイズ
  • ウォーターマークの挿入
  • 動画から音声ファイルを生成
  • 動画/音声のフォーマットの変換(MP4->AVIなど)
  • サムネイルの取得

など、多くの機能が用意されています。
気になる方はFFmpegの公式ドキュメントをご確認ください。
なお、今回はFFprobeコマンドの実行にfluent-ffmpegというパッケージを利用します。

Lambda Layerをデプロイ

AWS verified authorとして用意されているApplicationをデプロイします。

  • Deployをクリック
    ffmpeg_lambda_layer_deploy

  • テンプレートやアクセス許可、ライセンスが表示されるので確認してデプロイを実行
    ffmpeg_lambda_layer_deploy_check

  • デプロイ完了
    deployed_ffmpeg_lambda_layer

Lambda Layerを設定する

今回はAmplify Gen2のFunctionを使って対象のLambda Layerを設定します。

import { defineFunction } from "@aws-amplify/backend"

export const ffmpegSample = defineFunction({
  name: "ffmpegSample",
  entry: "./handler.ts",
  layers: {
    ffmpeg: "arn:aws:lambda:{region}:{accountId}:layer:ffmpeg:1"
  }
})

S3バケットを作成、Lambdaに権限を付与する

こちらも同じくAmplify Gen2のStorage(S3)を使っていきます。
動画ファイルを取得するためs3:GetObjectが付与されるようにしてください。

import { defineStorage } from "@aws-amplify/backend"
import { ffmpegSample } from "../functions/ffmpegSample/resource"

export const storage = defineStorage({
  name: "amplifyDevDrive",
  access: (allow) => ({
    "ffmpegTest/*": [allow.resource(ffmpegSample).to(["get"])]
  })
})

FFprobeコマンドを実行する

  1. 対象のバケットから動画ファイルをエフェメラルストレージへダウンロード
  2. ffprobeコマンドを実行
  3. エフェメラルストレージのファイルを削除

の流れで処理をしていきます

サンプルコード

import fs from "fs"
import path from "path"
import { promisify } from "util"
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"
import type { Handler } from "aws-lambda"
import ffmpeg from "fluent-ffmpeg"
import { Readable } from "stream"

const s3Client = new S3Client({ region: process.env.AWS_REGION })

const unlinkAsync = promisify(fs.unlink)

export const handler: Handler = async () => {
  const bucket = {バケット名}
  const key = {オブジェクトキー}
  const tempFilePath = path.join("/tmp", "video.mp4")

  try {
    const { Body } = await s3Client.send(
      new GetObjectCommand({ Bucket: bucket, Key: key })
    )

    if (!Body) {
      throw new Error()
    }

    const readableStream = Body as Readable
    await new Promise((resolve, reject) => {
      const writeStream = fs.createWriteStream(tempFilePath)
      readableStream.pipe(writeStream)
      writeStream.on("finish", resolve)
      writeStream.on("error", reject)
    })

    const metadata = await new Promise((resolve, reject) => {
      ffmpeg.ffprobe(tempFilePath, (err, metadata) => {
        if (err) {
          console.error("ffprobe error:", err)
          reject(err)
        } else {
          console.log("ffprobe result:", JSON.stringify(metadata, null, 2))
          resolve(metadata)
        }
      })
    })

    await unlinkAsync(tempFilePath)
    console.log(`Temporary file ${tempFilePath} deleted`)

    return {
      statusCode: 200,
      body: JSON.stringify(metadata)
    }
  } catch (error) {
    console.error("Error:", error)
    try {
      await unlinkAsync(tempFilePath)
      console.log(`Temporary file ${tempFilePath} deleted after error`)
    } catch (unlinkError) {
      console.error("Error deleting temporary file:", unlinkError)
    }
    return {
      statusCode: 500,
      body: JSON.stringify({
        error: "An error occurred while processing the video"
      })
    }
  }
}

実行結果を確認する

formatの項目にformat_name(フォーマット), duration(再生時間)が存在することを確認できました。
ちなみにformat_name, format_long_nameは動画ファイルを解析した結果が出力されるため、拡張子を変更しただけの動画ファイルであっても本来の形式を取得することができます。

{
  "streams": [
      {
          "index": 0,
          "codec_name": "h264",
          "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
          "profile": "Baseline",
          "codec_type": "video",
          "codec_time_base": "23151/1388000",
          "codec_tag_string": "avc1",
          "codec_tag": "0x31637661",
          "width": 1280,
          "height": 720,
          "coded_width": 1280,
          "coded_height": 720,
          "has_b_frames": 0,
          "sample_aspect_ratio": "1:1",
          "display_aspect_ratio": "16:9",
          "pix_fmt": "yuv420p",
          "level": 31,
          "color_range": "tv",
          "color_space": "smpte170m",
          "color_transfer": "bt709",
          "color_primaries": "bt709",
          "chroma_location": "topleft",
          "field_order": "unknown",
          "timecode": "N/A",
          "refs": 1,
          "is_avc": "true",
          "nal_length_size": 4,
          "id": "N/A",
          "r_frame_rate": "359/12",
          "avg_frame_rate": "694000/23151",
          "time_base": "1/16000",
          "start_pts": 224,
          "start_time": 0.014,
          "duration_ts": 185208,
          "duration": 11.5755,
          "bit_rate": 460477,
          "max_bit_rate": "N/A",
          "bits_per_raw_sample": 8,
          "nb_frames": 347,
          "nb_read_frames": "N/A",
          "nb_read_packets": "N/A",
          "tags": {
              "language": "eng",
              "handler_name": "VideoHandler"
          },
          "disposition": {
              "default": 1,
              "dub": 0,
              "original": 0,
              "comment": 0,
              "lyrics": 0,
              "karaoke": 0,
              "forced": 0,
              "hearing_impaired": 0,
              "visual_impaired": 0,
              "clean_effects": 0,
              "attached_pic": 0,
              "timed_thumbnails": 0
          }
      },
      {
          "index": 1,
          "codec_name": "aac",
          "codec_long_name": "AAC (Advanced Audio Coding)",
          "profile": "LC",
          "codec_type": "audio",
          "codec_time_base": "1/48000",
          "codec_tag_string": "mp4a",
          "codec_tag": "0x6134706d",
          "sample_fmt": "fltp",
          "sample_rate": 48000,
          "channels": 1,
          "channel_layout": "mono",
          "bits_per_sample": 0,
          "id": "N/A",
          "r_frame_rate": "0/0",
          "avg_frame_rate": "0/0",
          "time_base": "1/48000",
          "start_pts": 0,
          "start_time": 0,
          "duration_ts": 555840,
          "duration": 11.58,
          "bit_rate": 69630,
          "max_bit_rate": 69502,
          "bits_per_raw_sample": "N/A",
          "nb_frames": 544,
          "nb_read_frames": "N/A",
          "nb_read_packets": "N/A",
          "tags": {
              "language": "eng",
              "handler_name": "SoundHandler"
          },
          "disposition": {
              "default": 1,
              "dub": 0,
              "original": 0,
              "comment": 0,
              "lyrics": 0,
              "karaoke": 0,
              "forced": 0,
              "hearing_impaired": 0,
              "visual_impaired": 0,
              "clean_effects": 0,
              "attached_pic": 0,
              "timed_thumbnails": 0
          }
      }
  ],
  "format": {
      "filename": "/tmp/video.mp4",
      "nb_streams": 2,
      "nb_programs": 0,
      "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
      "format_long_name": "QuickTime / MOV",
      "start_time": 0,
      "duration": 11.59,
      "size": 780881,
      "bit_rate": 539003,
      "probe_score": 100,
      "tags": {
          "major_brand": "isom",
          "minor_version": "512",
          "compatible_brands": "isomiso2avc1mp41",
          "encoder": "Lavf58.76.100"
      }
  },
  "chapters": []
}

最後に

LambdaでFFmpeg/FFprobeを使ってみました。
動画/音声ファイルをLambdaで処理したい方の参考になると幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.