第 04 章 — 圖片上傳與前端縮圖目錄  

AI 互動行銷頁實戰 — 目標九宮格 — 第 04 章

圖片上傳與前端縮圖:
在瀏覽器裡先把圖變小

學完:讓使用者選照片當 AI 參考圖,在瀏覽器裡縮小、轉 base64、存進 IndexedDB——重新整理照片還在。

核心觀念

參考圖是給 AI 看的,不是要印海報。
長邊 1280 跟 4000×3000 原圖,AI 理解到的幾乎一樣,檔案差十倍以上

這章要接起來的管線

原則只有一句:在瀏覽器裡先縮,再上傳。瀏覽器本身就是一台夠用的影像處理器。

選檔:一個 input 就夠

<input id="refs-input" type="file" accept="image/*" multiple>

縮圖:解碼 → canvas → JPEG

// 長邊縮到 maxEdge,輸出 JPEG blob(app/js/wallpaper.js)
export async function resizeImageBlob(blob, maxEdge = 1280, quality = 0.85) {
  const bitmap = await createImageBitmap(blob);   // 1. 解碼成點陣圖
  const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
  const w = Math.max(1, Math.round(bitmap.width * scale));
  const h = Math.max(1, Math.round(bitmap.height * scale));
  const c = document.createElement('canvas');     // 2. 開縮小後尺寸的畫布
  c.width = w; c.height = h;
  c.getContext('2d').drawImage(bitmap, 0, 0, w, h); // 3. 畫上去 = 縮放完成
  bitmap.close();
  return new Promise((resolve, reject) => {
    c.toBlob((out) => out ? resolve(out) : reject(new Error('縮圖失敗')),
      'image/jpeg', quality);                     // 4. 輸出 85% JPEG
  });
}

Math.min(1, ...) 只縮小、不放大。一張 8MB 手機原圖走完通常剩 150-400KB,肉眼看內容沒差。

轉 base64:把前綴切掉

data:image/jpeg;base64,/9j/4AAQSkZJRg...
└────── 前綴 ──────────┘└── 真正的 base64 ──

// Blob → 純 base64(app/js/wallpaper.js)
export function blobToBase64(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result).split(',')[1] || '');
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(blob);
  });
}

去不去前綴沒有對錯,純看 API 怎麼約:文件說收 data URL 就留著,說收 raw base64 就切掉。撞到 400 時第一個檢查這裡。

為什麼一定要前端先縮:算給你看

路線單張三張 base64 後結果
原圖直傳6MB約 24MB (base64 膨脹 33%)撞爆 20MB 請求上限,連 AI 的面都沒見到
先縮長邊 1280約 300KB約 1.2MB幾秒傳完,離所有上限都遠

就算過了 20MB,Worker 還有一道驗證:每張 base64 超過 4MB 回 400。體積問題在最靠近源頭的地方解決最便宜——源頭就是使用者的瀏覽器。

存哪裡?localStorage 不行

localStorage(第 2 章存文字用)

  • 只能存字串 → 圖得轉 base64,再膨脹 33%
  • 配額約 5-10MB,兩三張就滿
  • 滿了之後整個 app 的狀態保存跟著壞

IndexedDB(圖片一律放這)

  • 直接存 Blob(二進位圖檔)
  • 空間以 GB 計
  • 開庫樣板寫一次,包成 idbPut / idbAll / idbDelete 到處用

串起來:上傳流程

refsInput.addEventListener('change', async () => {
  const files = [...refsInput.files].slice(0, 3 - refRecords.length); // 最多 3 張
  refsInput.value = '';
  for (const file of files) {
    try {
      const blob = await resizeImageBlob(file, 1280); // 先縮,控制 body 體積
      await idbPut('refs', { blob, meta: { name: file.name },
                             createdAt: new Date().toISOString() });
    } catch {
      showMsg(bgMsgEl, '這張圖讀不進來,請換一張(支援一般圖片格式)。');
    }
  }
  loadRefs(); // 重新從 IndexedDB 讀出來畫縮圖列表
});

try/catch 是必需品:部分格式(例如某些 HEIC)解不開會丟錯——接住、給一句人話,不要讓整個頁面停擺。

顯示縮圖:借用記憶體網址

const img = document.createElement('img');
img.src = URL.createObjectURL(r.blob);
img.addEventListener('load', () => URL.revokeObjectURL(img.src), { once: true });

常見坑

重點回顧

下一章:AI 桌布背景——一張圖要生 1-3 分鐘,長任務的正確姿勢是 job + 輪詢 + 一條等得下去的進度條。

對照成品:app/js/wallpaper.js · app/js/state.js · app/js/main.js(搜尋 refsInput)