Node.js・Expressでffmpegを実行し、アップロードした動画を変換する方法

はいさい！ちゅらデータぬオースティンやいびーん！

# 概要
本記事では、Node.jsの`child_process`の`spawn`を使って、アップロードされた動画をffmpegで変換する方法を紹介します。

## 背景
Node.jsでPythonなどのスクリプトを実行して、その結果を持ってレスポンスを返すようなコードが書きたいことが動機で本記事の内容を勉強することになりました。

Node.jsのイベントループシステムは非常に強力で、TypeScriptも共に使うと、JavaScriptの弱点も補ってほぼ無敵だと思います。

しかし、JavaScriptはイベントループがあっても、スレッドは一つ。そこで問題になるのは、大量のリクエストを捌くIOというより、時間がかかるプロセスが問題です。

また、Pythonの機械学習モデル、他言語のスクリプトなど、JavaScriptでは実行できないプロセスもあります。

そこで、助けになるのは、Node.jsの`child_process`機能です。

child_processは、サーバーのshellでコマンドを実行し、その結果を持ってNode.jsに知らせる諸関数を持っています。

これと、Promiseを一緒に使うと、何らかの重い処理を子プロセスに任せて、そのプロセスが終わった時に、Eventを配信して、レスポンスを返す、このような方法でメインスレッドを解放することができます。

### 引用
本記事は<a href="https://2ality.com/2022/07/nodejs-child-process.html#waiting-for-the-exit-of-a-child-process">こちらの2alityの記事</a>を参考にしています。

## 事前知識
本記事は前回、投稿したmulterを使ったファイルアップロードの記事を元に進めますので、multerをご存知内の方、Node.js+TypeScriptのプロジェクト・セットアップ方法を確認したい方は以下の記事をご確認ください。

https://qiita.com/tronicboy/items/e71c5f22a5a7da0faa36

なお、本記事では、ffmpegも使います。ffmpegの解説が本記事の目的ではないので、基本的に詳しい説明はしません。

## 事前準備
### Node.jsのローカルインストール
https://nodejs.org/en/download/

### ffmpegのダウンロード
https://ffmpeg.org/download.html

また、<a href="https://brew.sh/">homebrew</a>をお使いの方は、簡単にbrewでインストールできます。

https://formulae.brew.sh/formula/ffmpeg#default

# 前回のMIMEタイプを修正する
前回の記事では様々なMIMEタイプを受け取ってもいいようにしていましたが、今回は動画だけアップロードができるようにしたいので、修正します。
```typescript:src/index.ts
import bodyParser from "body-parser";
import express from "express";
import { createServer } from "http";
import path from "path";
import multer from "multer";

const storage = multer.diskStorage({
  destination(req, file, callback) {
    callback(null, path.resolve(__dirname, "../uploads"));
  },
  filename(req, file, callback) {
    const uniqueSuffix = Math.random().toString(26).substring(4, 10);
    callback(null, `${Date.now()}-${uniqueSuffix}-${file.originalname}`);
  },
});

const upload = multer({
  storage,
  fileFilter(req, file, callback) {
    console.log(file.mimetype);
    if (file.mimetype.includes("video/")) {
      callback(null, true);
      return;
    }
    callback(new TypeError("Invalid File Type"));
  },
});

const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use("/public", express.static(path.resolve(__dirname, "../public")));

app.get("/", (req, res) => {
  res.sendFile(path.join(__dirname, "index.html"));
});

// "file"とは、<input name="file">の"file"というフィールド名に当たります
app.post("/", upload.single("file"), (req, res) => {
  console.log(req.file);
  res.redirect("/");
});

const server = createServer(app);
const port = 4000;
server.listen(port, () => console.log("Listening on port: ", port));
```
HTMLの\<form>も修正します。
```html:src/index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Express Multer Uploads</title>
</head>

<body>
  <style>
    form {
      display: flex;
      flex-direction: column;
      max-width: 500px;
      margin: 1rem auto;
    }
    input {
      margin-bottom: 1rem;
    }
  </style>
  <h1>Express Multer Uploads!</h1>
  <form action="/" method="POST" enctype="multipart/form-data">
    <label for="name">Name</label>
    <input type="text" name="name" id="name" minlength="1" maxlength="64" required>
    <label for="file">File</label>
    <input type="file" name="file" id="file" accept="video/*">
    <button type="submit">Send</button>
  </form>
</body>

</html>
```

