star back image
people4
電飾 電飾
moon
astronaut

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

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

Webサイトに動画を載せたとき、「ページの表示がおそい」「動画が出るまで待たされる」とか言われたことはありませんか。

動画は容量が多く、読み込み表示に時間が必要なのは当たり前のことですが。少しでも読み込みを軽くしたい気持ちはわかります。

動画をアップロードせず、ブラウザ内だけでMP4→WebM変換できるツールが欲しくなりました。ffmpeg.wasm を使います。

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

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

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

変換サービスは検索すると沢山のサイトが出てきます。
どれがいいのかわからない。また変な広告をクリックさせようとする広告が画面を埋めていませんか。

広告だらけだったりイライラします。自分用が欲しいです。

でもどうやってするのかサッパリわかりません。
しかし今はAIがあります。

【更新履歴】

  • 特定シーン高画質化機能を追加。
  • 音声を削除(消音)チェックボックスを追加。

元のMP4のビットレートを自動検出し、それを基準に画質を調整する方式に変更しました。元ファイルより大きくなる問題は基本的に起こりません。

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

わたしでも使えるような「シンプルなもの」にしたいです。

要件

  • ffmpegをWebAssembly化したものJavaScript から直接実行
  • PCブラウザ内だけで変換(サーバー処理なし)
  • 画質とファイルサイズを自分で調整できる
  • 無料
MP4をWebMに変換したい(サンプルページ
https://astrowave.jp/amnesia_record/webp_mv.php


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

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

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

webp_mv.php

<?php
// webp_mv.php
// ブラウザ内(ffmpeg.wasm)で MP4 → WebM 変換(サーバー処理なし)
// 特定シーン高画質化機能付き
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-P74VLK9');</script>
<!-- End Google Tag Manager -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-L2YK2LMJ0Y"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-L2YK2LMJ0Y');
</script>

<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専用)。">
<link rel="icon" href="https://neo.astrowave.jp/wp-content/uploads/2022/04/cropped-siteicon.png" sizes="32x32">

<meta property="og:type" content="website">
<meta property="og:title" content="MP4→WebM変換ツール | ツール集">
<meta property="og:url" content="<?php echo (empty($_SERVER["HTTPS"]) ? "http://" : "https://") . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"]; ?>">
<meta property="og:description" content="ブラウザ上でMP4動画をWebM形式に変換できる無料ツールです(PC専用)。">
<meta property="og:image" content="https://neo.astrowave.jp/wp-content/uploads/2023/04/tsuzuri_main.jpg">
<meta property="og:site_name" content="アストロウェーブ">
<meta name="twitter:card" content="summary">
<meta name="twitter:site" content="@Saigamo_a">

<title>MP4→WebM変換ツール | ツール集 - アストロウェーブ</title>

<link rel="stylesheet" href="css/tools-common.css">

<style>
/* ツール固有のスタイル */
.upload-area {
  border: 2px dashed #ddd;
  border-radius: 12px;
  padding: 48px 24px;
  text-align: center;
  cursor: pointer;
  transition: all 0.2s ease;
  background: #fafafa;
}

.upload-area:hover {
  border-color: #999;
  background: #f5f5f5;
}

.upload-area.dragover {
  border-color: #222;
  background: #f0f0f0;
}

.upload-area__icon {
  width: 48px;
  height: 48px;
  margin: 0 auto 16px;
  color: #999;
}

.upload-area__title {
  font-size: 16px;
  font-weight: 600;
  margin-bottom: 8px;
}

.upload-area__hint {
  font-size: 14px;
  color: #888;
}

/* 「ファイルを選択」ボタンだけを黒背景に */
#selectFilesBtn.tool-btn--secondary {
  background: #222;
  color: #fff;
  border-color: #222;
}

#selectFilesBtn.tool-btn--secondary:hover {
  background: #444;
  border-color: #444;
  color: #fff;
  opacity: 1;
}

.settings-panel {
  display: none;
  margin-top: 24px;
}

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

.convert-btn {
  margin-top: 24px;
}

.progress-section {
  display: none;
  text-align: center;
  padding: 24px;
  background: #f9f9f9;
  border-radius: 12px;
  margin-top: 24px;
}

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

.results-section {
  display: none;
  margin-top: 32px;
}

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

.result-card {
  position: relative;
}

.result-card__remove {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: rgba(0,0,0,0.6);
  color: #fff;
  border: none;
  cursor: pointer;
  font-size: 14px;
  line-height: 24px;
  text-align: center;
  opacity: 0;
  transition: opacity 0.2s;
}

