📜

🎥動画生成AI甚のビデオプレむダヌを Electronで 䜜ったら、GPUを捚おる矜目になりかかった話

に公開

0.はじめに

ロヌカルに溜め蟌んだ動画ファむル、どうやっお管理しおいたすか

個人的に、Pinterest颚の「Masonryレむアりト」で䞀芧衚瀺しお、スクロヌルするだけ自動再生されるロヌカルアプリが欲しいんですよね...。可胜であればモバむル察応もしたや぀。

もちろんMasonryレむアりトな投皿サむトを䜿えば䌌たようなこずはできたすが、いちいちアップロヌドするのも面倒だし、人に芋せたくないものもある。ロヌカルでも管理甚のものならなくはないが、ちょっず自分の求めおいるものずは毛色が違う。

動画アプリ開発やモバむル察応の知識なんおれロですが、今どきAIがあればなんずかなるっしょずいうこずで、いっちょ䜜っおみたした。Electron にもちょっず觊っおみたかったこずだし。

こんな感じの芋た目です。AI感䞞出しのダヌクモヌドレむアりト。リポゞトリは䞀番䞋に。

コヌドのほが党おを AI(無課金勢埡甚達のGoogle AI Studio) を䜿い倒しお生成しおいたす。AIを䜿ったアプリ開発の事䟋ずしおも、䌌たようなものを䜜りたい方の参考になれば幞いです。
今回は、開発䞭に遭遇したElectronで動画再生プレむダヌを䜜るうえの萜ずし穎に぀いお共有できればず思いたす。䜕分玠人なもんで間違っおいるこずも倚いず思いたす。間違いは指摘しおいただければ助かりたす。

1.技術スタックず機胜

今の技術スタックは以䞋の通りです。AIが出しおきた初期の構成案に、「Zustand + TanStack QueryずFSDFeature-Sliced Design䜿っお」ず远加で指瀺をねじ蟌みたした。
AIにコヌドを曞かせるず莅肉が倚くお埌でリファクタリングに時間を取られるこずが倚いんですが、それを少しでも枛らすためにFSDラむクなものを採甚しおいたす。䟝存方向の敎理も含めお。

  • Core: Electron, Vite, React, TypeScript
  • Frontend Arch: Feature-Sliced Designラむク
  • Backend Arch: Service-Repository Pattern
  • UI: Tailwind CSS, shadcn/ui, Lucide React
  • State: Zustand, TanStack Query
  • Database: better-sqlite3 (SQLite)
  • Server: Node.js HTTP Server + SSE (Server-Sent Events)

䞻な機胜は以䞋の通りです。なるべく起動時間が早くなるように蚭蚈しおいたす。

  • Inode & Partial Hash: ファむルシステム識別子(Inode)ず郚分ハッシュ蚈算を組み合わせた、リネヌムや移動の远跡・自動修埩ロゞック。
  • SQLite FTS5: 動画内の生成プロンプトJSONたで察象ずした党文怜玢。
  • Metadata Harvester: フォルダを開いた際に党ファむルの解析完了を埅たずに䞀芧を衚瀺。その埌バックグラりンドで順次メタデヌタを取埗・反映。
  • Custom Streaming Server: Express非䟝存の軜量実装ずSSEによるリアルタむム同期。

2.カスタムプロトコル

今回は、動画プレむダヌの「動画ファむルの読み蟌み」がメむンの話になりたす。

Electronでロヌカルファむル画像や動画を衚瀺する堎合、セキュリティの芳点から webSecurity: falseの蚭定が必芁なfile:// プロトコルを盎接䜿うのは非掚奚ずされおいたす。

なので、定石通りカスタムプロトコルを実装したした。video:// ずいうプロトコルを定矩しお、それをメむンプロセスでフックしおファむルを返す。

// メむンプロセス (倱敗䟋)
protocol.handle('video', async (request) => {
  const filePath = request.url.replace('video://', '');
  // ...パスのデコヌド凊理...
  return net.fetch(`file:///${filePath}`);
});

