ウェブ上で録画した「みんなのメッセージ」でお祝い動画をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、この間ふと結婚式でもらった食器を使ってることに気づいたんです。

すると、あることを思い出しました。

それは・・・・・・

そういえば、みんなのお祝いメッセージ動画つくったな😊

当時はここまでスマホもネットも発達してなかったので、直接みんなのところに行って、デジカメで全員分の動画を撮影したんですね。

すると、ふとあることが思い浮かびました。

今ならウェブ上で実装できるぞ👍

そうです。
もはや撮影どころか、行くことすらしなくてもみんなのメッセージを録画することすらできるわけです。

そこで❗

今回は「Laravel + ffmpeg」を使って「みんなのお祝い5秒メッセージ」を録画し、さらにそれら全てを1つに合体させる機能を実装してみることにしました。

ぜひ何かの参考になりましたら嬉しいです。😊✨

「友人の結婚式で
新郎とプロレス
したことがあります(痛かった😫)」

開発環境: Laravel 10.x

前提として

今回は動画の結合をするわけですが、この作業はffmpegを利用します。
そのため、ffmpegPHPから実行できることが前提になります。

また、ウェブ上での録画はhttpsが必須になりますので、ローカル環境であってもhttpsに対応させておいてください。(「オレオレ証明」で大丈夫でした)

では、今回も楽しんでやっていきましょう❗

パッケージをインストールする

実ははじめ、ffmpegexec()を使って直接コマンドを実行するつもりだったのですが、どうやらphp-ffmpegを元にしたlaravel-ffmpegというパッケージがあるよう(php 8.2もサポート済)なので、今回はこちらを使ってみることにします。

composer require pbmedia/laravel-ffmpeg

そして、各環境に合わせてffmpegの場所を.envにセットします。

.env

FFMPEG_BINARIES=/usr/bin/ffmpeg
FFPROBE_BINARIES=/usr/bin/ffprobe

なお、これはffmpegffprobeがある場所(パス)をセットするのですが、私の環境はLinuxなので上記のようになっています。

つまり、皆さんはWindows or Macだと思うので、それぞれの環境に合わせてください。(こちらの stack overflow によると以下のようになるようです)

Mac:

  • /usr/local/bin/ffmpeg
  • /usr/local/bin/ffprobe

Windows:

  • C:/FFmpeg/bin/ffmpeg.exe
  • C:/FFmpeg/bin/ffprobe.exe

Ubuntu:

  • /usr/bin/ffmpeg
  • /usr/bin/ffprobe

ちなみに、コンフィグファイルをLaravel側へコピーすると、タイムアウトなどの設定もできます。お好みで変更してみてください。

php artisan vendor:publish --provider="ProtoneMedia\LaravelFFMpeg\Support\ServiceProvider"

これでパッケージのインストールが完了しました👍

DB まわり(モデル、マイグレーション)をつくる

では、続いてデータベースに関連する部分をつくっていきます。
以下のコマンドを実行してください。

php artisan make:model CelebrationVideo -m

すると、モデルとマイグレーションの2ファイルが作成されるのでそれぞれ中身を以下のように変更します。

app/Models/CelebrationVideo.php

<?php

namespace App\Models;

use App\Events\CelebrationVideoCreated;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class CelebrationVideo extends Model
{
    use HasFactory;

    protected $dispatchesEvents = [
        'created' => CelebrationVideoCreated::class,
    ];
}

ここには、データが追加されたときに実行されるイベントをセットしています(後でご紹介します)