.result-card:hover .result-card__remove {
  opacity: 1;
}

.result-stats {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  margin-top: 8px;
}

.result-stats__item {
  font-size: 11px;
  padding: 2px 8px;
  background: #f0f0f0;
  border-radius: 4px;
}

.result-stats__item--success {
  background: #e8f5e9;
  color: #2e7d32;
}

.download-all-section {
  display: none;
  text-align: center;
  margin-top: 32px;
  padding: 24px;
  background: #f9f9f9;
  border-radius: 12px;
}

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

.download-all-section__title {
  font-size: 18px;
  font-weight: 600;
  margin-bottom: 8px;
}

.download-all-section__count {
  font-size: 14px;
  color: #666;
  margin-bottom: 16px;
}

.env-hint {
  font-size: 12px;
  margin-top: 8px;
}

.env-hint--warning {
  color: #d32f2f;
}

.env-hint--info {
  color: #666;
}

.ffmpeg-hint {
  font-size: 12px;
  color: #666;
  margin: 8px 0;
  max-height: 3em;
  overflow: hidden;
}

/* プログレスバーのストライプアニメーション */
.tool-progress__fill.is-indeterminate {
  position: relative;
  overflow: hidden;
}

.tool-progress__fill.is-indeterminate::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: repeating-linear-gradient(
    45deg,
    rgba(255,255,255,0.35) 0px,
    rgba(255,255,255,0.35) 10px,
    rgba(255,255,255,0.10) 10px,
    rgba(255,255,255,0.10) 20px
  );
  transform: translateX(-40px);
  animation: barberStripes 1s linear infinite;
}

@keyframes barberStripes {
  from { transform: translateX(-40px); }
  to   { transform: translateX(40px); }
}

@media (prefers-reduced-motion: reduce) {
  .tool-progress__fill.is-indeterminate::after {
    animation: none;
  }
}

.preview-video {
  width: 100%;
  height: 160px;
  object-fit: cover;
  background: #f3f4f6;
  border-radius: 8px 8px 0 0;
}
</style>

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "WebApplication",
  "inLanguage": "ja",
  "operatingSystem": "Webブラウザ",
  "name": "MP4→WebM変換ツール",
  "url": "https://astrowave.jp/amnesia_record/webp_mv.php",
  "description": "ブラウザ上でMP4動画をWebM形式に変換できる無料ツールです(PC専用)。",
  "applicationCategory": "ブログ支援ツール",
  "audience": { "@type": "Audience", "audienceType": "個人ブロガー, Web制作者" },
  "keywords": "WebM, MP4, 動画変換, 動画圧縮, 無料ツール",
  "offers": { "@type": "Offer", "price": "0", "priceCurrency": "JPY" },
  "isAccessibleForFree": true
}
</script>
</head>

<body>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-P74VLK9"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->