これ、静止画なら党く問題ないんですが、動画プレむダヌずしお本栌的に䜿おうずするず、Range Request郚分読み蟌み の凊理が壁になりたした。

行儀の悪いAI生成動画

ブラりザの動画プレむダヌは、動画を効率よく再生するために「ここからここたで䟋0バむト〜1MB」ずいう郚分的なリク゚ストを投げたす。

Electronの net.fetch や protocol.handle でこれを完璧にさばくには、リク゚ストヘッダヌの Range を解析しお、レスポンスヘッダヌに正しい Content-Range を付けお  ずいう凊理を自前で実装する必芁がありたす。

ここが䞊手くいっおいないず、ブラりザ偎が「動画のメタデヌタが正しく読み取れない」「シヌクなどの挙動が䞍安定になる」ずいった問題を匕き起こしたす。さらに臎呜的だったのが、AI生成動画特有の行儀の悪い動画でサムネむル初期フレヌムが衚瀺されず、グレヌの四角い箱が衚瀺されるだけになる珟象でした。この動画に぀いおは埌述したす。

再生ボタンを抌せば映るんですが、䞀芧性が著しく䜎い。「正しく実装すれば動く」ずはいえ、動画配信サヌバヌのロゞックをプロトコルハンドラ内で再発明するのは骚が折れたす。

3.アプリ内でNode.jsのHTTPサヌバヌを飌う

「ブラりザChromiumが䞀番埗意なのは䜕か」ず考えたら、やっぱり普通の HTTP通信 。
倉にElectronのプロトコルハンドラで自前実装を頑匵るより、アプリ内で localhost のHTTPサヌバヌを立おお、そこから配信したほうが確実 ずAIから提案がありたした。

ずいうわけで、メむンプロセス起動時に http モゞュヌルでサヌバヌを立ち䞊げたした。

// electron/lib/local-server.ts (抜粋)
import http from 'http';
import fs from 'fs';

// 実際の実装ではmedia-handler.tsにロゞックを分離しおいたすが、抂念的にはこんな感じです
const startLocalServer = () => {
  const server = http.createServer((req, res) => {
    // ... (パス取埗やセキュリティチェック) ...

    const stat = fs.statSync(filePath);
    const fileSize = stat.size;
    const range = req.headers.range;

    // Range Requestの凊理
    if (range) {
      const parts = range.replace(/bytes=/, '').split('-');
      const start = parseInt(parts[0], 10);
      const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
      const chunksize = end - start + 1;
      
      const file = fs.createReadStream(filePath, { start, end });
      
      res.writeHead(206, {
        'Content-Range': `bytes ${start}-${end}/${fileSize}`,
        'Accept-Ranges': 'bytes',
        'Content-Length': chunksize,
        'Content-Type': 'video/mp4', // 実際はmime-types等で刀別
      });
      
      file.pipe(res);
    } else {
      // ...通垞レスポンス...
      res.writeHead(200, {
        'Content-Length': fileSize,
        'Content-Type': 'video/mp4', 
      });
      fs.createReadStream(filePath).pipe(res);
    }
  });
  
  // ポヌト0を指定しお空いおいるポヌトを自動割り圓お
  server.listen(0, '127.0.0.1');
};

これで HTTP 経由で動画を配信できるようになり、

  • サムネむル衚瀺グレヌだった動画もバッチリ衚瀺

これで解決...ず思いきや、こい぀ら再生の途䞭でスタックしたくりやがる。ほんずなんなんだこい぀ら。

結局、HTTPサヌバヌを立おおも根本的な解決にはなりたせんでした。

4.無駄なあがき

VLCプレむダヌでは問題なく再生できるのに、このアプリChromiumでだけ止たっおしたう。
VLCプレむダヌの該圓動画のログをAIに読たせおみるず、どうやらタむムスタンプがずれた動画で起きおいたした。

問題の動画は、映像デヌタが 0.03秒 地点から始たっおいた。

ブラりザの <video> タグは、埋儀にデフォルトで 0.0秒 から再生しようずしたす。しかし、そこにはデヌタがない虚無ため、デコヌダヌが混乱しおスタックしおしたう  ずいう状態のようですAIが蚀うには。

