star back image
people4
電飾 電飾
moon
astronaut

【WEBアプリ】画像をWebPに一括変換したい(webp対応・ローカル変換)

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

表示速度の改善ため、画像をWebP形式に変換したい。

jpg形式よりも軽量になるということで、一生懸命に変換している方もいらっしゃるのではないでしょうか。

WebPを検索しました。2010年9月30日にGoogleが発表。。

当初は対応するブラウザが限られていましたが、2020年のiOS 14以降や、その他の主要ブラウザでの対応が進んだことで、現在ではほとんどのユーザーが利用できる状況になっています。

モダンブラウザで表示可能。photoshopで編集もOK。
もうすっかり定着した感じがします。
最近shopifyではどんな形式でアップしても、自動でwebpで表示させているようです。人気ですね。

googleオフィシャル
https://developers.google.com/speed/webp?hl=ja

Squoosh(スクワッシュ)
https://squoosh.app/

軽量なのにキレイ。最高じゃないですか。

Squooshで一生懸命に1枚ずつ軽量化していました。

1枚ずつは大変でないですか。

量が多いとSquooshは大変です。まとめて変換したいです。。
でもそんなことできるのか、さっぱりわかりません。

しかし今はAIがあります。

修正更新の記録

修正更新 2026/2/13

10MBを超えるファイル容量の提供が頻繁にあり、まともに変換できなかったので10MB→30MBに制限を上げたところ。サーバーに怒られたので、ローカル変換きるタイプを追加しております。

修正更新 2026/1/23

超高画質の大容量なwebpファイルを素材提供されることがあり、webpファイルも軽量化できるようコード修正しております。

【使用法】webp変換サンプルページ

格安レンタルサーバーであること、そして一括を実現するということで、AIからの返答はPHPの標準画像処理ライブラリでのエンコードを提案してもらいました。

要件は以下です。

  • PHPファイル1つアップロードするだけ
  • 複数枚を一括変換できる
  • 細かい設定不要(品質スライダーだけ)
サンプルページ

・サーバー変換タイプ
https://astrowave.jp/amnesia_record/webp_conv.php

・ローカル変換タイプ
https://astrowave.jp/amnesia_record/webp_conv_local.php

※変換は画像のみです。
※Squooshみたいに比べる機能はありません。
※png画像がpng-8は変換不可です。png-24にしてどうそ。

品質: 80%で試してみると、十分な画質&軽量数値だなと感じると思います。

※保存するような機能もありませんのでリロードすると全部消えます。

Google謹製のlibwebpオープンソースを使うのは、あなたには無理ですね。

どうしても最高画質で細かい調整がしたいときは、おとなしくSquooshを利用しましょう。

10枚でも20枚でも数秒で変換完了です。

こんな人にオススメ

  • ブログ記事用の画像(複数枚)
  • 商品画像の一括変換
  • 日常的な画像処理
  • サクッと変換したい

まさに自分で使うため。
ブログのサイドバーにリンクを付けようと思います。

【共有】ソースコード

webp_conv.phpサーバー変換タイプ

<?php
/**
 * WebP画像変換ツール(シンプル版)
 * Node.js不要・PHP単体で動作
 */

