star back image
people4
電飾 電飾
moon
astronaut

【WEBアプリ】MP4をWebMに変換したい

BLOG AIWEBアプリWEBログ
読了約:70分

Webサイトに動画を載せるとき、「MP4は重い」と言われませんか。「軽量化のためにWebMにしたいけど変換がよくわからない」「オンライン変換は不安」など、そんな悩みはありませんでしょうか。

動画をアップロードせず、ブラウザ内だけでMP4→WebM変換できるツールを作りました。
ffmpeg.wasm を使い、画質とサイズのバランスを調整できるようにしています。

ffmpeg.wasmは結構前から存在していますね。

ffmpeg.wasmの解説はこちら:
GIGAZIN
https://gigazine.net/news/20201109-ffmpeg-wasm/

ffmpeg.wasmは元々ffmpegという動画変換ツールをWebブラウザ上で動かすために移植したもので、JavaScriptから直接動画変換が可能になります。

動画の容量が多いのは当たり前のことなんですが。少しでも読み込みを軽くしたいその気持ち。わかります。

でもアストロウェーブで、そんなことできるのか、さっぱりわかりません。
しかし今はAIがあります。

【共有】MP4をWebMに変換したいサンプルページ

変換サービスのサイトは、検索するといっぱいあります。
どれを使っていいかわからないし、変な広告がいっぱいで不安。画質もアレです。

自分用に作ろうと思いました。

MP4をWebMに変換したい(サンプルページ
https://astrowave.jp/amnesia_record/webp_mv.php

ざっくり技術的な仕組み
- ffmpegをWebAssembly化したもの
- JavaScript から ffmpeg を直接実行
- 動画データはブラウザ内のメモリだけで処理

つまり、動画は外部サーバーに送られません。

注意点
- PCブラウザ専用(スマホ不可)
- 大きすぎる動画は時間がかかる
- 初回はffmpeg.wasmはWebAssemblyで実装されているため、実行までは読み込みに少し時間がかかる

※技術的にはffmpeg.wasmはMP4以外の形式も処理できますが、本ツールでは安定性とわかりやすさを優先して「MP4」に限定しています。

格安レンタルサーバーであること、それを正直に話。AIさんに提案してもらいました。

要件は以下です。

  • 動画は一切アップロードされない
  • PCブラウザ内だけで変換(サーバー処理なし)
  • 画質とファイルサイズを自分で調整できる
  • 複数ファイル対応
  • 無料

webp_mv.php

<?php
// webp_mv.php
// ブラウザ内(ffmpeg.wasm)で MP4 → WebM 変換(サーバー処理なし)
?>
<!DOCTYPE html>
<html lang="ja">
<head>

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="" />
<meta name="keywords" content="WebM, MP4, 動画変換, 無料ツール, ブラウザ内変換" />
<meta name="description" content="ブラウザ上でMP4動画をWebM形式に変換できる無料ツールです(PC専用)。" />

<title>MP4→WebM変換ツール(無料)</title>

<style>
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --primary: #4f46e5;
  --primary-hover: #4338ca;
  --success: #10b981;
  --error: #ef4444;
  --bg: #f9fafb;
  --card: #ffffff;
  --text: #111827;
  --text-light: #6b7280;
  --border: #e5e7eb;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.6;
  min-height: 100vh;
}