database/migrations/****_**_**_******_create_celebration_videos_table.php

// 省略

public function up(): void
{
    Schema::create('celebration_videos', function (Blueprint $table) {
        $table->id();
        $table->string('filename')->comment('ファイル名');
        $table->timestamps();
    });
}

// 省略

ではこの状態でテーブルを追加しましょう。
以下のコマンドを実行してください。

php artisan migrate

すると、実際のテーブルはこうなりました。

イベントをつくる

では、celebration_videosテーブルにデータが追加されたら、自動的にこれまでの動画を結合して1つにまとめた動画をつくるようにしておきましょう。

以下のコマンドを実行してください。

php artisan make:event CelebrationVideoCreated

すると、ファイルが作成されるので中身を以下のように変更します。

app/Events/CelebrationVideoCreated.php

<?php

namespace App\Events;

use App\Models\CelebrationVideo;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use FFMpeg;

class CelebrationVideoCreated
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     */
    public function __construct()
    {
        $videos = CelebrationVideo::query()
            ->orderBy('id', 'asc')
            ->get();
        $file_paths = $videos->map(function ($video) {

            return 'videos/celebration_videos/'. $video->filename; // storage/app 以下のパス

        });
        $save_filename = 'merged_video_'. $videos->count() .'.webm';
        $save_path = 'videos/celebration_videos/'. $save_filename; // storage/app 以下のパス

        try {

            FFMpeg::fromDisk('local')
                ->open($file_paths->toArray())
                ->export()
                ->concatWithoutTranscoding()
                ->save($save_path);

        } catch (\Exception $e) {

            throw $e;

        }
    }

// 以下省略

この中でやっているのは、celebration_videosテーブルに登録された動画ファイルのパスをゲットし、結合しているだけです。

なお、結合された動画のファイル名は「merged_video_(結合した動画の件数).webm」になります。

コントローラーをつくる

続いて、コントローラーです。
以下のコマンドを実行してください。

php artisan make:controller CelebrationVideoController

すると、ファイルが作成されるので中身を以下のように変更します。

app/Http/Controllers/CelebrationVideoController.php

<?php

namespace App\Http\Controllers;

use App\Models\CelebrationVideo;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class CelebrationVideoController extends Controller
{
    public function create()
    {
        return view('celebration_video.create');
    }

    public function store(Request $request)
    {
        // 注意: バリデーションは省略しています

        // 5秒動画の保存
        $file = $request->file('video');
        $filename = Str::uuid() .'.webm';
        $file->storeAs('videos/celebration_videos', $filename);

        $celebration_video = new CelebrationVideo();
        $celebration_video->filename = $filename;
        $celebration_video->save();

        return ['result' => true];
    }

    public function download()
    {
        $count = CelebrationVideo::count();
        $filename = 'merged_video_'. $count .'.webm';
        $filepath = storage_path('app/videos/celebration_videos/'. $filename);

        return response()->download($filepath, $filename, [
            'Content-Type' => 'video/webm',
        ]);
    }
}

この中では特別なことはしていませんが、中身は以下のようになります。

  • create(): 動画を撮影するフォームをつくる
  • store(): 撮影された動画を保存する
  • download(): 結合された動画をダウンロードする

ビューをつくる

次に、先ほどのコントローラーの中でセットした「ビュー」をつくります。
ここが今回のメインになるところですね。

resources/views/celebration_video/create.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>5秒動画を撮ってアップロード!</title>
    <script src="https://cdn.tailwindcss.com/3.3.5"></script>
    <script src="https://unpkg.com/vue@3.3.7/dist/vue.global.prod.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body class="bg-pink-100 h-screen flex flex-col justify-center items-center">

<div id="app" class="bg-white p-8 rounded-lg shadow-md w-1/2 border-4 border-pink-300 text-center">
    <h1 class="text-2xl font-bold mb-4 text-pink-600">5秒動画を撮ってアップロード!</h1>

    <!-- 録画中の画面 -->
    <div class="mb-4">
        <video id="video" class="w-full h-48 rounded border border-pink-300" autoplay></video>
    </div>

    <!-- カウントダウン表示 -->
    <div class="text-2xl font-bold text-pink-600 mb-4">
        残り <span v-text="countdownSeconds"></span> 秒
    </div>

    <!-- 録画開始ボタン -->
    <div class="mb-4">
        <button
            class="bg-pink-500 text-white px-4 py-2 rounded hover:bg-pink-600 text-3xl"
            @click="startRecording">
            録画開始
        </button>
    </div>
</div>

<div class="mt-8">
    <a href="{{ route('celebration_video.download') }}" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
        これまでの動画をダウンロード
    </a>
</div>