// POSTリクエスト(画像変換API)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  header('Content-Type: application/json');
  
  try {
    // 画像ファイルのチェック
    if (!isset($_FILES['image'])) {
      throw new Exception('画像ファイルがアップロードされていません。');
    }

    $file = $_FILES['image'];
    
    // エラーチェック
    if ($file['error'] !== UPLOAD_ERR_OK) {
      throw new Exception('ファイルのアップロードに失敗しました。');
    }

    // ファイルサイズチェック(最大10MB)
    $maxSize = 10 * 1024 * 1024;
    if ($file['size'] > $maxSize) {
      throw new Exception('ファイルサイズが大きすぎます(最大10MB)。');
    }

    // MIMEタイプチェック
    $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mimeType = finfo_file($finfo, $file['tmp_name']);
    finfo_close($finfo);

    if (!in_array($mimeType, $allowedTypes)) {
      throw new Exception('対応していない画像形式です(JPG、PNG、GIF、WebPのみ)。');
    }

    // 品質の取得
    $quality = isset($_POST['quality']) ? intval($_POST['quality']) : 80;
    $quality = max(1, min(100, $quality));

    // 画像の読み込み
    $image = null;
    switch ($mimeType) {
      case 'image/jpeg':
        $image = imagecreatefromjpeg($file['tmp_name']);
        break;
      case 'image/png':
        $image = imagecreatefrompng($file['tmp_name']);
        
        // PNG-8(256色以下)のチェック
        $colorTotal = imagecolorstotal($image);
        if ($colorTotal > 0 && $colorTotal <= 256) {
          imagedestroy($image);
          throw new Exception('PNG-8形式は対応していません。PNG-24/32形式で保存し直してください。');
        }
        
        imagealphablending($image, true);
        imagesavealpha($image, true);
        break;
      case 'image/gif':
        $image = imagecreatefromgif($file['tmp_name']);
        imagealphablending($image, true);
        imagesavealpha($image, true);
        break;
      case 'image/webp':
        $image = imagecreatefromwebp($file['tmp_name']);
        imagealphablending($image, true);
        imagesavealpha($image, true);
        break;
    }

    if (!$image) {
      throw new Exception('画像の読み込みに失敗しました。');
    }

    // WebP変換が利用可能かチェック
    if (!function_exists('imagewebp')) {
      imagedestroy($image);
      throw new Exception('サーバーがWebP変換に対応していません。');
    }

    // WebP形式で出力
    ob_start();
    imagewebp($image, null, $quality);
    $webpData = ob_get_clean();
    imagedestroy($image);

    // Base64エンコードして返す
    $base64 = base64_encode($webpData);
    $originalSize = $file['size'];
    $newSize = strlen($webpData);

    echo json_encode([
      'success' => true,
      'filename' => pathinfo($file['name'], PATHINFO_FILENAME) . '.webp',
      'data' => $base64,
      'originalSize' => $originalSize,
      'newSize' => $newSize,
      'reduction' => round(($originalSize - $newSize) / $originalSize * 100, 1)
    ]);

  } catch (Exception $e) {
    http_response_code(400);
    echo json_encode([
      'success' => false,
      'error' => $e->getMessage()
    ]);
  }
  exit;
}

// GETリクエスト(HTMLページ表示)
?>
<!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="宇宙,デザイン,近未来,アストロウェーブ,web,ホームページ,空想" />
<meta name="description" content="宇宙生活を楽しくデザインしたいサイト「アストロウェーブ」" />

<link rel="icon" href="https://neo.astrowave.jp/wp-content/uploads/2022/04/cropped-siteicon.png" sizes="32x32" />
<link rel="icon" href="https://neo.astrowave.jp/wp-content/uploads/2022/04/cropped-siteicon.png" sizes="192x192" />
<link rel="apple-touch-icon" href="https://neo.astrowave.jp/wp-content/uploads/2022/04/cropped-siteicon.png" />

<!-- OG -->
<meta property="og:type" content="blog">
<meta property="og:title" content="宇宙生活を楽しくデザインしたいサイト「アストロウェーブ」">
<meta property="og:url" content="<?php echo (empty($_SERVER["HTTPS"]) ? "http://" : "https://") . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"]; ?>">
<meta property="og:description" content="宇宙生活を楽しくデザインしたいサイト「アストロウェーブ」">
<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>WebP画像変換ツール</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;
}