<!-- ヘッダー -->
<header class="tools-header">
  <div class="tools-header__inner">
    <a href="https://astrowave.jp" class="tools-header__logo">
      <svg height="40" viewBox="0 0 957.6189603 247.7738937" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill-rule:evenodd;}</style></defs><path class="cls-1" d="M900.5134606,96.0364474h52.4696288l-.2107214,10.1146272h-39.8263448c.0076703,7.0003757.3802678,15.1251607.4214428,22.1257471h29.5009961v9.6931844h-29.9224389c.009061,9.5593131-.2009229,19.4184618-.2107214,28.6581105h41.3013946c1.1939685,3.4414387,2.3882953,6.8839099,3.5822638,10.3253486-15.381124,0-55.3478724-.0231583-57.5269425,0,.1404669-26.8994086.2809759-54.0176093.4214428-80.917018Z"/><path class="cls-1" d="M814.9605718,96.0364474h14.1183339c7.3745115,19.5248971,14.7512356,39.0556523,22.1257471,58.5805495.0702334-.0702334,21.0726037-58.3307603,21.4935829-58.5805495h14.5397767c0,.1404669-31.7562289,80.1184385-32.6618171,82.1813464h-6.7430848c-10.9564171-27.3910427-21.9161214-54.7903036-32.8725385-82.1813464Z"/><path class="cls-1" d="M784.4224589,95.0677867c-1.1237274,0-2.2478233,0-3.3715507,0-.5263791.2911953-36.545038,81.441082-36.6984876,81.7599036h13.7298549c.2336919-.483229,5.5385106-13.3945707,7.1645276-17.2791549h32.0296529s5.9419217,15.7724145,6.1109206,17.2791549c4.705644,0,9.412711,0,14.1183339,0-4.2541441-11.9938084-31.5189005-78.3672684-33.0832599-81.7599036ZM768.8290669,151.541122c.5443979-3.1391238,12.9045508-31.0132797,13.2754483-33.9261456.9161957,2.4724524,12.4325627,33.7856732,12.4325627,33.9261456h-25.7080109Z"/><path class="cls-1" d="M312.9333111,95.0677866h-3.3715424c-.5263791.2911953-36.545038,81.441082-36.6984876,81.7599036h13.7298549c.2336919-.483229,5.5385106-13.3945707,7.1645276-17.2791549h32.0296529s5.619917,15.539041,6.1109206,17.2791549h14.1183339c-5.6268582-13.7221523-31.850995-78.7079158-33.0832599-81.7599036ZM297.3399274,151.541122c.8286424-2.6325399,12.3910105-31.1290971,13.2754483-33.9261456.9161957,2.4724524,12.4325627,33.7856732,12.4325627,33.9261456h-25.7080109Z"/><path class="cls-1" d="M638.5867593,96.0364474h12.6432841c.0702334.0702334,16.6642909,53.1340756,17.4898763,54.1554v-.2107214c1.0641431-1.4241184,17.5994983-49.1107924,19.8078117-53.9446786h4.4251494c4.4081653,11.9289596,18.2186668,52.3197418,19.3863689,53.7339572,5.8293758-17.9095279,11.6605005-35.8244293,17.4898763-53.7339572h13.4861697c-1.2250592,3.3588394-27.6987171,81.867245-28.2366677,82.1813464h-4.0037066c-6.812644-18.1202283-20.5804638-54.3661214-20.6506973-54.3661214-1.1924759,3.5359426-19.5667028,52.9760178-20.2292545,54.3661214h-4.0037066c-.6510448-3.2704172-27.2543688-79.9296196-27.6045035-82.1813464Z"/><path class="cls-1" d="M623.1933982,110.6309203c-7.3736899-10.7511117-18.143802-16.3610429-32.5413057-16.1146568-23.6007455.403887-31.2993047,16.4185591-34.6788212,26.8614482-2.2476606,6.945382-2.7897114,22.755201,1.053607,32.8725385,3.8433184,10.1173375,10.2198989,18.7286375,20.4399759,21.9150257,14.2018895,4.427827,25.2665765,1.3372527,30.1331603-.6321642,10.2483904-4.1473349,16.6434941-11.9453782,19.8078117-22.7579113,3.8431766-13.1322072,2.1947253-32.7994984-4.214428-42.1442802ZM612.4466017,155.3038612c-2.0710992,4.221759-6.9029651,9.335364-11.3789557,11.1682342-3.4617592,1.4175533-13.7168467,3.1635168-21.0721401-1.2643284-5.993619-3.6081248-10.1345477-11.0952668-11.5896771-17.7005977-.8529812-3.8719741-1.8102184-12.7688455,0-20.0185331,2.0989802-8.406141,5.5028901-16.391011,12.2218413-20.2292545,8.4791373-4.843761,20.2107516-3.3922507,26.3401751,2.107214,9.1932254,8.2483807,11.8085526,33.0345409,5.4787564,45.9372654Z"/><path class="cls-1" d="M525.0079242,140.2879449c8.879893-1.7595957,14.4421585-7.161621,17.4898763-14.7504981,3.3868475-8.4333061-.4229943-19.075001-5.268035-22.7579113-1.6378429-1.2449903-5.4482498-4.8338973-15.5933837-6.9538062s-24.504054.1368326-33.0832599.2107214c-.1404595,26.8994022-.2809704,53.8068815-.4214428,80.7062966h12.8540055c.0090287-11.7351011.2194543-23.7750691.2107214-35.190474,2.7434681.2910023,4.7458563.6193716,9.0610202.6321642h3.7929852c.7987266,1.1671738,18.1617326,31.3143926,20.2292545,34.5583098h14.1183339c-1.0458001-2.0467396-23.3900755-36.384579-23.3900755-36.4548024ZM512.1539187,132.7019745c-2.4383568.0043729-6.6504635.0000335-10.9575128-.4214428.0702362-8.7089519.1404981-17.4205147.2107214-26.1294537,14.2161578-1.7449466,26.8797757.0878177,29.7117175,11.3789557.6685363,2.6654508-.5113312,5.5184725-1.2643284,6.9538062-2.8196592,5.3747848-9.6488897,8.2036784-17.7005977,8.2181346Z"/><path class="cls-1" d="M407.214661,96.014219h69.1166195l-.2107214,10.1146272h-27.3937821c.0108943,23.2140599-.2030511,47.0572075-.2107214,70.5916693h-13.0647269c.2107003-23.5282034.4214639-47.0634659.6321642-70.5916693h-28.8688319v-10.1146272Z"/><path class="cls-1" d="M389.3033438,95.2667784c1.7565476.433211,4.2543949,1.0379289,6.6987901,2.6492326-.5896058,1.9515137-2.4189425,8.0313869-3.1165263,9.9940515-1.8565193-.9990617-4.5212177-2.0118852-6.9538062-2.7393782-9.6489991-2.8856639-25.2018178.4912546-20.8614219,12.4325627,2.5972467,7.1455313,10.3916653,10.1592306,16.4362725,13.9076125,9.160246,5.6804363,18.4163985,11.78273,20.8614187,23.1793541,2.7090841,12.6274679-3.0527002,19.199012-13.0647269,22.1257471-7.6864577,2.2469202-17.8010646,3.1449794-33.6402588-2.4391285.5268033-3.8280957,1.2594571-7.2544032,1.8213241-10.6255983,2.3755353,1.1958414,3.8768963,1.6771026,5.9001992,2.3179354,7.7715124,2.4613916,21.0682013,3.7231477,25.2865713-2.5286568,1.0169198-1.5071262,2.5362643-4.6215432,1.6857712-7.7966918-.3289371-1.2280206-1.0148813-2.9242739-1.6857712-4.0037066-2.5990602-4.1818115-7.7872323-7.3322276-12.0111231-9.9039058-.6818286-.4151278-3.7534299-2.3944994-4.6358708-2.9500996-6.4722653-4.0751003-13.5721225-7.2842159-17.0684335-14.3290553-3.397754-6.8462848-3.604199-17.3204657,1.2643284-22.5471899,2.3432435-2.5156539,5.7911344-4.7830697,9.2717416-6.1109206,6.8533393-2.6145582,19.5010059-1.6952758,23.8115215-.6321642Z"/><path d="M166.4590222,139.5966965c-.0322,2.7645829,10.0586,5.2694999,26.2598,7.0575829l20.5478-15.711c-27.7275,1.5845173-46.7656,4.8565172-46.8076,8.6534171Z"/><path d="M111.2831972,144.3558964c-.1103,9.8871998,29.642625,18.5248995,73.531325,22.9770994l-3.2773-10.5927997c-27.752-3.3793999-45.584-8.6063998-45.5147-14.4384996.0977-8.2406998,35.9219-15.107417,84.8691-17.188417l8.1495-6.231c-66.6651,1.9507176-117.610425,12.5845173-117.757925,25.473617Z"/><path d="M95.2236976,148.2333963c.208-18.1431169,61.8164246-32.9151169,140.6582246-34.5815991l24.2392-18.5331995-97.1699-1.3725L130.5157222,0l-32.3067247,92.4086977L0,93.8651976l78.375998,62.1674984-29.4335758,90.9672977,81.6787-55.7699986,79.0879,56.5438986-20.7705-67.1171983c-55.3203-5.5521999-93.8857246-18.0204995-93.7148246-32.4232992Z"/></svg>
    </a>
    <nav class="tools-header__nav">
      <ul>
        <li><a href="./">ツール集</a></li>
        <li><a href="https://neo.astrowave.jp/">ブログ</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>
    </nav>
  </div>