<script>

    Vue.createApp({
        data() {
            return {
                mediaRecorder: null,
                chunks: [],
                countdownSeconds: 5,
                timer: null,
            };
        },
        methods: {
            startRecording() {

                navigator.mediaDevices.getUserMedia({ video: true, audio: true }) // 動画と音声を取得
                    .then(stream => {

                        const video = document.getElementById('video');
                        video.srcObject = stream;

                        this.mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm' }); // webm 形式で録画
                        this.mediaRecorder.ondataavailable = e => {

                            this.chunks.push(e.data);

                        };

                        this.mediaRecorder.onstop = () => {

                            const mimeType = this.mediaRecorder.mimeType;
                            const blob = new Blob(this.chunks, { type: mimeType });

                            if (confirm('アップロードしますか?')) {

                                const url = '{{ route('celebration_video.store') }}';
                                const formData = new FormData();
                                const headers = { 'Content-Type': 'multipart/form-data' };
                                formData.append('video', blob);

                                axios.post(url, formData, { headers })
                                    .then(response => {

                                        if(response.data.result === true) {

                                            alert('アップロードしました!');
                                            this.chunks = [];

                                        }

                                    });

                            }

                        };

                        this.mediaRecorder.start();
                        this.timer = setInterval(() => {

                            this.countdownSeconds--;

                            if (this.countdownSeconds === 0) {

                                clearInterval(this.timer);
                                this.mediaRecorder.stop();
                                this.countdownSeconds = 5;

                            }

                        }, 1000);

                    });
            }
        },
    })
    .mount('#app');

</script>

</body>
</html>

この中でやっていることは以下の手順になっています。

  1. 録画開始ボタンをクリックすると録画を開始
  2. 5秒間撮影する
  3. 時間が来たら自動で録画をストップ
  4. 送信されたら動画を保存
  5. 保存したと同時に過去の動画すべてを合体させる
  6. ダウンロードボタンがクリックされたらダウンロードを実行

なお、少し詰まったところは動画データを送信して保存まではできたのですが、再生ができなかったところです。

実はwebmなので動画アプリケーションが対応してなかったというオチでした😂
Google Chromeなら再生できます。

ルートをつくる

最後にルートをつくって完了です。

routes/web.php

use App\Http\Controllers\CelebrationVideoController;

// 省略

Route::prefix('celebration_video')->controller(CelebrationVideoController::class)->group(function(){

    // フォーム
    Route::get('create', 'create')->name('celebration_video.create');
    Route::post('store', 'store')->name('celebration_video.store');

    // ダウンロード
    Route::get('download', 'download')->name('celebration_video.download');

});

これで作業はすべて完了です。
お疲れ様でした😊✨

テストしてみる

では、実際にテストしてみましょう。

まず「https://******/celebration_video/create」へブラウザでアクセスします。(https必須です❗)

すると・・・・・・

はい❗
録画のフォームが表示されました。

では、ここからは実際に3つ動画を撮影して作成した動画をご覧ください。

(最近読んだ本を3つ撮影しました)

企業様へのご提案

現在ブラウザは(過去にはできないと思われていたような)マルチメディアに関する機能も特別なインストールなどがなくても対応しています。

また、これはスマートフォンでも同じことで制限がある場合もありますが、大抵の場合はPCのブラウザでできることはスマートフォンでもできたりします。

もしそういった機能をご希望でしたら、ぜひお問い合わせよりお問い合わせください。

お待ちしております。😊✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで今回は「ブラウザ + Laravel」で動画撮影し、結合までしてみました。

今回はログインや個別IDなどがないので、そのままでは一般的には公開できませんが、そのうちホントに個人サービスとして公開してもいいのかななんて思っていました。

ただ、今回保存されたファイルのサイズを見てみると、大体1.7 MBぐらいあったのでサーバーの容量と負荷を考えるとそんなに簡単な話でもないのかなといった印象です。🤔

もちろんサーバーを2つに分けたりAWSとかならいけるんでしょうけど、それだけコストもかかりますし、そうなるとそこまで本腰入れる熱量もないですし・・・今回は見送りですね😂

もし熱量のある方、ぜひやってみてください。

ではでは〜❗

「結婚のお祝いに、
1〜2年分ぐらいの
ハミガキ粉あげたことあります」

このエントリーをはてなブックマークに追加       follow us in feedly