MoviePy を AWS Lambda (Chalice) で利用する

# はじめに

ウェブアプリケーションとしてアップロードしてきた動画を加工することを考えたい。 
そのために、Python と [moviepy](https://github.com/Zulko/moviepy) を使いたいが、Chalice (AWS Lambda) で動かしたいと思うとやはり色々と準備が必要になる。　ここではそのためにどのようなことを行ったかを記載する。
なお、Chalice については自前の記事を参照。

https://qiita.com/t-kigi/items/d2cb619eca6b0d382ab3


# 動作手順

## 1. ローカルでの検証

まずはちゃんとローカルでの動作検証を行う。 ローカルでは Virtual Box で動かす Ubuntu 20.04 LTS を利用している。

### 初期設定

```bash
$ pipenv install --python=3.8
$ pipenv install moviepy chalice boto3
$ pipenv run chalice --version
chalice 1.23.0, python 3.8.5, linux 5.8.0-53-generic

$ pipenv lock -r
#
# These requirements were autogenerated by pipenv
# To regenerate from the project's Pipfile, run:
#
#    pipenv lock --requirements
#

-i https://pypi.org/simple
attrs==20.3.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
blessed==1.17.6
boto3==1.17.83
botocore==1.20.83; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
certifi==2020.12.5
chalice==1.23.0
chardet==4.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
click==7.1.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
decorator==4.4.2
idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
imageio-ffmpeg==0.4.4; python_version >= '3.4'
imageio==2.9.0
inquirer==2.7.0
jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
moviepy==1.0.3
mypy-extensions==0.4.3
numpy==1.20.3; python_version >= '3.7'
pillow==8.2.0; python_version >= '3.6'
proglog==0.1.9
python-dateutil==2.8.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
python-editor==1.0.4
pyyaml==5.4.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
readchar==2.0.1
requests==2.25.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
s3transfer==0.4.2
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
tqdm==4.61.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
urllib3==1.26.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
wcwidth==0.2.5
wheel==0.36.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'

```

### ソースコード

`pipenv run chalice new-project moviepy` で新しいプロジェクトを作成して、`app.py` , `requirements.txt` , `.chalice/config.json` をそれぞれ以下の通り編集する。
実際にLambdaで動かすことを想定して、S3から動画をダウンロードして読み込み加工。 その後別の動画ファイルを出力した後に、最終的にS3に動画をアップロードする。

```python:app.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

import os
import uuid

import boto3
from chalice import Chalice
from moviepy.editor import (
    VideoFileClip, TextClip, CompositeVideoClip
)

app = Chalice(app_name='moviepy')
s3bucket = '<WRITE YOUR BUCKET>'

profile = os.environ.get('PROFILE')
if profile:
    # ローカルでプロファイルを使う場合
    s3 = boto3.session.Session(profile_name=profile).client('s3')
    # Ubuntu の場合なので、他ディストリビューションの場合は適時変更すること
    fontdir = '/usr/share/fonts'
else:
    s3 = boto3.client('s3')
    fontdir = '/opt/fonts'


def rms(pathes: list):
    """ ファイルが存在した場合は消去する """
    for path in pathes:
        try:
            if os.path.exists(path):
                os.remove(path)
        except Exception:
            pass


@app.route('/')
def convert():
    """ 動画を読み込んで、書き出すテスト """
    file_prefix = str(uuid.uuid4())
    srcpath = f'/tmp/{file_prefix}-in.mp4'
    destpath = f'/tmp/{file_prefix}-out.mp4'
    mp3path = f'/tmp/{file_prefix}-audio.mp3'
    fontpath = f'{fontdir}/opentype/noto/NotoSansCJK-Regular.ttc'

    try:
        # download into /tmp
        s3.download_file(s3bucket, 'moviepy/sample.mp4', srcpath)

        # load, composite and save other movie file
        clip1 = VideoFileClip(srcpath)
        txt_clip = TextClip(
            'test', fontsize=100, color='white', font=fontpath)
        txt_clip = txt_clip.set_position("center").set_duration((0, 10))
        clip2 = CompositeVideoClip([clip1, txt_clip])
        clip2 = clip2.set_duration(clip1.duration)
        clip2.write_videofile(destpath, temp_audiofile=mp3path, logger=None)

        # upload output
        s3.upload_file(destpath, s3bucket, f'moviepy/{file_prefix}.mp4')
    finally:
        rms([srcpath, destpath, mp3path])

    return {'file': file_prefix}
```

```txt:requirements.txt
moviepy==1.0.3
```

```json:.chalice/config.json
{
  "version": "2.0",
  "app_name": "moviepy",
  "stages": {
    "dev": {
      "api_gateway_stage": "api",
    },
    "local": {
      "environment_variables": {
        "PROFILE": "work",
        "STAGE": "local"
      }
    }
  }
}

```

### ImageMagick の設定

moviepy は内部で ImageMagick のコマンドを叩いているものがある。 そのため、機能をフルで使おうとすると、ImageMagick の convert を利用可能としておく必要がある。 インストールしていない場合は `convert` を使えるように ImageMagick をインストールする。
この例だと、テキストを動画に貼り付ける場合 ( `TextClip` の利用時) に以下のようなコマンドを使っていた。

```bash
$ convert -background transparent -fill white -font /opt/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc -pointsize 100 -gravity center label:@/tmp/tmpbmzo1zhy.txt -type truecolormatte PNG32:/tmp/tmpi8atxclc.png
```

デフォルトではこれは以下のような理由で失敗した (ファイル名は違うが)。

```
OSError: convert-im6.q16: attempt to perform an operation not allowed by the security policy `@/tmp/tmp9e4m1vpg.txt' @ error/property.c/InterpretImageProperties/3666.
convert-im6.q16: no images defined `PNG32:/tmp/tmp1wzu2c1y.png' @ error/convert.c/ConvertImageCommand/3258
```

これは ImageMagick のデフォルトポリシー内の `<policy domain="path" rights="none" pattern="@*"/>` という項目で `@` 付きのファイル参照が禁じられているため。 ポリシーのコメントにもあるが、例えばテキストファイルを書き出してしまうとパスワードが画像経由で流出するなどの危険性があるためにデフォルトで禁止されているようだ。
Ubuntu (aptで入ってきたバージョンが6系) の場合は `/etc/ImageMagick-6/policy.xml` にポリシーの記載があるので、これをコメントアウトすることで上記のコマンドが正常に動作するようになる。


### ローカルでの動作確認

動作開始前に、検証用のS3バケットに `moviepy/sample.mp4` の動画ファイルを配置する。
また、chalice のプログラムがこの S3 バケットにアクセスできるような権限をローカル環境上に設定すること。

```bash
# ローカルで convert を動かすように待機
$ pipenv run chalice local --stage local

# 別ウインドウで curl を実行して、変換が終了するまでまつ
# 用意する動画のファイルサイズなどにもよるが、
# 2.2MB 程度の mp4 ファイルの変換に 10秒強程度。
$ curl http://localhost:8000
{"file":"3ad9fe31-46c6-4964-879d-29044a7b2009"}
```

正常にレスポンスが返ってきた場合、指定S3バケットの `moviepy/` 以下にレスポンスと同様のファイルの `mp4` ファイルが存在するので、これを確認する。
元の動画の最初10秒に「test」という文字が中央表示されていれば成功。


## 2. Lambda 用の設定

ローカルでは正常に動いたので、これを Lambda 上で動くように設定していく。

### ImageMagick レイヤーの設定

デフォルトの Lambda には ImageMagick は入ってないので、Lambda上で ImageMagick を動かす設定をしなければならない。 
これを使うためには Lambda Layer を使うとよい。 Lambda Layer 構築用の環境を提供してくれているリポジトリがあるので、これを clone して自前で `make all` してレイヤーを作成して利用する。

https://github.com/serverlesspub/imagemagick-aws-lambda-2

…のだが、ここで提供されている ImageMagick には freetype が入っておらず、文字の書き込みを行うことができない。 そこで、リポジトリを fork して、freetype 込みのLambda Layer を作成した。

https://github.com/t-kigi/imagemagick-aws-lambda-2

これをローカルで clone して、ビルドの前提条件であるDocker のデーモンを動かした後、以下のコマンドを実行して「ImageMagick が利用可能、かつ、デフォルトのフォントを含む Lambda Layer」を作成する。

```bash
$ make all
# Docker で root 権限で実行されているので、中で zip 化するための権限を付与
# 所有者を変えても良いが、再ビルドとかを考えるとこっちの方が都合がよさそうなので今回はこれで
$ sudo chmod -R 777 build

# Lambda で使う Font を同一レイヤーに同梱
# 今回は Ubuntu 用のパスから Noto フォントをコピー
$ mkdir -p results/fonts/opentype/noto
$ cp /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc result/fonts/opentype/noto/
# フォントディレクトリの設定を記載
$ vim result/fonts/fonts.conf

# デプロイすると CloudFormation が走る
$ make deploy PROFILE=(デプロイ用プロファイル) DEPLOYMENT_BUCKET=(ビルド結果のZIPをアップロードするS3バケット)
# 中略
------------------------------------------------------------------------------------------------------------
|                                              DescribeStacks                                              |
+---------------------+---------------+--------------------------------------------------------------------+
|     Description     |   OutputKey   |                            OutputValue                             |
+---------------------+---------------+--------------------------------------------------------------------+
|  Layer ARN Reference|  LayerVersion |  arn:aws:lambda:ap-northeast-1:************:layer:image-magick:7   |
+---------------------+---------------+--------------------------------------------------------------------+

```

```xml:fonts.conf
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <dir>/opt/fonts/</dir>
  <cachedir>/tmp/fonts-cache/</cachedir>
  <config></config>
</fontconfig>
```

以上で Lambda Layer の作成は完了。
Font をLayer にインストールする経緯の詳細などはこちらでも。

https://qiita.com/t-kigi/items/acb3e8c5c8c08b8dcb51

### Chalice の設定

設定ファイルを以下のように記載。 変更点は `stages.dev` の部分。
Lambda はメモリ量を増やすと、1秒当たりの課金額も増えるが、同時にCPUも高機能になるという仕様がある。 動画変換にはメモリよりもCPUを多用することが想定されるので、メモリを 2048MB とやや過剰にあてて処理速度を上げようとしている (実際、テストで使ったファイルは 1024MB の割り当てだと約33秒、2048MB の割り当てだと約16秒で終わった。 単純に 1/2 であれば実行料金は同じだったりする)。
また、API Gateway のタイムアウトは最大30秒だが、例えAPI Gatewayがタイムアウトしても後ろのLambda は動き続ける。 ので、今回はたとえAPI呼び出しがタイムアウトしても1分以内には終わるという目算でのテストとなる。

```json:.chalice/config.json
{
  "version": "2.0",
  "app_name": "moviepy",
  "stages": {
    "dev": {
      "api_gateway_stage": "api",
      "lambda_timeout": 60,
      "lambda_memory_size": 2048,
      "layers": [
        "arn:aws:lambda:ap-northeast-1:************:layer:image-magick:7"
      ],
      "environment_variables": {
        "FONTCONFIG_PATH": "/opt/fonts",
        "STAGE": "dev"
      }
    },
    "local": {
      "environment_variables": {
        "PROFILE": "work",
        "STAGE": "local"
      }
    }
  }
}
```

これを記載したら、`dev` ステージをデプロイする。 デプロイ時に boto3.client を利用しているのでリソースへのアクセス権限IAMは自動生成してくれる。 デプロイ時のprofile の利用はお好みで。 

```bash:コマンドラインでのデプロイ
$ pipenv run chalice deploy --stage dev --profile work
Creating deployment package.
Updating policy for IAM role: moviepy-dev
Updating lambda function: moviepy-dev
Updating rest API
Resources deployed:
  - Lambda ARN: arn:aws:lambda:ap-northeast-1:************:function:moviepy-dev
  - Rest API URL: https://**********.execute-api.ap-northeast-1.amazonaws.com/api/
```

あとは、ここでできた API Endpoint をたたいて Lambda を起動する。

```bash
$ curl https://**********.execute-api.ap-northeast-1.amazonaws.com/api/
{"file":"99d288af-a82e-464f-a795-43c9b28f47ff"}
```

これで `s3://指定バケット/moviepy/[fileのuuid].mp4` に変換後の動画がアップロードされていれば正常に処理は完了している。


### 実際に使う場合の話

実際に Lambda で MoviePy を使うのであれば、S3にファイルがアップロードされたことをトリガにして動作させるのが一般的なユースケースだろう。 この場合は例えば以下のように書けば、特定のS3バケットにファイルがアップロードされたら Lambda を起動するようにできる。

```python
@app.on_s3_event('<bucketname>',
                 events=['s3:ObjectCreated:Put'],
                 prefix='<event prefix>', suffix='<event suffix>')
def movie_put_event(event):
    pass
```

また、Lambda の最大実行時間は[記事の執筆時点で15分](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html)である。 なので、あまりにも大きな動画ファイルの変換をしようとするとタイムアウトしてしまうため、すべてのケースで Lambda を使えるわけではない。
なので、これを使うのであれば十分に短い時間の動画であったり、解像度が低い動画であったり、といったユースケースで利用するとよい。


# まとめ

Chalice + MoviePy を使って、AWS Lambda 上で動画の編集を行うための方法を調査した。
ただ、実際には動画のエンコードなどの関係で必要になるリソースがあるため、これだけではまだうまく動かない機能があるかもしれないが、必要に応じて Layer にリソースを突っ込んでいけばある程度は動かせると思う。


# 参考

https://github.com/Zulko/moviepy/issues/693