</header>

<!-- メイン -->
<main class="tools-wrapper">
  <div class="tool-page">
    
    <!-- パンくず -->
    <nav class="tool-page__breadcrumb">
      <a href="./">ツール集</a>
      <span>›</span>
      <span>MP4→WebM変換ツール</span>
    </nav>
    
    <!-- ヘッダー -->
    <header class="tool-page__header">
      <h1 class="tool-page__title">MP4→WebM変換ツール</h1>
      <p class="tool-page__desc">ブラウザ上でMP4動画をWebM形式に変換できる無料ツールです(PC専用)</p>
    </header>
    
    <!-- ツールコンテンツ -->
    <div class="tool-content">
      
      <!-- アップロードエリア -->
      <div class="upload-area" id="uploadArea">
        <input type="file" id="fileInput" accept="video/mp4" hidden>
        <svg class="upload-area__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" 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>
        <div class="upload-area__title">MP4をドラッグ&ドロップ</div>
        <button class="tool-btn tool-btn--secondary mt-2" id="selectFilesBtn">ファイルを選択</button>
        <p class="upload-area__hint mt-2">対応形式: MP4</p>
        <p class="upload-area__hint"><strong>PC専用</strong> / <strong>50MB以下推奨</strong> / アップロードされません</p>
        <p class="env-hint" id="envHint"></p>
      </div>
      
      <!-- 設定パネル -->
      <div class="settings-panel" id="settingsCard">
        <div class="tool-content__section">
          <label class="tool-content__label">解像度</label>
          <select id="scaleSelect" class="tool-select" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px;">
            <option value="orig" selected>原寸(おすすめ)</option>
            <option value="1080">1080p(最大幅1920)</option>
            <option value="720">720p(最大幅1280)</option>
          </select>
          <p class="upload-area__hint mt-1">原寸のままが一番きれい。サイズを減らしたい時は720pが効きます。</p>
        </div>
        
        <div class="tool-content__section mt-3">
          <label class="tool-content__label">
            画質: <span id="qualityLabel">高画質(元と同等)</span>
          </label>
          <input type="range" class="tool-slider" id="qualitySlider" min="10" max="100" value="100">
          <p class="upload-area__hint mt-1">右が元動画と同等画質。左に下げるほどファイルサイズが軽くなります(ブロックノイズが出る場合あり)。</p>
        </div>

        <div class="tool-content__section mt-3">
          <label class="tool-content__label">特定シーン高画質化(任意)</label>
          <div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
            <div style="flex: 1; min-width: 120px;">
              <label style="font-size: 12px; color: #666; display: block; margin-bottom: 4px;">開始(秒)</label>
              <input type="number" id="hqStart" step="0.1" min="0" placeholder="例: 2.5" style="width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px;">
            </div>
            <div style="flex: 1; min-width: 120px;">
              <label style="font-size: 12px; color: #666; display: block; margin-bottom: 4px;">終了(秒)</label>
              <input type="number" id="hqEnd" step="0.1" min="0" placeholder="例: 5.0" style="width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px;">
            </div>
          </div>
          <p class="upload-area__hint mt-1">指定区間は元動画と同等の高画質を維持し、それ以外をスライダーの設定で軽量化します。空欄なら全体を均一品質で変換。</p>
        </div>
        
        <div class="tool-content__section mt-3">
          <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
            <input type="checkbox" id="muteAudio" style="width: 18px; height: 18px; accent-color: #222;">
            <span class="tool-content__label" style="margin: 0;">音声を削除(消音)</span>
          </label>
          <p class="upload-area__hint mt-1">チェックすると音声トラックを完全に除去します。ファイルサイズも軽くなります。</p>
        </div>
        
        <button class="tool-btn tool-btn--primary tool-btn--large convert-btn" id="convertBtn">
          変換する
        </button>
      </div>
      
      <!-- 進捗 -->
      <div class="progress-section" id="progressSection">
        <h3 id="progressTitle">準備中...</h3>
        <div class="tool-progress">
          <div class="tool-progress__fill" id="progressFill"></div>
        </div>
        <p class="ffmpeg-hint" id="ffmpegHint"></p>
        <p id="progressText">準備完了</p>
        <p class="upload-area__hint" id="progressHint">初回はエンジンの読み込みに少し時間がかかります</p>
      </div>
      
    </div>
    
    <!-- 結果 -->
    <div class="results-section" id="previewSection">
      <h2 class="tools-section__title" id="previewTitle" style="display: none;">動画プレビュー</h2>
      <div class="tool-preview-grid" id="previewGrid"></div>
    </div>
    
    <!-- まとめてダウンロード -->
    <div class="download-all-section" id="downloadAllSection">
      <div class="download-all-section__title">変換完了</div>
      <p class="download-all-section__count">動画をWebMに変換しました</p>
      <button class="tool-btn tool-btn--primary" id="downloadAllBtn">ダウンロード</button>
    </div>
    
    <!-- 使い方 -->
    <div class="tool-content mt-4">
      <h3 class="tool-content__label">使い方</h3>
      <ol style="padding-left: 20px; line-height: 2;">
        <li>MP4ファイルをドラッグ&ドロップ、または「ファイルを選択」をクリック</li>
        <li>解像度と画質を調整(任意)</li>
        <li>「変換する」ボタンをクリック</li>
        <li>変換後の動画をダウンロード</li>
      </ol>
      <p class="upload-area__hint mt-2">※ すべての処理はブラウザ上(PC内)で行われます。動画はサーバーに保存されません。</p>
      
      <div class="mt-3" style="padding-top: 16px; border-top: 1px solid #eee;">
        <a href="https://neo.astrowave.jp/blog/25527/" class="tool-btn tool-btn--secondary" target="_blank" style="display: inline-flex; align-items: center; gap: 8px;">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
          このツールの解説記事を読む
        </a>
      </div>
    </div>
    
  </div>