.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>WebP画像変換ツール</h1>
    <h2>ブラウザ上で画像をWebP形式に変換できる無料ツールです。</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="image/jpeg,image/png,image/gif,image/webp" 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>画像をドラッグ&ドロップ</h2>
      <p>または</p>
      <button class="btn-primary" id="selectFilesBtn">ファイルを選択</button>
      <p class="hint">対応形式: JPG, PNG, GIF, WebP(複数選択可)</p>
    </div>

    <!-- 設定エリア -->
    <div class="settings-card" id="settingsCard">
      <h3>⚙️ 変換設定</h3>
      <div class="setting-item">
        <label>品質: <span id="qualityValue">80</span>%</label>
        <input type="range" id="qualitySlider" min="1" max="100" value="80">
        <p class="hint">低い値 = ファイルサイズ小 / 高い値 = 画質良</p>
      </div>
      <button class="btn-convert" id="convertBtn">🔄 すべて変換</button>
    </div>

    <!-- 進捗表示 -->
    <div class="progress-section" id="progressSection">
      <h3>変換中...</h3>
      <div class="progress-bar">
        <div class="progress-fill" id="progressFill"></div>
      </div>
      <p id="progressText">0 / 0 完了</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>個の画像をWebPに変換しました</p>
      <button class="btn-download-all" id="downloadAllBtn">📦 すべてまとめてダウンロード</button>
    </div>
  </main>

  <!-- フッター -->
  <div id="footer">
    <div class="footer-content">
    <h2 class="footer-title">WebP画像変換ツール</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>
    class WebPConverter {
      constructor() {
        this.files = [];
        this.quality = 80;
        this.results = [];
        
        this.initElements();
        this.attachEvents();
      }

      initElements() {
        this.uploadArea = document.getElementById('uploadArea');
        this.fileInput = document.getElementById('fileInput');
        this.selectFilesBtn = document.getElementById('selectFilesBtn');
        this.qualitySlider = document.getElementById('qualitySlider');
        this.qualityValue = document.getElementById('qualityValue');
        this.convertBtn = document.getElementById('convertBtn');
        this.settingsCard = document.getElementById('settingsCard');
        this.previewSection = document.getElementById('previewSection');
        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.downloadAllSection = document.getElementById('downloadAllSection');
        this.downloadAllBtn = document.getElementById('downloadAllBtn');
        this.completedCount = document.getElementById('completedCount');
      }

      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.startsWith('image/'));
          this.handleFiles(files);
        });

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

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

      handleFiles(files) {
        if (files.length === 0) return;
        
        this.files = files;
        this.results = [];
        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 reader = new FileReader();
          reader.onload = (e) => {
            const card = document.createElement('div');
            card.className = 'preview-card';
            card.innerHTML = `
              <img src="${e.target.result}" alt="${file.name}" class="preview-image">
              <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 status-pending">待機中</span>
                </div>
              </div>
              <button class="btn-remove" onclick="converter.removeFile(${index})">✕</button>
            `;
            this.previewGrid.appendChild(card);
          };
          reader.readAsDataURL(file);
        });
      }

      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();
      }

      async convertAll() {
        this.convertBtn.disabled = true;
        this.progressSection.classList.add('active');
        this.downloadAllSection.classList.remove('active');
        
        let completed = 0;
        const total = this.files.length;

        for (let i = 0; i < this.files.length; i++) {
          await this.convertImage(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' });
          }
        }, 1000);
      }

      async convertImage(file, index) {
        const statusEl = document.getElementById(`status-${index}`);
        
        try {
          statusEl.innerHTML = '<span class="status-badge status-converting">変換中...</span>';

          const formData = new FormData();
          formData.append('image', file);
          formData.append('quality', this.quality);

          const response = await fetch('webp_conv.php', {
            method: 'POST',
            body: formData
          });

          const result = await response.json();

          if (!result.success) {
            throw new Error(result.error);
          }

          this.results.push({
            filename: result.filename,
            data: result.data
          });

          statusEl.innerHTML = `
            <span class="status-badge status-success">✓ 完了</span>
            <div class="preview-size">${this.formatSize(result.originalSize)} → ${this.formatSize(result.newSize)}</div>
            <div class="preview-size" style="color: var(--success); font-weight: 600;">${result.reduction}% 削減</div>
            <button class="btn-download" onclick="converter.download(${index})">💾 ダウンロード</button>
          `;

        } catch (error) {
          console.error('変換エラー:', error);
          statusEl.innerHTML = `<span class="status-badge status-error">✕ ${error.message}</span>`;
        }
      }

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

        const blob = this.base64ToBlob(result.data, 'image/webp');
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = result.filename;
        a.click();
        URL.revokeObjectURL(url);
      }

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

        // 複数ファイルを順次ダウンロード
        this.results.forEach((result, index) => {
          setTimeout(() => {
            const blob = this.base64ToBlob(result.data, 'image/webp');
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = result.filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            
            // メモリ解放
            setTimeout(() => URL.revokeObjectURL(url), 100);
          }, index * 300); // 300msごとにダウンロード
        });

        // フィードバック表示
        const originalText = this.downloadAllBtn.textContent;
        this.downloadAllBtn.textContent = '📥 ダウンロード中...';
        this.downloadAllBtn.disabled = true;
        
        setTimeout(() => {
          this.downloadAllBtn.textContent = '✅ ダウンロード完了!';
          setTimeout(() => {
            this.downloadAllBtn.textContent = originalText;
            this.downloadAllBtn.disabled = false;
          }, 2000);
        }, this.results.length * 300 + 500);
      }

      base64ToBlob(base64, mimeType) {
        const byteCharacters = atob(base64);
        const byteArrays = [];
        
        for (let offset = 0; offset < byteCharacters.length; offset += 512) {
          const slice = byteCharacters.slice(offset, offset + 512);
          const byteNumbers = new Array(slice.length);
          for (let i = 0; i < slice.length; i++) {
            byteNumbers[i] = slice.charCodeAt(i);
          }
          byteArrays.push(new Uint8Array(byteNumbers));
        }
        
        return new Blob(byteArrays, { type: mimeType });
      }

      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'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
      }
    }

    const converter = new WebPConverter();
  </script>