# 単純なshellコマンドを実行してみる
まずは、ffmpegに手を出す前に、簡単にpwdなど、shellコマンドを実行してみたいと思います。

Node.jsの`child_process`という標準機能の中から、`spawn`という関数を使います。

この関数は、引数として、実行したいshell・コマンドをもらって、それを子プロセスとして実行してくれるものです。

他にもたくさんの設定があり、とても強力な機能ですが、今回は深掘りしません。

spawnが非同期プロセスになるので、例えすぐに終わるはずにコマンドでも、Node.jsのメインスレッドは待ってくれません。
```typescript:src/index.ts
import { spawn } from "child_process";
import fs from "fs";

...

app.post("/", upload.single("file"), (req, res) => {
  const childProcess = spawn(
    `(echo $(pwd)) > result.txt`, // 実行したいプロセスはここ
    {
      shell: true, // UnixのShellのすべての権限を与える
    }
  );

  childProcess.on("exit", (code, signal) => {
    console.log("Child Processs finished with code: ", code);
    const resultText = fs.readFileSync("result.txt", { encoding: "utf-8" });
    console.log("Shell output: ", resultText);
  });

  console.log("Child Process Initiated!"); // これは先にログに出ます！

  res.redirect("/");
});
...
```
これでPOSTで何かアップロードすると、ターミナルには以下のような出力があります。

![スクリーンショット 2022-07-13 10.42.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/e805138c-61f9-e43c-6706-d3a7ef7d66b0.png)
また、プロジェクトのルート・ダイレクトリーにも、`result.txt`が保存されています！

成功した時は、`code`が`0`になりますが、何らかのエラーがあったらそれ以外の番号になります。

例えば、以下のコードだったら、`127`が出ます。

```typescript
  const childProcess = spawn(
    `(ech $(pwd)) > result.txt`, // echは存在しないコマンド
    {
      shell: true, // UnixのShellのすべての権限を与える
    }
  );

  childProcess.on("exit", (code, signal) => {
    console.log("Child Processs finished with code: ", code); // ここが127番になる
  });
  childProcess.on("error", (error) => {
    console.log("Child process finished with error: ", error); // これは出ない！
  });
```
注意するべき事項として、`childProcess`のErrorイベントは、Node.jsの都合で`spawn`を実行できなかった時のみ配信されます。例えば、Node.jsが子プロセスを実行する許可がなかったなどの時です。
Shellコマンドが失敗しても、Errorイベントは配信されないので、ご注意ください。

## `spawn`をPromise化する
上記のコマンドをPromise化して、`childProcess`が終了した時に、レスポンスとしてその中身を返すようにしましょう！

`async/await`化して、若干読みやすくもしましょう。
```typescript
...

app.post("/", upload.single("file"), async (req, res) => {
  try {
    const result = await new Promise((resolve, reject) => {
      const childProcess = spawn(`(echo $(pwd)) > result.txt`, { shell: true });

      childProcess.on("exit", (code, signal) => {
        if (code !== 0) {
          reject(`Child process failed with exit code: ${code}`);
        }

        const resultText = fs.readFileSync("result.txt", { encoding: "utf-8" });
        resolve(resultText);
      });

      console.log("Child Process Initiated!");
    });

    res.statusCode = 200;
    res.send(result);
  } catch (error) {
    res.statusCode = 500;
    res.send(error);
  }
});

...
```
POSTしてみると以下のような結果になります。
![スクリーンショット 2022-07-13 11.17.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/f2d4ef0d-fe23-9c6a-d31f-978670c55da1.png)
これでフェーズ２にいけるぞ！

# `spawn`でffmpegを実行し、アップロードされた動画を変換する
次からは、もう少し複雑なコマンドを実行します。

アップロードした.movファイルをffmpegで画質を落としたmp4に変換します。

### `/converted`というフォルダーを作成します。
プロジェクトフォルダーに`/converted`という新規フォルダーを作っておきましょう。
ここに、変換した動画を一時的におきます。
![スクリーンショット 2022-07-13 11.25.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/542a1b27-3e1b-2a41-b301-ecd7226ceb7b.png)