</main>

<!-- フッター -->
<footer class="tools-footer">
  <div class="tools-footer__inner">
    <div class="tools-footer__title">サイガモ自作Webツール集</div>
    <nav class="tools-footer__links">
      <a href="https://astrowave.jp">HOME</a>
      <a href="./">ツール集</a>
      <a href="https://neo.astrowave.jp/">ブログ</a>
      <a href="https://neo.astrowave.jp/privacy-policy/">プライバシーポリシー</a>
      <a href="https://neo.astrowave.jp/saigamo-profile/">ABOUT</a>
      <a href="https://neo.astrowave.jp/otoiawase/">お問い合わせ</a>
    </nav>
    <p class="tools-footer__copyright">© <?php echo date('Y'); ?> astrowave.</p>
  </div>
</footer>

<!-- ページTOPボタン -->
<a href="#" class="pagetop-btn" id="pagetopBtn" aria-label="ページの先頭へ戻る">
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
    <polyline points="18 15 12 9 6 15"></polyline>
  </svg>
</a>

<script>
(function() {
  const btn = document.getElementById('pagetopBtn');
  window.addEventListener('scroll', function() {
    if (window.scrollY > 300) {
      btn.classList.add('is-visible');
    } else {
      btn.classList.remove('is-visible');
    }
  });
})();
</script>

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

  class WebMConverter {
    constructor() {
      this.file = null;
      this.result = null;
      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.previewSection = document.getElementById('previewSection');

      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.envHint = document.getElementById('envHint');

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

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

      this.hqStartInput = document.getElementById('hqStart');
      this.hqEndInput = document.getElementById('hqEnd');
      this.muteAudio = document.getElementById('muteAudio');

      this.quality = 100;
      this.scaleMode = "orig";
      this.sourceBitrateKbps = 1500;
    }

    attachEvents() {
      this.selectFilesBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        this.fileInput.click();
      });
      this.uploadArea.addEventListener('click', () => this.fileInput.click());
      this.fileInput.addEventListener('change', (e) => {
        if (e.target.files.length > 0) {
          this.handleFile(e.target.files[0]);
        }
      });

      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');
        if (files.length > 0) {
          this.handleFile(files[0]);
        }
      });

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

      this.convertBtn.addEventListener('click', () => this.convert());
      this.downloadAllBtn.addEventListener('click', () => this.download());

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

    updateQualityLabel() {
      const q = this.quality;
      let label = '高画質(元と同等)';
      if (q <= 30) 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.className = 'env-hint env-hint--warning';
      } else {
        this.envHint.textContent = '推奨ブラウザ:Chrome / Edge(最新)';
        this.envHint.className = 'env-hint env-hint--info';
      }
    }

    handleFile(file) {
      if (!file) return;

      this.file = file;
      this.clearResult();

      // 動画のdurationを取得してソースビットレートを計算
      const tempVideo = document.createElement('video');
      tempVideo.preload = 'metadata';
      tempVideo.src = URL.createObjectURL(file);
      tempVideo.onloadedmetadata = () => {
        const duration = tempVideo.duration;
        if (duration && duration > 0) {
          this.sourceBitrateKbps = Math.round((file.size * 8) / duration / 1000);
        }
        URL.revokeObjectURL(tempVideo.src);
      };

      this.displayPreview();

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

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

      const card = document.createElement('div');
      card.className = 'tool-preview-card result-card';

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

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

    removeFile() {
      this.file = null;
      this.settingsCard.classList.remove('active');
      this.previewTitle.style.display = 'none';
      this.previewSection.classList.remove('active');
      this.displayPreview();
    }

    clearResult() {
      if (this.result) {
        try { URL.revokeObjectURL(this.result.blobUrl); } catch(e){}
        this.result = null;
      }
    }

    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);
        if (this.ffmpegHint) {
          const short = message.length > 120 ? message.slice(0, 120) + "…" : message;
          this.ffmpegHint.textContent = "ffmpeg: " + short;
        }
      });
    }

    async convert() {
      if (!this.file) return;

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

      // 前回の変換結果をクリア
      this.clearResult();

      this.progressFill.classList.add('is-indeterminate');

      try {
        await this.ensureFFmpeg();

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

        await this.convertVideo(this.file, 0);
        this.updateProgress(1, 1);

        this.convertBtn.disabled = false;

        setTimeout(() => {
          this.progressFill.classList.remove('is-indeterminate');
          this.progressSection.classList.remove('active');
          if (this.result) {
            this.downloadAllSection.classList.add('active');
            this.downloadAllSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
          }
        }, 700);

      } catch (error) {
        this.progressFill.classList.remove('is-indeterminate');
        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}`);
      const tempFiles = [];

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

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

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

        const inName = `in_${id}.mp4`;
        tempFiles.push(inName);

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

        const hqStart = parseFloat(this.hqStartInput.value);
        const hqEnd = parseFloat(this.hqEndInput.value);
        const hasHqRange = !isNaN(hqStart) && !isNaN(hqEnd) && hqEnd > hqStart;

        const q = this.quality;

        // ソースビットレートを基準に、スライダーの割合でビットレートを決定
        const ratio = q / 100;
        let bv = Math.round(this.sourceBitrateKbps * ratio);
        bv = Math.max(bv, 200);

        let cpuUsed = 5;
        if (ratio >= 0.85) cpuUsed = 4;
        else if (ratio >= 0.5) cpuUsed = 5;
        else cpuUsed = 6;

        if (this.scaleMode === "720") bv = Math.round(bv * 0.65);
        else if (this.scaleMode === "1080") bv = Math.round(bv * 0.9);

        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"];

        let outName;

        const isMuted = this.muteAudio.checked;
        const audioArgs = isMuted ? ["-an"] : ["-c:a", "libopus", "-b:a", "64k"];
        const audioArgsHq = isMuted ? ["-an"] : ["-c:a", "libopus", "-b:a", "96k"];

        if (hasHqRange) {
          // --- 分割エンコード方式 ---
          const hqBv = this.sourceBitrateKbps;
          const hqCpuUsed = 4;

          const partNames = [];

          // Part 1: 0 → hqStart(低画質)
          if (hqStart > 0) {
            const p1 = `p1_${id}.webm`;
            tempFiles.push(p1);
            partNames.push(p1);
            await this.ffmpeg.exec([
              "-i", inName,
              "-t", String(hqStart),
              ...vfArgs,
              "-c:v", "libvpx", "-deadline", "good",
              "-cpu-used", String(cpuUsed),
              "-b:v", `${bv}k`, "-maxrate", `${bv}k`, "-bufsize", `${bv * 2}k`,
              "-pix_fmt", "yuv420p",
              ...audioArgs,
              p1
            ]);
          }

          // Part 2: hqStart → hqEnd(高画質)
          const p2 = `p2_${id}.webm`;
          tempFiles.push(p2);
          partNames.push(p2);
          await this.ffmpeg.exec([
            "-i", inName,
            "-ss", String(hqStart),
            "-t", String(hqEnd - hqStart),
            ...vfArgs,
            "-c:v", "libvpx", "-deadline", "good",
            "-cpu-used", String(hqCpuUsed),
            "-b:v", `${hqBv}k`, "-maxrate", `${hqBv}k`, "-bufsize", `${hqBv * 2}k`,
            "-pix_fmt", "yuv420p",
            ...audioArgsHq,
            p2
          ]);

          // Part 3: hqEnd → end(低画質)
          const p3 = `p3_${id}.webm`;
          tempFiles.push(p3);
          partNames.push(p3);
          await this.ffmpeg.exec([
            "-i", inName,
            "-ss", String(hqEnd),
            ...vfArgs,
            "-c:v", "libvpx", "-deadline", "good",
            "-cpu-used", String(cpuUsed),
            "-b:v", `${bv}k`, "-maxrate", `${bv}k`, "-bufsize", `${bv * 2}k`,
            "-pix_fmt", "yuv420p",
            ...audioArgs,
            p3
          ]);

          // Part 3が空(hqEndが動画の最後の場合)を除外
          const validParts = [];
          for (const pn of partNames) {
            try {
              const d = await this.ffmpeg.readFile(pn);
              if (d && d.length > 0) validParts.push(pn);
            } catch(e) {}
          }

          // 結合用リストファイル作成
          const concatList = validParts.map(p => `file '${p}'`).join('\n');
          const listName = `list_${id}.txt`;
          tempFiles.push(listName);
          await this.ffmpeg.writeFile(listName, new TextEncoder().encode(concatList));

          // 結合
          outName = `out_${id}.webm`;
          tempFiles.push(outName);
          await this.ffmpeg.exec([
            "-f", "concat", "-safe", "0",
            "-i", listName,
            "-c", "copy",
            outName
          ]);

        } else {
          // --- 通常の単一エンコード ---
          outName = `out_${id}.webm`;
          tempFiles.push(outName);

          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",
            ...audioArgs,
            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`;
        this.result = { filename, blobUrl };

        const isBigger = blob.size > file.size;
        const sizeWarning = isBigger
          ? '<span class="tool-badge tool-badge--error" style="margin-top:4px;">⚠ 元のMP4より大きくなりました。画質を下げるか、解像度を720pにすることをおすすめします。</span>'
          : '';

        statusEl.innerHTML = `
          <div class="result-stats">
            <span class="tool-badge tool-badge--success">完了${hasHqRange ? '(シーン高画質化適用)' : ''}</span>
            <span class="result-stats__item">MP4: ${this.formatSize(file.size)} → WebM: ${this.formatSize(blob.size)}</span>
          </div>
          ${sizeWarning}
          <button class="tool-btn tool-btn--secondary mt-2" style="width:100%;padding:8px;" onclick="converter.download()">ダウンロード</button>
        `;
      } catch (error) {
        console.error('変換エラー:', error);
        statusEl.innerHTML = `<span class="tool-badge tool-badge--error">${(error && error.message) ? error.message : '変換に失敗しました'}</span>`;
      } finally {
        for (const f of tempFiles) {
          try { await this.ffmpeg.deleteFile(f); } catch(e) {}
        }
      }
    }

    download() {
      if (!this.result) return;

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

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

    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];
    }
  }

  window.converter = new WebMConverter();
