Enludus
お問い合わせ

動画編集を AI に任せるために、3 つに分けたサービスを 1 つに戻した

素材の動画を入れると、AI が中身を見て編集の指示書を作り、縦型の動画に書き出す Nue の話です。最初は 3 つの言語・3 つのサービスに分けて作り、そのあと Rust と Next.js の 2 つにまとめ直しています。

Nue は、撮った動画を入れると、AI が中身を見て「どこを使うか」「どこに字幕を入れるか」「どこで効果音を鳴らすか」を決め、そのとおりに縦型の動画へ書き出すしくみです。いまも開発中で、この記事に書くのは途中経過です。

この記事では、Nue が何をしているかと、作る途中で 3 つに分けたサービスを 1 つに戻した 話を書きます。

なぜ作っているか

動画を作るとき、いちばん重いのは撮影より編集だと感じていました。カットを選び、字幕を入れ、音量をそろえる。その作業量が、発信すること自体のハードルになります。

そこで、素材の取り込みから解析、書き出しまでを自動化したい と考えたのが始まりです。

Nue がやっていること

流れは 3 段です。

  1. 受け取る:Web の画面に動画をドラッグすると、サーバーの決まったフォルダに保存されます。
  2. 見る:使う区間、字幕、効果音のタイミング、ズーム、サムネイルにする場面を決めます。API キーがなくても動くように、まず手元で解析します(無音の区間、音量のピーク、場面の切り替えを FFmpeg で見つけ、字幕は whisper.cpp で起こす)。Gemini の API キーを設定したときだけ、Google の Gemini に動画を渡して決めてもらいます。
  3. 焼く:その決定をもとに、FFmpeg で動画を切り出し、縦型(1080×1920)にして、字幕や音を重ねて書き出します。

2 と 3 の間には、編集指示書 と呼んでいる JSON ファイルが 1 枚あります。「00:00:03 から 00:00:08 までを使う。字幕は『WOW!』、黄色で下に。00:00:05 に効果音」といったことが書かれた、AI と書き出し担当の間の台本のようなものです。

この指示書を挟んでいるのには理由があります。AI の判断が文字として残るので、人が読めますし、あとから直すこともできます。書き出し側は「指示書に書いてあるとおりに焼く」ことだけに集中できます。

Gemini の呼び出しが失敗したときや、使えるカットが返ってこなかったときは、手元の解析で指示書を書き直します。AI が答えられなくても、動画は出てきます。

最初は、3 つの言語で 3 つのサービスにした

作り始めたとき、Nue は役割ごとに別々のプログラムに分かれていました。

名前 言語 役割
Gateway Go 動画のアップロードを受け取る
Brain Python Gemini に動画を見せて、編集指示書を作る
Muscle Rust 指示書どおりに FFmpeg で動画を書き出す

それぞれを Docker のコンテナとして動かし、共有のフォルダでファイルを受け渡していました。Gateway が raw フォルダに動画を置くと、Brain がそれに気づいて解析し、json フォルダに指示書を置く。今度は Muscle がそれに気づいて書き出し、output フォルダに完成品を置く、という順番です。

脳(Brain)と筋肉(Muscle)という名前のとおり、「考える」と「手を動かす」をはっきり分けたかったのです。言語もそれぞれの得意なところで選びました。軽くて速いアップロード受付は Go、AI のライブラリが揃っている Python、重い映像処理を落ちにくく書ける Rust。メモリの上限も Gateway は 128MB、Brain は 1GB、Muscle は 3GB と、コンテナごとに分けていました。

あとから Web の画面(Next.js)と、参考になる動画のスタイルを集める Trend Watcher(Python)も加わり、言語は 4 つになりました。

分けて、困ったこと

動くものはできました。ただ、手を入れるたびに引っかかることが増えていきました。

わかりやすいのが「いまどこまで進んだか」の表示です。最初の Web 画面は、アップロードが終わると 5 秒後に「完了」と表示するだけ でした。実際に書き出しが終わったかどうかは見ていなかったのです。

これを直そうとして、各段階が jobs フォルダに「解析中」「書き出し中」「完了」と書き込むしくみを足しました。すると、同じ「進み具合を書き込む処理」を、Go・Python・Rust・TypeScript の 4 か所で書くことになりました。しかも、更新時刻の書き方がそれぞれ違います。ある言語は「2026-04-12T15:01:51Z」のような文字列、別の言語は 1970 年からの秒数です。

ほかにも、

  • コンテナごとに Dockerfile があり、FFmpeg は 2 つのコンテナに別々に入っている
  • ログの形式、エラーの返し方、設定の読み方が言語ごとに少しずつ違う
  • 1 つの機能を直すのに、複数の言語のコードを読まないといけない
  • Gemini の API キーを、複数のコンテナにそれぞれ配っている

ということが、少しずつ積み重なっていました。

「責務を分ける」と「サービスを分ける」は別のこと

振り返ると、「考える」と「焼く」を分けたこと自体は正しかったと思っています。間違えたのは、その分け目を、プログラムやコンテナの分け目と同じにしてしまったこと でした。

