star back image
people4
電飾 電飾
moon
men

【WEBアプリ】画像をWebPに一括変換したい

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

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

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

WebPを検索しました。googleさんが作ったようです。

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

モダンブラウザで表示可能。photoshopで編集もOK。
もうすっかり定着した感じがします。

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

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

最近shopifyではどんな形式でアップしても、自動でwebpで表示させているようです。人気ですね。

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

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

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

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

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

【共有】webp変換サンプルページ

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

要件は以下です。

  • PHPファイル1つアップロードするだけ
  • 複数枚を一括変換できる
  • 細かい設定不要(品質スライダーだけ)
画像をwebpに変換(サンプルページ)
https://astrowave.jp/amnesia_record/webp_conv.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'];
    $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のみ)。');
    }

    // 品質の取得
    $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;
    }

    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>
<!-- 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 -->
<!-- Google tag (gtag.js) -->
<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');
  //gtag('config', 'UA-30305859-1');
</script>

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

.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>
    <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" 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(複数選択可)</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'];

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

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

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

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

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

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

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

星間旅路のメロディ

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

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

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