</script>

<!-- cute flying ship (overlay) -->
<script src="/js/fly_ship.js" defer></script>
</body>
</html>

※保存するような機能もありませんのでリロードすると全部消えます。
※デフォルトは「高画質(元と同等)」です。スライダーを左に下げるほど軽量化されます。

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

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

【補足】画質スライダーの設定

画質スライダーは「元動画のビットレートに対する割合(%)」で動作します。
読み込んだMP4ファイルの実際のビットレートを自動計算し、それを100%として基準にします。

スライダー位置ラベルビットレート動作
86〜100高画質(元と同等)元の86〜100%ほぼ同等の画質。サイズも同程度
61〜85やや高画質元の61〜85%きれい。ほとんどの人が劣化に気づかない
31〜60標準(バランス重視)元の31〜60%普通に見える。ファイルサイズ半減前後
10〜30軽量(大幅に圧縮)元の10〜30%動きの速いシーンでブロックノイズが出る場合あり

デフォルトは右端(100%)= 元動画と同等画質です。
左に下げるほどファイルサイズが軽くなりますが、画質も下がります。

※元のMP4がすでに高圧縮の場合、同じビットレートで変換してもコーデックの差(H.264 vs VP8)により若干大きくなる場合があります。その際は警告が表示されます。

【補足2】特定シーン高画質化

特定のタイムラインの部分だけ高画質を維持したいときに使います。

仕組み

  • 指定区間: 元動画と同等のビットレート(100%)で高画質を維持
  • それ以外: 画質スライダーの設定に従い軽量化

例えば、スライダーを50%に設定して「2.5秒〜5.0秒」を高画質指定すると、以下のような感じになります。

区間ビットレート結果
0〜2.5秒元の50%軽量化
2.5〜5.0秒元の100%高画質維持
5.0秒〜最後元の50%軽量化

使い方のコツ

  • 見せたいシーン(ロゴ登場、重要な動き等)だけ高画質にして、前後を軽くするのが一番コスパの良い使い方です。
  • スライダーが100%(デフォルト)のまま使っても機能しますが、全体が同じ画質になるため効果はありません。スライダーを下げてから使うのがポイントです。
  • 空欄の場合は全体を均一品質で変換します。

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

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

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

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

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

星間旅路のメロディ

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

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

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