### multerからアップロードされたファイルの新規ファイル名を教えてもらう
multerがアップロードされたファイルのファイル名を変更した上で`/uploads`に保管してくれます。
この新しいファイル名を`spawn`に渡す必要があるので、定数に保管します。
```typescript
...

app.post("/", upload.single("file"), async (req, res) => {
  try {
    if (!req.file) throw Error("A file must be provided.");

    const filename = req.file.filename;
    console.log(filename); // 123213-asjdnf-test.movのようなファイル名が出る

...
```

### `spawn`のコマンドを修正します。
以前echoしていたコマンドを以下のようにします。

ffmpegのコマンドは複雑なので、とりあえずこの便利なツールに任せて出してもらいましょう。

https://alfg.dev/ffmpeg-commander/

```typescript
...

app.post("/", upload.single("file"), async (req, res) => {
  try {
    if (!req.file) throw Error("A file must be provided.");

    const filename = req.file.filename;
    const filenameWithoutExtension = filename.split(".")[0];
    const result = await new Promise<string>((resolve, reject) => {
      const childProcess = spawn(
        `ffmpeg -i uploads/${filename} -c:v libx264 -preset veryfast -r 30 -vf "scale=720:-1" -c:a copy converted/${filenameWithoutExtension}.mp4`,
        { shell: true }
      );

      childProcess.on("exit", (code, signal) => {
        if (code !== 0) {
          reject(`Child process failed with exit code: ${code}`);
        }

        resolve(`converted/${filenameWithoutExtension}.mp4`);
      });
    });

    res.statusCode = 200;
    res.send(result);
  } catch (error) {
    res.statusCode = 500;
    res.send(error);
  }
});

...
```
これでもう一度動画をPOSTしてみると、時間は数秒かかりますが、以下のようなレスポンスが帰ってきます。
![スクリーンショット 2022-07-13 11.32.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/0e8d08a1-7068-2755-76df-8d7dd5787dac.png)
そして、`/converted`を見てみると、
![スクリーンショット 2022-07-13 11.33.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/dcc33f9f-36d7-7ab7-7f52-49a69a21e3aa.png)
変換されたファイルが無事に入っています！

# 変換したファイルをWeb Streamでユーザーに返す
ここからはおまけですが、変換した動画をユーザーにStreamとして返し、ファイルを削除するところまで紹介しておきたいと思いました。

```typescript
app.post("/", upload.single("file"), async (req, res) => {
  try {
    if (!req.file) throw Error("A file must be provided.");

    const filename = req.file.filename;
    const filenameWithoutExtension = filename.split(".")[0];
    const result = await new Promise<string>((resolve, reject) => {
      const childProcess = spawn(
        `ffmpeg -i uploads/${filename} -c:v libx264 -preset veryfast -r 30 -vf "scale=720:-1" -c:a copy converted/${filenameWithoutExtension}.mp4`,
        { shell: true }
      );

      childProcess.on("exit", (code, signal) => {
        if (code !== 0) {
          reject(`Child process failed with exit code: ${code}`);
        }

        resolve(`${filenameWithoutExtension}.mp4`);
      });
    });

    res.statusCode = 200;

    new Promise((reject, resolve) => {
      const stream = fs.createReadStream(path.resolve(__dirname, "../converted", result));
      stream.on("open", () => {
        res.attachment(result); // ここでダウンロードしてもらうファイル名を指定する
        stream.pipe(res);
      });

      stream.on("error", (err) => {
        reject("Converted file could not be read.");
      });

      stream.on("close", () => resolve(48));
    }).finally(() => spawn(`rm uploads/${filename} && rm converted/${result}`, { shell: true })); // これは待たなくてもいいので、awaitしません
  } catch (error) {
    res.statusCode = 500;
    res.send(error);
  }
});
```
# まとめ
これで`child_process`の`spawn`を軽く紹介してきましたが、いかがでしょうか？

`spawn`は深掘りすればとても複雑ですが、簡単な使い方でもとりあえず使えるのです。`spawn`をこなせば、shellのすべての力がNode.jsで発揮できるようになりますので、非常に魅力的です。

`child_process`の`exec`という関数もあって、こちらはより簡単に使っていただけるかと思います。