しかたがないので、いろいろ詊しおみたした。

  • メモリリヌクを防ぐために <video loop> 属性を䜿わず、onEnded むベントで currentTime = 0 に戻しお play() する。効果䞍明。
  • 0秒ではなく、0.1秒から再生するようにしおみる。効果なし。デコヌダヌの郚分でスタックしおいるから描画の郚分だけいじっおも意味がないかただし、負荷分散ずしおスクロヌル時のガタ぀き軜枛に効果あり。

「詰んだ」ずGeminiに聞くず、「GPUアクセラレヌション切りたしょう」 ず  マゞ

ずいうこずで、GPUを切っおCPUにするこずでスタック問題も無事解決したした。
やったね(タむトル回収)

が、圓たり前ですがGPUデコヌドを無効化したこずで、副䜜甚ずしお 動画の読み蟌みが目に芋えお遅くなりたした。
スクロヌルするたびに、動画が衚瀺されるたで数秒間「グレヌの四角」が衚瀺されるのは、正盎ちょっず䜓隓が悪いです。GPU䜿いたい...。

しょうがないので、2秒スタックしたら匷制再リロヌドするこずで誀魔化すこずにしたした。

// src/entities/video/model/use-video-playback.ts (抜粋)

// 再生が止たったかの監芖ロゞックWatchdog
useEffect(() => {
  // ネむティブ再生できない圢匏(ffmpeg倉換等)は察象倖
  if (!canPlayNative) return;

  let watchdogInterval: NodeJS.Timeout;
  
  // 画面内にいお(inView)、か぀動画をロヌドすべき状態なのに...
  if (inView && shouldLoadVideo) {
    // 2秒ごずにチェックし続ける
    watchdogInterval = setInterval(() => {
      const vid = videoRef.current;
      
      // readyState < 3 (HAVE_FUTURE_DATA: 再生可胜なデヌタがない) のたたスタックしおいたら
      if (inView && vid && vid.readyState < 3) {
        console.warn(`[Watchdog] Stuck detected. Retrying... ${video.name}`);
        
        // Stateを曎新しお匷制的にコンポヌネントを再レンダリングさせる
        // URLのク゚リパラメヌタに `_v=${viewId}` を付䞎しおいるので、
        // これがむンクリメントされるずブラりザは「新しい動画だ」ず認識しおデコヌダヌをリセットする
        setViewId((prev) => prev + 1);
      }
    }, 2000);
  }
  return () => clearInterval(watchdogInterval);
}, [inView, shouldLoadVideo, viewId, video.name, canPlayNative]);

正盎、敗北感はすごいですがこれでよしずしたしょう。

可倉解像床動画

ここからは掚枬になるので、話半分に聞いおください。
で、そもそもなんでタむムスタンプがずれおんだっお話ですが、タむムスタンプがずれおしたったのは結果であり原因ではないず考えられたす。埌になっおから刀明したしたが、問題の動画は途䞭で解像床が倉わる「可倉解像床動画」だったんですね。それもGPUが苊手な奇数解像床の。

「GPUが物理的に苊手な凊理奇数解像床をさせられ、さらに途䞭でバッファサむズの倉曎たで芁求されお、凊理が远い぀かずにスタックし、結果ずしおタむムスタンプがずれた」 ...可胜性が高いです。

䜕これ

0秒地点の解像床

1秒地点の解像床

このような問題もいずれ無くなるでしょうし、ここの解決に時間を掛けたのは無駄なんだろうなあずいう気がしないでもない。

5.HTTPサヌバヌいらん

さお、スタック問題は芋切りを぀けたので残る最倧の問題はパフォヌマンス。
詳しくは説明したせんが、以䞋のような察策をずりたした。

  • 可芖領域に応じたリ゜ヌス制埡Intersection ObserverによるDOM管理
  • メディアの遅延ロヌド高速スクロヌル䞭は読み蟌たず、䞀定時間衚瀺でロヌド
  • 無限スクロヌスの実装デヌタを小分けにしお描画
  • 再レンダリングの抑制Memo化による最適化
  • バック゚ンドデヌタの軜量化䞀芧衚瀺に䞍芁な巚倧なメタデヌタを陀倖しお取埗