.header {
  background: linear-gradient(135deg, var(--primary) 0%, #7c3aed 100%);
  color: white;
  text-align: center;
  padding: 3rem 2rem;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}

.header h1 {
  font-size: 2.5rem;
  margin-bottom: 0.5rem;
}

.header h2 {
  font-size: 1.1rem;
  font-weight: 400;
  margin-bottom: 0.5rem;
}

.subtitle {
  font-size: 1.1rem;
  opacity: 0.9;
}

.subtitle a {
  color: white;
  text-decoration: underline;
  font-size: 18px;
  transition: opacity 0.3s ease;
}

.subtitle a:hover { text-decoration: none; }

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

.upload-area {
  background: var(--card);
  border: 3px dashed var(--border);
  border-radius: 1rem;
  padding: 4rem 2rem;
  text-align: center;
  transition: all 0.3s ease;
  cursor: pointer;
  margin-bottom: 2rem;
}

.upload-area:hover {
  border-color: var(--primary);
  background: #fafbff;
}

.upload-area.dragover {
  border-color: var(--primary);
  background: #f0f4ff;
  transform: scale(1.02);
}

.upload-icon {
  width: 80px;
  height: 80px;
  margin: 0 auto 1rem;
  color: var(--primary);
}

.btn-primary {
  background: var(--primary);
  color: white;
  border: none;
  padding: 0.75rem 2rem;
  border-radius: 0.5rem;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s ease;
  margin: 1rem 0;
}

.btn-primary:hover {
  background: var(--primary-hover);
  transform: translateY(-2px);
}

.settings-card {
  background: var(--card);
  padding: 2rem;
  border-radius: 1rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  margin-bottom: 2rem;
  display: none;
}

.settings-card.active { display: block; }

.setting-item { margin-bottom: 1.5rem; }

.setting-item label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.5rem;
}

input[type="range"] {
  width: 100%;
  height: 8px;
  border-radius: 5px;
  background: var(--border);
  outline: none;
  -webkit-appearance: none;
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--primary);
  cursor: pointer;
}

select {
  width: 100%;
  padding: 0.65rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: #fff;
  font-size: 1rem;
}

.btn-convert {
  background: var(--success);
  color: white;
  border: none;
  padding: 1rem 3rem;
  border-radius: 0.5rem;
  font-size: 1.1rem;
  font-weight: 600;
  cursor: pointer;
  width: 100%;
  margin-top: 1.5rem;
  transition: all 0.2s ease;
}

.btn-convert:hover:not(:disabled) {
  background: #059669;
  transform: translateY(-2px);
}

.btn-convert:disabled {
  background: var(--border);
  cursor: not-allowed;
}

.preview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
  margin-top: 1.5rem;
}

.preview-card {
  background: var(--card);
  border-radius: 0.75rem;
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;
  position: relative;
}

.preview-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}

.preview-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
  background: #f3f4f6;
}

.preview-info { padding: 1rem; }

