Closed12

ブラウザで動画を収録してサーバーへ送るデモを Express.js で作る

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コーディング

main.js
const express = require("express");
const path = require("path");
const fsPromises = require("fs/promises");

const app = express();

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

app.post("/api/upload/blob", async (req, res, next) => {
  try {
    const rawBody = await new Promise((resolve, reject) => {
      const chunks = [];

      req.on("data", (chunk) => chunks.push(chunk));
      req.on("end", () => resolve(Buffer.concat(chunks)));
      req.on("error", (err) => reject(err));
    });

    const outputDirectory = path.join(__dirname, "tmp");
    const outputFilename = Date.now() + req.query.extname;
    const outputPath = path.join(outputDirectory, outputFilename);

    await fsPromises.mkdir(outputDirectory, { recursive: true });
    await fsPromises.writeFile(outputPath, rawBody);

    res.status(201).end();
  } catch (err) {
    next(err);
  }
});

app.post("/api/upload/dataurl", express.json(), async (req, res, next) => {
  try {
    const recordedVideoBuffer = Buffer.from(
      req.body.dataUrl.split("base64,")[1],
      "base64"
    );

    const outputDirectory = path.join(__dirname, "tmp");
    const outputFilename = Date.now() + req.body.extname;
    const outputPath = path.join(outputDirectory, outputFilename);

    await fsPromises.mkdir(outputDirectory, { recursive: true });
    await fsPromises.writeFile(outputPath, recordedVideoBuffer);

    res.status(201).end();
  } catch (err) {
    next(err);
  }
});

app.listen(3000);
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.js で作る
    </title>
  </head>
  <body>
    <h1>ブラウザで動画を収録してサーバーへ送るデモを Express.js で作る</h1>
    <div>
      <button type="button" id="buttonStart">Start</button>
      <button type="button" id="buttonStop" disabled>Stop</button>
      <button type="button" id="buttonUploadBlob" disabled>
        Upload (Blob)
      </button>
      <button type="button" id="buttonUploadDataURL" disabled>
        Upload (DataURL)
      </button>
    </div>
    <div>
      <video autoplay muted playsinline id="videoLive"></video>
    </div>
    <div>
      <video controls playsinline id="videoRecorded"></video>
    </div>
    <script>
      async function main() {
        const buttonStart = document.querySelector("#buttonStart");
        const buttonStop = document.querySelector("#buttonStop");
        const buttonUploadBlob = document.querySelector("#buttonUploadBlob");
        const buttonUploadDataURL = document.querySelector(
          "#buttonUploadDataURL"
        );
        const videoLive = document.querySelector("#videoLive");
        const videoRecorded = document.querySelector("#videoRecorded");
        let recordedVideoBlob = null;

        const stream = await navigator.mediaDevices.getUserMedia({
          video: true,
          audio: true,
        });

        videoLive.srcObject = stream;

        const mediaRecorder = new MediaRecorder(stream, {
          mimeType: "video/webm",
        });

        buttonStart.addEventListener("click", () => {
          mediaRecorder.start();
          buttonStart.setAttribute("disabled", "");
          buttonStop.removeAttribute("disabled");
        });

        buttonStop.addEventListener("click", () => {
          mediaRecorder.stop();
          buttonStart.removeAttribute("disabled");
          buttonStop.setAttribute("disabled", "");
          buttonUploadBlob.removeAttribute("disabled");
          buttonUploadDataURL.removeAttribute("disabled");
        });

        mediaRecorder.addEventListener("dataavailable", (event) => {
          videoRecorded.src = URL.createObjectURL(event.data);
          recordedVideoBlob = event.data;
        });

        buttonUploadBlob.addEventListener("click", async () => {
          try {
            const search = new URLSearchParams({
              extname: ".webm",
            }).toString();

            const url = "/api/upload/blob?" + search;

            console.log(recordedVideoBlob);

            const response = await fetch(url, {
              method: "POST",
              headers: {
                "Content-Type": "application/octet-stream",
              },
              body: recordedVideoBlob,
            });

            if (response.status !== 201) {
              console.warn(response.status);
              console.warn(await response.text());
            }
          } catch (err) {
            console.error(err);
          }
        });

        buttonUploadDataURL.addEventListener("click", async () => {
          try {
            const url = "/api/upload/dataurl";
            const response = await fetch(url, {
              method: "POST",
              headers: {
                "Content-Type": "application/json; charset=UTF-8",
              },
              body: JSON.stringify({
                extname: ".webm",
                dataUrl: await convertBlob(recordedVideoBlob),
              }),
            });

            if (response.status !== 201) {
              console.warn(response.status);
              console.warn(await response.text());
            }
          } catch (err) {
            console.error(err);
          }
        });
      }

      async function convertBlob(blob) {
        return await new Promise((resolve, reject) => {
          const fileReader = new FileReader();

          const subscribe = () => {
            fileReader.addEventListener("abort", onAbort);
            fileReader.addEventListener("error", onError);
            fileReader.addEventListener("load", onLoad);
          };

          const unsubscribe = () => {
            fileReader.removeEventListener("abort", onAbort);
            fileReader.removeEventListener("error", onError);
            fileReader.removeEventListener("load", onLoad);
          };

          const onAbort = () => {
            unsubscribe();
            reject(new Error("abort"));
          };

          const onError = (event) => {
            unsubscribe();
            reject(event.target.error);
          };

          const onLoad = (event) => {
            unsubscribe();
            resolve(event.target.result);
          };

          subscribe();
          fileReader.readAsDataURL(blob);
        });
      }

      main();
    </script>
  </body>