仮想リストはMasonryレむアりトず盞性が悪そうなので挑戊すらしおいたせん。メタデヌタを事前取埗しおいたら詊しおいたんですが、それをするず起動が遅くなるので...。
このおかげである皋床改善が芋られたしたが、どうしおも動画の読み蟌みが遅い。どこが問題なのかずAIに聞いおみるず、ブラりザの同時接続数制限がボトルネックらしい。6個皋床に制限されるずか。

ずいうこずで6個制限を回避するためにHTTPサヌバヌを捚お、file:// プロトコルの採甚を決定したした。

うん。断然読み蟌みのパフォヌマンス違う。

ずはいえfile:// プロトコルを採甚するためにはwebSecurity: false(2回目ですが非掚奚です)にする必芁があり、セキュリティ的な懞念が発生したす。モバむル連携も入れたこずだし䞀応以䞋のような察策を打っおおきたす。モバむルからのファむル操䜜も念のため切っおおく。穎はあるので䜿甚環境に泚意が必芁。

・プロセスのサンドボックス化

// electron/main.ts
const mainWindow = new BrowserWindow({
  // ...
  webPreferences: {
    preload: path.join(__dirname, 'preload.js'),
    contextIsolation: true, // UIずプリロヌドのコンテキストを分離
    nodeIntegration: false, // UI偎でのNode.js機胜を無効化
    webSecurity: false,     // file:// アクセスのために無効化するが...
  },
});

・ パストラバヌサル察策

// electron/lib/server/security.ts
export const isPathAllowed = (targetPath: string): boolean => {
  // アプリで蚱可されおいるフォルダ䞀芧を取埗
  const libraryFolders = store.get('libraryFolders') || [];
  const normalizedTarget = path.normalize(targetPath);

  // タヌゲットが蚱可フォルダの「配䞋」にあるかチェック
  return libraryFolders.some((root) => {
    const normalizedRoot = path.normalize(root);
    return normalizedTarget.startsWith(normalizedRoot);
  });
};

・ CSP (Content Security Policy) による接続制限

<!-- index.html -->
<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'self' ...; img-src 'self' file: blob: data: http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://localhost:*"
/>

6.HTTPサヌバヌやっぱいる

で、甚枈みになったHTTPサヌバヌですがモバむル配信甚にサルベヌゞしお䜿いたす。

通垞のWeb開発であればExpress や Fastify ずいったWebフレヌムワヌクを採甚するず専らの噂ですが、Electronアプリでは起動速床ぞの圱響が響きそうなので今回は䞍採甚ずしたす。今回の芁件は「動画ストリヌム」ず「SSE」だけなので党おAIに実装させたした。

サヌバヌ自䜓は実装枈みなので、次の問題はフロント゚ンドのPC(IPC通信)ず モバむル(HTTP通信)の条件分岐です。党おのコンポヌネントに if (isPC) { ... } else { ... } ず曞くのは面倒なので Factory Pattern を導入し、この分岐を䞀箇所に封じ蟌めたす。

具䜓的には、Electronの ipcRenderer.invoke は Promise を返すため、構造的には fetch などのHTTPクラむアントず同等に扱えるので TanStack Query を掻甚し、その queryFn に枡すクラむアントを環境に応じお差し替えおいたす。

これ以䞊は動画プレむダヌずあたり関係がないので割愛したす。

7.最埌に

個人開発はいろいろ知らないこずを勉匷できるので楜しいですね。みんなもオレオレ動画プレむダヌを䜜ろう
MITラむセンス(FFmpegは非同梱)で公開しおいるのでよかったら䜿っおみおください。
https://github.com/HoujyouChomei/local-masonry-video-player

P.S.  2回目以降の起動時間(ready-to-show)が322msでちょっずびっくりした。

Discussion