責務を分けるのは、コードを読みやすく、直しやすくするためです。サービスを分けると、それに加えて、通信、デプロイ、ログ、設定、監視がサービスの数だけ必要になります。サービスを分けるのは、別々に増やしたい、別々の人が持つ、別々の速さで更新したい、といった理由があるときです。

Nue は、ひとりで作っている、手元で動かす動画生成のしくみです。Gateway は「ファイルを受け取って保存する」だけで、Go でなければならない理由はまだありませんでした。Brain も中身はほぼ「Gemini に動画を渡して JSON を受け取る」処理で、Python でなければ書けないものではありません。

リポジトリの方針メモには、こう書きました。

Nue の価値は、複数言語を使うことではなく、短時間で質の高い動画を作ることです。

いま、どうなっているか

今年の 4 月に、構成を Rust と Next.js の 2 つ にまとめ直す作業を始めました。

  • Rust:これまでの Muscle を広げて、analyze(AI で指示書を作る)、render(指示書から書き出す)、run(その 2 つを続けて行う)、worker(アップロードを見張って自動で処理する)の 4 つの使い方ができる 1 つのプログラムにしました。Gemini とのやりとりも Rust に移しています。
  • Next.js:画面に加えて、アップロードの受付、進み具合の確認、完成した動画の配信を担当します。Go の Gateway がしていた仕事です。

「考える」と「焼く」は、今も別の関数で、間には今までどおり編集指示書の JSON が挟まっています。分け目は残したまま、1 つのプログラムの中に入れた ということです。コマンド 1 つで動くので、Web を立ち上げなくても、手元の動画を直接処理できます。

Go と Python のサービスは、最初は「legacy」として残し、普段の起動では動かないようにしました。Rust 側で同じことができるのを確かめてから消す段取りでしたが、その段取りは終わっています(下の「更新」を参照)。

書き出し側でも、一度シンプルにした

映像の書き出しでも、似たことがありました。

最初の Muscle は、すべてのカット、場面転換のクロスフェード、話し声に合わせて BGM を下げる処理、効果音を、FFmpeg の 1 本の長い命令にまとめていました。賢いのですが、どこかが 1 つおかしいと全体が止まり、どこが原因かも追いにくい作り方です。効果音が鳴らない不具合を追いかけたとき、カットを 1 本ずつ別々に書き出して最後につなぐ方式に書き直しました。

その代わり、場面転換のクロスフェードと、話し声に合わせて BGM を自動で下げる処理は、このときは入っていませんでした。BGM は小さめの固定の音量で、効果音は合成で作った 2 種類だけでした。わかりやすさと引き換えに手放したもので、BGM を下げる処理はあとで戻しています(下の「更新」を参照)。場面転換のクロスフェードは、まだ戻していません。

これから

移行は終わったので、これからは、インフラを足すより、出てくる動画の質に時間を使いたいと考えています。カットの選び方、音量、字幕、仕上がりを見て直す画面。データベースや分散処理のしくみは、ファイルのやりとりで間に合わなくなってから入れます。

Python を二度と使わない、と決めたわけでもありません。音声の文字起こしや画像の解析のように、Python の道具が本当に必要になったら、その部分だけを独立させて戻すつもりです。そのときは「この処理は別にしたい理由がある」と言えるはずです。


編集指示書の中身、Gemini への渡し方、FFmpeg のフィルタの組み立て、Rust のコードの構成、エラーと一時ファイルの扱い、Next.js の API まで、ソースを引用して追った技術編は「Nue のパイプラインを全部書く」に書きました。

更新(2026-10-20)

この記事を書いたあとで、「これから」に書いていた移行を終わらせました。書いたまま古くなっていた箇所は、上の本文を直しています。

  • Go の Gateway、Python の Brain、Trend Watcher を消しました。docker compose up で起動するのは、Rust の worker と Next.js の Web の 2 つだけです。
  • Web 画面の台本や手動の指示(「この区間は消す」など)を、Rust 側で読むようにしました。BGM を動画の雰囲気に合わせて選ぶ処理も Rust にあります。
  • 話し声に合わせて BGM を下げる処理(FFmpeg の sidechaincompress)を戻しました。効果音の種類も増え、アイコンのスタンプ、横型・正方形の出力、.srt の書き出しも入っています。
  • Gemini の API キーがなくても最後まで動くようにしました(上の「見る」を参照)。
  • Web 画面で、できあがった指示書のカットや字幕、効果音を直して、書き出し直せるようにしました。
  • iPhone や iPad の中だけで動く iOS アプリ(SwiftUI と、指示書の仕様を Rust と共有する NueKit)を足しました。字幕は端末の音声認識、書き出しは AVFoundation です。
  • 自動テストを足しました。Rust の単体テスト、FFmpeg を実際に呼ぶ結合テスト、Gemini の API を模したテストが走り、CI は iOS 側のテストとシミュレータでの起動も確かめます。