</body>
</html>

・画質のデフォルトは80%にしています。
・一括ダウンロードはZIPせずに、複数ファイルを順次ダウンロードする方式を採用しています。

必要な環境
PHP 7.4以上
GD拡張機能(WebP対応)


パフォーマンスとセキュリティ
ファイルタイプの検証: MIMEタイプをサーバー側で厳密にチェック
エラーハンドリング: 不正なファイルを適切に拒否
--------php--------
// MIMEタイプチェック
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);

if (!in_array($mimeType, $allowedTypes)) {
throw new Exception('対応していない画像形式です');
}
--------------------

カスタマイズ方法
ファイルサイズ制限: デフォルトで10MBまで(変更可能)

最大ファイルサイズの変更
--------php--------
// デフォルト: 10MB
$maxSize = 10 * 1024 * 1024;

// 20MBに変更する場合
$maxSize = 20 * 1024 * 1024;
--------------------

デフォルト品質の変更
--------javascript--------
// デフォルト: 80%
this.quality = 80;

// 90%に変更する場合
this.quality = 90;
--------------------

対応形式の追加
WebP形式の画像を入力として受け付ける場合:

--------php--------
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

ローカルタイプも作成です。

サーバー変換との大きな違い:画像をサーバーに送らない

サーバーUPタイプ(webp_conv.php)

  • ブラウザで選んだ画像を fetch でPHPへPOST
  • サーバー側(PHP/GD)が WebPに変換
  • 変換結果を base64で返す → ブラウザでダウンロード

ローカルタイプ(webp_conv_local.php)

  • 画像は サーバーに送信しない
  • ブラウザで画像をデコードして Canvasに描画
  • canvas.toBlob(‘image/webp’, quality) で WebPを生成
  • その場でダウンロード

ローカル版は「サーバーのアップロード上限」は関係ないので、10MB〜30MB“縛り”は必須ではありません。

10MB超えでエラーになる場合は、ローカルの方で変換するなども可能です。

サーバーに負担が無くなるのですが、無制限にすると、今度は「ブラウザ/端末側のメモリ・処理時間」で落ちる可能性が上がります。問題は。。

解像度(総ピクセル)が大きくなりすぎるとエラーになるようで、詳しくはサンプルページの「使い方」の下に注釈を入れました。

webp_conv_local.php(ローカル変換タイプ)

<?php
/**
 * WebP画像変換ツール(ローカル変換版)
 * - 変換はブラウザ内で完結(画像はサーバーに送信しない)
 */