.preview-filename {
  font-weight: 600;
  margin-bottom: 0.25rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.preview-size {
  font-size: 0.9rem;
  color: var(--text-light);
}

.status-badge {
  display: inline-block;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.85rem;
  font-weight: 600;
  margin-top: 0.5rem;
}

.status-pending { background: #e5e7eb; color: #6b7280; }
.status-converting { background: #dbeafe; color: #1e40af; }
.status-success { background: #d1fae5; color: #065f46; }
.status-error { background: #fee2e2; color: #991b1b; }

.btn-remove {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  background: rgba(239, 68, 68, 0.9);
  color: white;
  border: none;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  cursor: pointer;
  font-size: 1rem;
  opacity: 0;
  transition: all 0.2s ease;
}

.preview-card:hover .btn-remove { opacity: 1; }

.btn-remove:hover {
  background: #dc2626;
  transform: scale(1.1);
}

.btn-download {
  background: var(--primary);
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-size: 0.9rem;
  cursor: pointer;
  margin-top: 0.5rem;
  width: 100%;
}

.btn-download:hover { background: var(--primary-hover); }

.download-all-section {
  background: var(--card);
  padding: 1.5rem;
  border-radius: 1rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  margin-top: 2rem;
  text-align: center;
  display: none;
}

.download-all-section.active { display: block; }

.btn-download-all {
  background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
  color: white;
  border: none;
  padding: 1rem 3rem;
  border-radius: 0.5rem;
  font-size: 1.1rem;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s ease;
  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}

.btn-download-all:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4);
}

.download-all-section p { color: var(--text-light); margin-bottom: 1rem; }

.progress-section {
  background: var(--card);
  padding: 2rem;
  border-radius: 1rem;
  text-align: center;
  margin-bottom: 2rem;
  display: none;
}

.progress-section.active { display: block; }

.progress-bar {
  width: 100%;
  height: 24px;
  background: var(--border);
  border-radius: 12px;
  overflow: hidden;
  margin: 1rem 0;
}

.progress-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--primary), #7c3aed);
  transition: width 0.3s ease;
  border-radius: 12px;
}

.hint { color: var(--text-light); font-size: 0.9rem; }

@media (max-width: 768px) {
  .header h1 { font-size: 1.8rem; }
  .container { padding: 1rem; }
  .preview-grid { grid-template-columns: 1fr; }
}

/* フッター */
#footer {
  width: 100%;
  background: linear-gradient(45deg, #1abc9c, #16a085);
  color: white;
  padding: 100px 0 50px;
  text-align: center;
}
.footer-content { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
.footer-title { font-size: 2.5rem; font-weight: bold; margin-bottom: 2rem; }
.footer-subtitle { font-size: 1.2rem; opacity: 0.8; margin-bottom: 3rem; }
.footer-subtitle a { color: white; text-decoration: underline; font-size: 18px; transition: opacity 0.3s ease; }
.footer-subtitle a:hover { text-decoration: none; }
.footer-links { border-top: 1px solid rgba(255, 255, 255, 0.2); padding-top: 50px; margin-top: 50px; }
.footer-links ul { list-style: none; display: flex; justify-content: center; gap: 30px; flex-wrap: wrap; margin-bottom: 30px; }
.footer-links a { color: white; text-decoration: none; font-size: 14px; opacity: 0.8; transition: opacity 0.3s ease; }
.footer-links a:hover { opacity: 1; }
.footer-copyright { font-size: 12px; opacity: 0.6; margin-top: 20px; }
.footer-title { font-size: 1.8rem; }
.footer-subtitle { font-size: 1rem; }
</style>
</head>

<body>
<header class="header">
  <h1>MP4→WebM変換ツール</h1>
  <h2>ブラウザ上でMP4動画をWebM形式に変換できる無料ツールです(PC専用)。</h2>
  <p class="subtitle">-<a href="https://neo.astrowave.jp/blog/22698/" target="_blank">使い方はこちら</a>-</p>
</header>

<main class="container">
  <!-- アップロードエリア -->
  <div class="upload-area" id="uploadArea">
    <input type="file" id="fileInput" multiple accept="video/mp4" hidden>
    <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
    </svg>
    <h2>MP4をドラッグ&ドロップ</h2>
    <p>または</p>
    <button class="btn-primary" id="selectFilesBtn">ファイルを選択</button>
    <p class="hint">
      対応形式: MP4(複数選択可) / <b>PC専用</b> / <b>50MB以下推奨</b> / アップロードされません
    </p>
    <p class="hint" id="envHint" style="margin-top:6px;"></p>
  </div>

  <!-- 設定エリア -->
  <div class="settings-card" id="settingsCard">
    <h3>⚙️ 変換設定</h3>

    <div class="setting-item">
      <label>解像度</label>
      <select id="scaleSelect">
        <option value="orig" selected>原寸(おすすめ)</option>
        <option value="1080">1080p(最大幅1920)</option>
        <option value="720">720p(最大幅1280)</option>
      </select>
      <p class="hint">原寸のままが一番きれい。サイズを減らしたい時は720pが効きます。</p>
    </div>

    <div class="setting-item">
      <label>画質: <span id="qualityLabel">標準(おすすめ)</span></label>
      <input type="range" id="qualitySlider" min="0" max="100" value="55">
      <p class="hint">左ほど軽い(サイズ小) / 右ほど高画質(サイズ大・時間長)</p>
    </div>

    <button class="btn-convert" id="convertBtn">🔄 すべて変換</button>
  </div>

  <!-- 進捗表示 -->
  <div class="progress-section" id="progressSection">
    <h3 id="progressTitle">準備中...</h3>
    <div class="progress-bar">
      <div class="progress-fill" id="progressFill"></div>
    </div>
    <p id="progressText">0 / 0 完了</p>
    <p class="hint" id="progressHint">初回はエンジンの読み込みに少し時間がかかります</p>
  </div>

  <!-- プレビューエリア -->
  <div id="previewSection">
    <h3 style="display: none;" id="previewTitle">📋 動画一覧</h3>
    <div class="preview-grid" id="previewGrid"></div>
  </div>

  <!-- まとめてダウンロード -->
  <div class="download-all-section" id="downloadAllSection">
    <h3>✅ 変換完了!</h3>
    <p><span id="completedCount">0</span>本の動画をWebMに変換しました</p>
    <button class="btn-download-all" id="downloadAllBtn">📦 すべてまとめてダウンロード</button>
  </div>
</main>

<!-- フッター -->
<div id="footer">
  <div class="footer-content">
    <h2 class="footer-title">MP4→WebM変換ツール</h2>
    <p class="footer-subtitle"><a href="https://neo.astrowave.jp/blog/22698/" target="_blank">使い方</a></p>
    <div class="footer-links">
      <ul>
        <li><a href="https://astrowave.jp/amnesia_record/">健忘録</a></li>
        <li><a href="https://astrowave.jp">アストロウェーブ</a></li>
        <li><a href="https://neo.astrowave.jp/privacy-policy/">PRIVACY POLICY</a></li>
        <li><a href="https://neo.astrowave.jp/saigamo-profile/">ABOUT</a></li>
        <li><a href="https://neo.astrowave.jp/otoiawase/">お問い合わせ</a></li>
      </ul>
      <div class="footer-copyright">
        © <script type="text/javascript">var date = new Date();var year = date.getFullYear();document.write(year);</script> astrowave.
      </div>
    </div>
  </div>
</div>

<script type="module">
  import { FFmpeg } from "./ffmpeg/ffmpeg/index.js";
  import { fetchFile, toBlobURL } from "./ffmpeg/util/index.js";

  class WebMConverter {
    constructor() {
      this.files = [];
      this.results = [];
      this.maxBytes = 50 * 1024 * 1024;

      this.ffmpeg = new FFmpeg();
      this.ffmpegReady = false;

      this.initElements();
      this.attachEvents();
      this.updateEnvHint();

      this.updateQualityLabel();
    }

    initElements() {
      this.uploadArea = document.getElementById('uploadArea');
      this.fileInput = document.getElementById('fileInput');
      this.selectFilesBtn = document.getElementById('selectFilesBtn');

      this.convertBtn = document.getElementById('convertBtn');
      this.settingsCard = document.getElementById('settingsCard');

      this.previewTitle = document.getElementById('previewTitle');
      this.previewGrid = document.getElementById('previewGrid');

      this.progressSection = document.getElementById('progressSection');
      this.progressFill = document.getElementById('progressFill');
      this.progressText = document.getElementById('progressText');
      this.progressTitle = document.getElementById('progressTitle');
      this.progressHint = document.getElementById('progressHint');

      this.downloadAllSection = document.getElementById('downloadAllSection');
      this.downloadAllBtn = document.getElementById('downloadAllBtn');
      this.completedCount = document.getElementById('completedCount');

      this.envHint = document.getElementById('envHint');

      this.qualitySlider = document.getElementById('qualitySlider');
      this.qualityLabel  = document.getElementById('qualityLabel');

      this.scaleSelect = document.getElementById('scaleSelect');

      // デフォルト(妥協点)
      this.quality = 55;
      this.scaleMode = "orig"; // orig / 1080 / 720
    }

    attachEvents() {
      this.selectFilesBtn.addEventListener('click', () => this.fileInput.click());
      this.fileInput.addEventListener('change', (e) => this.handleFiles(Array.from(e.target.files)));

      this.uploadArea.addEventListener('dragover', (e) => {
        e.preventDefault();
        this.uploadArea.classList.add('dragover');
      });

      this.uploadArea.addEventListener('dragleave', () => {
        this.uploadArea.classList.remove('dragover');
      });

      this.uploadArea.addEventListener('drop', (e) => {
        e.preventDefault();
        this.uploadArea.classList.remove('dragover');
        const files = Array.from(e.dataTransfer.files).filter(f => f.type === 'video/mp4');
        this.handleFiles(files);
      });

      this.qualitySlider.addEventListener('input', (e) => {
        this.quality = parseInt(e.target.value, 10);
        this.updateQualityLabel();
      });

      this.convertBtn.addEventListener('click', () => this.convertAll());
      this.downloadAllBtn.addEventListener('click', () => this.downloadAll());

      this.scaleSelect.addEventListener('change', (e) => {
        this.scaleMode = e.target.value;
      });
    }

    updateQualityLabel() {
      const q = this.quality;
      let label = '標準(おすすめ)';
      if (q <= 25) label = '軽量(サイズ優先)';
      else if (q <= 60) label = '標準(おすすめ)';
      else if (q <= 85) label = '高画質(やや重い)';
      else label = '最高画質(重い)';
      this.qualityLabel.textContent = label;
    }


    updateEnvHint() {
      const ua = navigator.userAgent || '';
      const isMobileLike = /Android|iPhone|iPad|iPod/i.test(ua) || ((navigator.maxTouchPoints||0) > 1 && /Macintosh/i.test(ua));
      if (isMobileLike) {
        this.envHint.innerHTML = '⚠️ このツールは <b>PCブラウザ専用</b> です(スマホ/タブレットは非対応)';
        this.envHint.style.color = 'var(--error)';
      } else {
        this.envHint.textContent = '推奨ブラウザ:Chrome / Edge(最新)';
        this.envHint.style.color = 'var(--text-light)';
      }
    }

    handleFiles(files) {
      if (files.length === 0) return;

      this.files = files;
      this.clearResults();
      this.displayPreviews();

      this.settingsCard.classList.add('active');
      this.previewTitle.style.display = 'block';
      this.downloadAllSection.classList.remove('active');
    }

    displayPreviews() {
      this.previewGrid.innerHTML = '';

      this.files.forEach((file, index) => {
        const card = document.createElement('div');
        card.className = 'preview-card';

        const url = URL.createObjectURL(file);
        const tooBig = file.size > this.maxBytes;

        card.innerHTML = `
          <video src="${url}" class="preview-image" muted playsinline preload="metadata"></video>
          <div class="preview-info">
            <div class="preview-filename">${file.name}</div>
            <div class="preview-size">${this.formatSize(file.size)}</div>
            <div id="status-${index}">
              <span class="status-badge ${tooBig ? 'status-error' : 'status-pending'}">
                ${tooBig ? 'サイズ超過(50MB以下推奨)' : '待機中'}
              </span>
            </div>
          </div>
          <button class="btn-remove" onclick="converter.removeFile(${index})">✕</button>
        `;
        this.previewGrid.appendChild(card);
      });
    }

    removeFile(index) {
      this.files = Array.from(this.files).filter((_, i) => i !== index);
      if (this.files.length === 0) {
        this.settingsCard.classList.remove('active');
        this.previewTitle.style.display = 'none';
      }
      this.displayPreviews();
    }

    clearResults() {
      // 以前のblob URLを開放
      this.results.forEach(r => { try { URL.revokeObjectURL(r.blobUrl); } catch(e){} });
      this.results = [];
    }

    async ensureFFmpeg() {
      if (this.ffmpegReady) return;

      this.progressSection.classList.add('active');
      this.progressTitle.textContent = '準備中...';
      this.progressHint.textContent = '初回はエンジンの読み込みに少し時間がかかります';

      const base = "./ffmpeg/core";
      const coreURL = await toBlobURL(`${base}/ffmpeg-core.js`, "text/javascript");
      const wasmURL = await toBlobURL(`${base}/ffmpeg-core.wasm`, "application/wasm");

      await this.ffmpeg.load({ coreURL, wasmURL });
      this.ffmpegReady = true;

      this.ffmpeg.on("log", ({ message }) => console.log("[ffmpeg]", message));
    }


    async convertAll() {
      this.convertBtn.disabled = true;
      this.progressSection.classList.add('active');
      this.downloadAllSection.classList.remove('active');

      try {
        await this.ensureFFmpeg();

        let completed = 0;
        const total = this.files.length;

        this.progressTitle.textContent = '変換中...';
        this.progressHint.textContent = '大きい動画は時間がかかる場合があります(処理はPC内で実行)';

        for (let i = 0; i < this.files.length; i++) {
          await this.convertVideo(this.files[i], i);
          completed++;
          this.updateProgress(completed, total);
        }

        this.convertBtn.disabled = false;

        setTimeout(() => {
          this.progressSection.classList.remove('active');
          if (this.results.length > 0) {
            this.completedCount.textContent = this.results.length;
            this.downloadAllSection.classList.add('active');
            this.downloadAllSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
          }
        }, 700);

      } catch (error) {
        console.error('変換エラー:', error);
        this.progressTitle.textContent = 'エラー';
        this.progressHint.textContent = (error && error.message) ? error.message : String(error);
        this.convertBtn.disabled = false;
      }
    }

    async convertVideo(file, index) {
      const statusEl = document.getElementById(`status-${index}`);

      let inName = null;
      let outName = null;

      // サイズ制限
      if (file.size > this.maxBytes) {
        statusEl.innerHTML = `<span class="status-badge status-error">✕ サイズ超過(50MB以下推奨)</span>`;
        return;
      }

      try {
        statusEl.innerHTML = '<span class="status-badge status-converting">変換中...</span>';

        const id = (crypto.randomUUID?.() || (Date.now() + '_' + Math.random()))
          .replace(/[^a-zA-Z0-9_]/g,'');

        inName = `in_${id}.mp4`;
        outName = `out_${id}.webm`;

        await this.ffmpeg.writeFile(inName, await fetchFile(file));

        const q = this.quality;

        // 画質スライダー → CRF(小さいほど高画質)
        // 例:軽量=36 / 標準=32 / 高画質=28 / 最高=24
        let crf = 32;
        let cpuUsed = 2;

        if (q <= 25) { crf = 36; cpuUsed = 7; }
        else if (q <= 60) { crf = 32; cpuUsed = 6; }
        else if (q <= 85) { crf = 28; cpuUsed = 5; }
        else { crf = 24; cpuUsed = 4; }

        // 画質スライダー → 目標ビットレート(上げるほどサイズ増)
        // 720p想定:軽量 900k / 標準 1500k / 高画質 2500k / 最高 3500k
        let bv = 1500;
        if (q <= 25) bv = 900;
        else if (q <= 60) bv = 1500;
        else if (q <= 85) bv = 2500;
        else bv = 3500;

        // 解像度に応じてビットレート補正
        if (this.scaleMode === "720") {
          bv = Math.round(bv * 0.65); // 標準1500k → 約975k
        } else if (this.scaleMode === "1080") {
          bv = Math.round(bv * 0.9);  // ほぼ現状維持
        }
        // orig は補正なし
        // 解像度(scale)設定
        let vfArgs = [];
        if (this.scaleMode === "1080") {
          vfArgs = ["-vf", "scale='min(1920,iw)':-2"];
        } else if (this.scaleMode === "720") {
          vfArgs = ["-vf", "scale='min(1280,iw)':-2"];
        }
        // orig は vf なし
        const args = [
          "-i", inName,
          ...vfArgs,

          "-c:v", "libvpx",
          "-deadline", "good",
          "-cpu-used", String(cpuUsed),
          "-auto-alt-ref", "1",
          "-lag-in-frames", "25",
          "-g", "240",

          "-b:v", `${bv}k`,
          "-maxrate", `${bv}k`,
          "-bufsize", `${bv * 2}k`,

          "-pix_fmt", "yuv420p",

          "-c:a", "libopus",
          "-b:a", "64k",
          outName
        ];


        await this.ffmpeg.exec(args);

        const data = await this.ffmpeg.readFile(outName);
        if (!data || data.length === 0) throw new Error("出力が0バイトでした(変換に失敗)");

        const blob = new Blob([data], { type: "video/webm" });
        if (blob.size === 0) throw new Error("Blobが0バイトでした(変換に失敗)");

        const blobUrl = URL.createObjectURL(blob);

        const base = file.name.replace(/\.mp4$/i, '');
        const filename = `${base}.webm`;

        const resultIndex = this.results.push({ filename, blobUrl }) - 1;

        statusEl.innerHTML = `
          <span class="status-badge status-success">✓ 完了</span>
          <div class="preview-size">MP4: ${this.formatSize(file.size)} → WebM: ${this.formatSize(blob.size)}</div>
          <button class="btn-download" onclick="converter.download(${resultIndex})">💾 ダウンロード</button>
        `;
      } catch (error) {
        console.error('変換エラー:', error);
        statusEl.innerHTML = `<span class="status-badge status-error">✕ ${(error && error.message) ? error.message : '変換に失敗しました'}</span>`;
      } finally {
        if (inName) { try { await this.ffmpeg.deleteFile(inName); } catch(e){} }
        if (outName){ try { await this.ffmpeg.deleteFile(outName);} catch(e){} }
      }
    }

    download(index) {
      const result = this.results[index];
      if (!result) return;

      const a = document.createElement('a');
      a.href = result.blobUrl;
      a.download = result.filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
    }

    downloadAll() {
      if (this.results.length === 0) return;

      this.results.forEach((result, i) => {
        if (!result) return;
        setTimeout(() => {
          const a = document.createElement('a');
          a.href = result.blobUrl;
          a.download = result.filename;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
        }, i * 350);
      });
    }

    updateProgress(completed, total) {
      const percent = (completed / total) * 100;
      this.progressFill.style.width = percent + '%';
      this.progressText.textContent = `${completed} / ${total} 完了`;
    }

    formatSize(bytes) {
      if (bytes === 0) return '0 Bytes';
      const k = 1024;
      const sizes = ['Bytes', 'KB', 'MB', 'GB'];
      const i = Math.floor(Math.log(bytes) / Math.log(k));
      return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
    }
  }

  // グローバル(removeFile / download 呼び出し用)
  window.converter = new WebMConverter();
</script>
</body>
</html>

※保存するような機能もありませんのでリロードすると全部消えます。
※解像度を原寸、画質は標準でも結構な高画質の設定です。

どうしてもまだ圧縮したい。まだ攻めたいという人は。おとなしくVP9に対応しているサービスサイトを利用しましょう。

VP9について
VP9はさらに軽量化が可能ということで、対応しようと試しましたが、ブラウザ内処理ではメモリ負荷が高く、安定しませんでした。
そのため現状はVP8(libvpx)を採用しています。

実用上を考え、安定性を重視した形です。

画質とファイルサイズについて
画質を「感覚的なスライダー」で選べますが、
内部では主にビットレート(bv)を調整しています。

実際の例です。
MP4(1080p):6.37MB

- WebM(原寸・標準):約2.4MB
- WebM(720p・標準):約1.6MB

コードの中身の詳しくはAIに放り込んでください。

まさに自分で使うためですが。
動画を使うWeb制作者やブロガーの助けになれば嬉しいです。

【AI】イラストを描いてもらった

今回の記事のキャッチ画像で使わせてもらいます「Google ImageFX」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

この記事にピッタリなイラストのための考えたリクエストは、「サイバー空間で大きなデータを両手で力強く圧縮する執事型ロボット。執事は男性。頭部は丸みがあるレトロなブラウン管TV。画面にはイケメンが映っていて叫んでいる表情。両手から電磁アークが迸る。サイバーパンク風で、ダッチアングル。」です。

高機能でサイバーな執事さんです。

メイドさんはやめたんですか。

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

どこかで聞いたことがあるような。