はいさい!ちゅらデータぬオースティンやいびーん!
概要
本記事では、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を配信して、レスポンスを返す、このような方法でメインスレッドを解放することができます。
引用
本記事はこちらの2alityの記事を参考にしています。
事前知識
本記事は前回、投稿したmulterを使ったファイルアップロードの記事を元に進めますので、multerをご存知内の方、Node.js+TypeScriptのプロジェクト・セットアップ方法を確認したい方は以下の記事をご確認ください。
なお、本記事では、ffmpegも使います。ffmpegの解説が本記事の目的ではないので、基本的に詳しい説明はしません。
事前準備
Node.jsのローカルインストール
ffmpegのダウンロード
また、homebrewをお使いの方は、簡単にbrewでインストールできます。
前回のMIMEタイプを修正する
前回の記事では様々なMIMEタイプを受け取ってもいいようにしていましたが、今回は動画だけアップロードができるようにしたいので、修正します。
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>も修正します。
<!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のメインスレッドは待ってくれません。
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で何かアップロードすると、ターミナルには以下のような出力があります。
また、プロジェクトのルート・ダイレクトリーにも、result.txt
が保存されています!
成功した時は、code
が0
になりますが、何らかのエラーがあったらそれ以外の番号になります。
例えば、以下のコードだったら、127
が出ます。
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
化して、若干読みやすくもしましょう。
...
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してみると以下のような結果になります。
これでフェーズ2にいけるぞ!
spawn
でffmpegを実行し、アップロードされた動画を変換する
次からは、もう少し複雑なコマンドを実行します。
アップロードした.movファイルをffmpegで画質を落としたmp4に変換します。
/converted
というフォルダーを作成します。
プロジェクトフォルダーに/converted
という新規フォルダーを作っておきましょう。
ここに、変換した動画を一時的におきます。
multerからアップロードされたファイルの新規ファイル名を教えてもらう
multerがアップロードされたファイルのファイル名を変更した上で/uploads
に保管してくれます。
この新しいファイル名をspawn
に渡す必要があるので、定数に保管します。
...
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のコマンドは複雑なので、とりあえずこの便利なツールに任せて出してもらいましょう。
...
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してみると、時間は数秒かかりますが、以下のようなレスポンスが帰ってきます。
そして、/converted
を見てみると、
変換されたファイルが無事に入っています!
変換したファイルをWeb Streamでユーザーに返す
ここからはおまけですが、変換した動画をユーザーにStreamとして返し、ファイルを削除するところまで紹介しておきたいと思いました。
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
という関数もあって、こちらはより簡単に使っていただけるかと思います。