declare(strict_types=1);
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="ja">
<head>

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="画像をWebP形式に変換できる無料ツール。変換はブラウザ内で完結し、画像はサーバーに送信されません。JPG、PNG、GIF、WebPに対応。">
<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="WebP画像変換ツール(ローカル変換) | ツール集">
<meta property="og:url" content="<?php echo (empty($_SERVER['HTTPS']) ? 'http://' : 'https://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:description" content="画像をWebP形式に変換できる無料ツール。変換はブラウザ内で完結し、画像はサーバーに送信されません。">

<title>WebP画像変換ツール(ローカル変換) | ツール集 - アストロウェーブ</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; }

.upload-error {
  display: none;
  margin-top: 12px;
  font-size: 13px;
  color: #b00020;
  line-height: 1.6;
  white-space: pre-line;
}
.upload-error.active { display: block; }

.settings-panel { display: none; margin-top: 24px; }
.settings-panel.active { display: block; }
.quality-display { font-size: 14px; font-weight: 600; color: #222; }
.convert-btn { margin-top: 24px; }

.progress-section { display: none; text-align: center; padding: 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: 10px;
  right: 10px;
  width: 28px;
  height: 28px;
  border-radius: 999px;
  border: 1px solid rgba(0,0,0,.15);
  background: rgba(255,255,255,.9);
  color: #222;
  cursor: pointer;
  line-height: 1;
}
.result-card__remove:hover { background: #fff; }

.result-stats { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; align-items: center; }
.result-stats__item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  padding: 3px 8px;
  border-radius: 999px;
  background: #f2f2f2;
  color: #444;
}
.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; }
</style>
</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.1183422,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>WebP画像変換ツール(ローカル変換)</span>
    </nav>

    <!-- ヘッダー -->
    <header class="tool-page__header">
      <h1 class="tool-page__title">WebP画像変換ツール(ローカル変換)</h1>
      <p class="tool-page__desc">画像をWebP形式に変換できる無料ツールです(変換はブラウザ内で完結し、画像はサーバーに送信されません)。</p>
    </header>

    <!-- ツールコンテンツ -->
    <div class="tool-content">

      <!-- アップロードエリア -->
      <div class="upload-area" id="uploadArea">
        <input type="file" id="fileInput" multiple accept="image/jpeg,image/png,image/gif,image/webp" hidden>
        <svg class="upload-area__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
          <polyline points="17 8 12 3 7 8"/>
          <line x1="12" y1="3" x2="12" y2="15"/>
        </svg>
        <div class="upload-area__title">画像をドラッグ&ドロップ</div>
        <button class="tool-btn tool-btn--secondary mt-2" id="selectFilesBtn">ファイルを選択</button>
        <p class="upload-area__hint mt-2" id="supportHint">対応形式: JPG, PNG, GIF, WebP(複数選択可・最大30MB)</p>
        <div class="upload-error" id="uploadError" role="status" aria-live="polite"></div>
      </div>

      <!-- 設定パネル -->
      <div class="settings-panel" id="settingsPanel">
        <div class="tool-content__section">
          <label class="tool-content__label">
            品質: <span class="quality-display" id="qualityValue">80</span>%
          </label>
          <input type="range" class="tool-slider" id="qualitySlider" min="1" max="100" value="80">
          <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">
        <p>変換中...</p>
        <div class="tool-progress">
          <div class="tool-progress__fill" id="progressFill"></div>
        </div>
        <p id="progressText">0 / 0 完了</p>
      </div>

    </div>

    <!-- 結果 -->
    <div class="results-section" id="resultsSection">
      <h2 class="tools-section__title">変換結果</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"><span id="completedCount">0</span>個の画像をWebPに変換しました</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>画像ファイルをドラッグ&ドロップ、または「ファイルを選択」をクリック</li>
        <li>品質スライダーで圧縮率を調整(任意)</li>
        <li>「すべて変換」ボタンをクリック</li>
        <li>変換後の画像をダウンロード</li>
      </ol>
      <p class="upload-area__hint mt-2">※ 変換はブラウザ内で完結します。画像はサーバーに送信されません。</p>
      <p class="upload-area__hint mt-1">※ 画像サイズが大きい場合(例: 8000×6000px など)は、端末性能によって変換に時間がかかったり失敗することがあります。</p>
      <p class="upload-area__hint mt-1">※ GIFは静止画(先頭フレーム)として変換されます。</p>
    </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>