</html>
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Blob と DataURL どっちが良い?

個人的には Blob の方が好き、理由としては変換が無くて楽ちん。

よく知らないけど AWS S3 や GCP Cloud Storage のファイルアップロード API も Blob アップロードが前提になっている気がする。

デメリットとしてはファイル名などのパラメーターを指定できないこと、複数のファイルを一度に送れないこと。

パラメーターはクエリパラメーターで代用できるから良いが、複数のファイルはどうしようもない。

DataURL はクライアントでの変換も必要だし、Base64 に変換しているから転送データ量も増えるのでおすすめではない。

どうしても JSON で送らなければならない時以外は Blob を選ぶ。

mimiHatmimiHat

いつもありがとうございます。

ああ、なるほど。Blobでは、ファイル名などのパラメータも指定できない、複数ファイルを一度に送れないんですね。

う~ん、そうなんだあ。

最大3つの動画ファイルを名前を付けてサーバーに送りたかったんですが、Blobだと別々に処理することになりますね。

ちなみに、DataURLだとファイルが大きくなるようですが(4/3倍くらい?)、動画ひとつ100MBくらいまではいけるかなと思っていたのですが、社内サーバーとはいえやっぱダメかな。

静止画は、DataURLでまとめて送ってるんで、操作画面をほぼ変えずに同じパターンでいけるとよかったんですが。。

勉強もかねて、BlobとDataURLと両方でやってみます。

そろそろ片付けて、TypeScript、React、Next.jsの勉強やらんとです。

また教えてくださいネ!

mimiHatmimiHat

DataURLの転送でrequest entity too largeが出たので、express.json({ extended: true, limit: '100mb'})としたら転送できました!

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コメントありがとうございます!

複数ファイルをアップロードする場合は Blob か Data URL か悩ましいですよね。

Data URL だと Base64 なのでおっしゃる通りファイルサイズが 4/3 倍くらいになります。

動画でファイルサイズが 100 MB だとブラウザの方で JSON.stringify() ができなそうなのでほぼ Blob 一択かなって感じがしましたが実際にはサーバー側で express.json({ extended: true, limit: '100mb'}) を設定すれば成功するんですね、勉強になりました。

静止画の場合はサイズが小さいので僕も同じ立場だったら Data URL を使うと思います、別の操作画面で同じようなことやっているのであれば流用できるのでなおさら楽で良いですよね。

Blob の方が行儀の良いやり方なのはわかるのですが Data URL を使った方が JSON リクエスト 1 回で済むので色々と楽ですよね。

Next.js / React + TypeScript でも何かお困りのことがありましたらお気軽にご質問くださいね 🙆

このスクラップは2023/04/10にクローズされました