class WebPConverterLocal {
  constructor() {
    this.MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
    this.files = [];
    this.fileObjectUrls = new Map(); // index -> url
    this.quality = 80;
    this.results = []; // { filename, blob, originalSize, newSize }

    this.initElements();
    this.attachEvents();
    this.updateHint();
  }

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

    this.qualitySlider = document.getElementById('qualitySlider');
    this.qualityValue = document.getElementById('qualityValue');
    this.convertBtn = document.getElementById('convertBtn');
    this.settingsPanel = document.getElementById('settingsPanel');

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

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

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

  attachEvents() {
    this.selectFilesBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      this.fileInput.click();
    });

    this.uploadArea.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');
      this.handleFiles(Array.from(e.dataTransfer?.files || []));
    });

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

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

  updateHint() {
    if (this.supportHint) {
      this.supportHint.textContent = `対応形式: JPG, PNG, GIF, WebP(複数選択可・最大${Math.round(this.MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`;
    }
  }

  setUploadError(message) {
    if (!this.uploadError) return;
    if (!message) {
      this.uploadError.textContent = '';
      this.uploadError.classList.remove('active');
      return;
    }
    this.uploadError.textContent = message;
    this.uploadError.classList.add('active');
  }

  filterSupportedFiles(files) {
    const supportedTypes = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
    const supportedExts = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']);
    const supported = [];
    const rejected = [];

    files.forEach((file) => {
      const name = file?.name ?? 'unknown';
      const ext = (name.split('.').pop() || '').toLowerCase();
      const type = (file?.type || '').toLowerCase();

      // TIFFはブラウザがデコードできないことが多いので非対応として明示
      if (ext === 'tif' || ext === 'tiff' || type === 'image/tiff' || type === 'image/x-tiff') {
        rejected.push({ name, reason: 'TIFF(.tif)は非対応です' });
        return;
      }

      const isSupported = supportedTypes.has(type) || supportedExts.has(ext);
      if (!isSupported) {
        rejected.push({ name, reason: '対応形式外です(JPG/PNG/GIF/WebPのみ)' });
        return;
      }

      if (typeof file.size === 'number' && file.size > this.MAX_UPLOAD_BYTES) {
        rejected.push({ name, reason: `容量オーバーです(最大${Math.round(this.MAX_UPLOAD_BYTES / 1024 / 1024)}MB)` });
        return;
      }

      supported.push(file);
    });

    return { supported, rejected };
  }

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

    this.setUploadError('');
    const { supported, rejected } = this.filterSupportedFiles(files);
    if (rejected.length > 0) {
      const msg = rejected.slice(0, 4).map(r => `- ${r.name}(${r.reason})`).join('\n');
      const more = rejected.length > 4 ? `\n…他${rejected.length - 4}件` : '';
      this.setUploadError(`一部のファイルは追加できませんでした。\n${msg}${more}`);
    }

    if (supported.length === 0) return;

    this.clearObjectUrls();
    this.files = supported;
    this.results = [];

    this.settingsPanel.classList.add('active');
    this.downloadAllSection.classList.remove('active');
    this.displayPreviews();
  }

  clearObjectUrls() {
    for (const url of this.fileObjectUrls.values()) URL.revokeObjectURL(url);
    this.fileObjectUrls.clear();
  }

  displayPreviews() {
    this.previewGrid.innerHTML = '';
    this.resultsSection.classList.add('active');

    this.files.forEach((file, index) => {
      const url = URL.createObjectURL(file);
      this.fileObjectUrls.set(index, url);

      const card = document.createElement('div');
      card.className = 'tool-preview-card result-card';
      card.innerHTML = `
        <img src="${url}" alt="${this.escapeHtml(file.name)}" class="tool-preview-card__image">
        <div class="tool-preview-card__info">
          <div class="tool-preview-card__name">${this.escapeHtml(file.name)}</div>
          <div class="tool-preview-card__meta">${this.formatSize(file.size)}</div>
          <div id="status-${index}">
            <span class="tool-badge tool-badge--pending">待機中</span>
          </div>
        </div>
        <button class="result-card__remove" type="button" aria-label="削除" data-remove-index="${index}">×</button>
      `;
      const removeBtn = card.querySelector('[data-remove-index]');
      if (removeBtn) removeBtn.addEventListener('click', () => this.removeFile(index));

      const img = card.querySelector('img');
      if (img) {
        img.addEventListener('error', () => {
          img.style.display = 'none';
          const placeholder = document.createElement('div');
          placeholder.className = 'tool-preview-card__image';
          placeholder.style.cssText = 'display:flex;align-items:center;justify-content:center;background:#f2f2f2;color:#666;font-size:12px;';
          placeholder.textContent = 'プレビュー不可';
          card.insertBefore(placeholder, card.firstChild);
        }, { once: true });
      }

      this.previewGrid.appendChild(card);
    });
  }

  removeFile(index) {
    const url = this.fileObjectUrls.get(index);
    if (url) URL.revokeObjectURL(url);

    this.files = this.files.filter((_, i) => i !== index);
    this.results = [];

    // indexが詰まるのでURLマップを作り直し
    this.clearObjectUrls();

    if (this.files.length === 0) {
      this.settingsPanel.classList.remove('active');
      this.resultsSection.classList.remove('active');
      this.downloadAllSection.classList.remove('active');
      return;
    }

    this.displayPreviews();
  }

  async convertAll() {
    if (this.files.length === 0) return;

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

    let completed = 0;
    const total = this.files.length;
    this.results = [];

    for (let i = 0; i < this.files.length; i++) {
      await this.convertImage(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 = String(this.results.length);
        this.downloadAllSection.classList.add('active');
        this.downloadAllSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
      }
    }, 500);
  }

  async convertImage(file, index) {
    const statusEl = document.getElementById(`status-${index}`);
    if (!statusEl) return;

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

      const canvas = await this.decodeToCanvas(file);
      const q = Math.max(1, Math.min(100, this.quality)) / 100;
      const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', q));
      if (!blob) throw new Error('WebP変換に失敗しました。');

      const filename = this.toWebpName(file.name);
      const originalSize = file.size ?? 0;
      const newSize = blob.size;
      const reduction = originalSize > 0 ? Math.round(((originalSize - newSize) / originalSize) * 1000) / 10 : 0;

      const resultIndex = this.results.push({ filename, blob, originalSize, newSize, reduction }) - 1;

      statusEl.innerHTML = `
        <div class="result-stats">
          <span class="tool-badge tool-badge--success">完了</span>
          <span class="result-stats__item">${this.formatSize(originalSize)} → ${this.formatSize(newSize)}</span>
          <span class="result-stats__item result-stats__item--success">-${reduction}%</span>
        </div>
        <button class="tool-btn tool-btn--secondary mt-2" style="width:100%;padding:8px;" data-download-index="${resultIndex}">ダウンロード</button>
      `;

      const dlBtn = statusEl.querySelector('[data-download-index]');
      if (dlBtn) dlBtn.addEventListener('click', () => this.download(resultIndex));

    } catch (error) {
      const msg = (error && error.message) ? error.message : '変換に失敗しました。';
      statusEl.innerHTML = `<span class="tool-badge tool-badge--error">${this.escapeHtml(msg)}</span>`;
    }
  }

  async decodeToCanvas(file) {
    // createImageBitmap があれば優先(高速・低メモリ)
    if (typeof createImageBitmap === 'function') {
      const bitmap = await createImageBitmap(file);
      const canvas = document.createElement('canvas');
      canvas.width = bitmap.width;
      canvas.height = bitmap.height;
      const ctx = canvas.getContext('2d', { alpha: true });
      if (!ctx) throw new Error('Canvas初期化に失敗しました。');
      ctx.drawImage(bitmap, 0, 0);
      if (typeof bitmap.close === 'function') bitmap.close();
      return canvas;
    }

    // フォールバック(Safari等):Image要素でデコード
    const url = URL.createObjectURL(file);
    try {
      const img = await new Promise((resolve, reject) => {
        const i = new Image();
        i.onload = () => resolve(i);
        i.onerror = () => reject(new Error('画像の読み込みに失敗しました。'));
        i.src = url;
      });

      const canvas = document.createElement('canvas');
      canvas.width = img.naturalWidth || img.width;
      canvas.height = img.naturalHeight || img.height;
      const ctx = canvas.getContext('2d', { alpha: true });
      if (!ctx) throw new Error('Canvas初期化に失敗しました。');
      ctx.drawImage(img, 0, 0);
      return canvas;
    } finally {
      URL.revokeObjectURL(url);
    }
  }

  download(resultIndex) {
    const result = this.results[resultIndex];
    if (!result) return;
    const url = URL.createObjectURL(result.blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = result.filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(url), 200);
  }

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

    const btn = this.downloadAllBtn;
    const originalText = btn.textContent;
    btn.textContent = 'ダウンロード中...';
    btn.disabled = true;

    this.results.forEach((result, idx) => {
      setTimeout(() => {
        const url = URL.createObjectURL(result.blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = result.filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(() => URL.revokeObjectURL(url), 200);
      }, idx * 300);
    });

    setTimeout(() => {
      btn.textContent = 'ダウンロード完了';
      setTimeout(() => {
        btn.textContent = originalText;
        btn.disabled = false;
      }, 2000);
    }, this.results.length * 300 + 600);
  }

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

  formatSize(bytes) {
    const b = Number(bytes || 0);
    if (!Number.isFinite(b) || b <= 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB'];
    const i = Math.min(sizes.length - 1, Math.floor(Math.log(b) / Math.log(k)));
    return Math.round((b / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
  }

  toWebpName(name) {
    const base = (name || 'image').replace(/^.*[\\\\/]/, '');
    const dot = base.lastIndexOf('.');
    const stem = dot > 0 ? base.slice(0, dot) : base;
    return stem + '.webp';
  }

  escapeHtml(str) {
    return String(str).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
  }
}

const converter = new WebPConverterLocal();
</script>

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

</body>
</html>

画質はどうのなの、同じ?

気になりますので聞きました。AIの答えは以下です。

同じと思わない方が安全です。ローカル版とサーバ版はエンコーダが別物なので、同じ「品質80%」でも ファイルサイズや見た目が微妙に変わることがあります。
  • サーバ版: PHPのGD(サーバ側の imagewebp() の実装)でエンコード
  • ローカル版: ブラウザのCanvas(canvas.toBlob(‘image/webp’, quality))でエンコード

どちらも「概ねそれっぽい品質」にはなりますが、

  • 同じ品質値でもサイズが違う
  • 細部(文字・細い線・ノイズ)で差が出る
  • ブラウザ差(Chrome/Safari等)でも少し変わる

は普通に起きます。ただ、用途が「ブログ画像を軽くする」なら、実務的には どちらも十分とAIさんが言ってます。

サーバー変換タイプの方が安定?
ローカルに左右されない分。

画質について心配でAIに質問をしましたが、実際に変換したものを比べてみて「ほぼ同じだな」という感想です。

あなたは適当ですからね。。

同じ「品質80%設定」でも、サーバ(GD)とブラウザ(Canvas)ではエンコーダが違うため、画質やファイルサイズが完全一致しない場合があります(ブラウザ差も出ます)。

サーバー変換の方が、ファイル容量が小さいかも。。

詳しくはAIに放り込んでください。

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

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

この記事にピッタリなイラストのための考えたリクエストは、「サイバー空間で膨大なデータを懸命に圧縮するメイド型ロボット。メイドは女性で、日本のアニメ風。髪の毛はピンク。」です。

すごい高機能なメイドさんの雰囲気がしますよ。

こういうメイドさんが好きなんですか。

星間旅路のメロディ

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

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

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