上傳照片時自動在瀏覽器端縮圖壓縮
根本原因: 照片一律以原始解析度存檔(手機相機動輒 2000px 以上、數 MB),除了先前已修的編譯端問題,也拖慢上傳、佔用 NAS 空間與管理頁縮圖載入。 影響: 上傳大照片時傳輸慢、data 目錄膨脹;縮圖修正前上傳的原圖也一直以全尺寸存放。 修法: 管理頁上傳前先在瀏覽器把照片縮到長邊 1280px、以 JPEG 90% 重新編碼(canvas 會把 EXIF 方向烘進像素,直式照片不會轉向),訊息列顯示壓縮前後大小;MAX_EDGE 常數與編譯端共用,編譯端縮圖保留作為舊照片的雙保險。已在本機實測:2244x4124 / 1.4MB 原圖上傳後存為 696x1280 / 170KB。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+45
-6
@@ -59,7 +59,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>新增配對</h2>
|
<h2>新增配對</h2>
|
||||||
<form id="addForm">
|
<form id="addForm">
|
||||||
<label>照片(jpg / png)<input type="file" name="photo" accept=".jpg,.jpeg,.png" required></label>
|
<label>照片(jpg / png,上傳時自動壓縮)<input type="file" name="photo" accept=".jpg,.jpeg,.png" required></label>
|
||||||
<label>影片(mp4 建議 H.264)<input type="file" name="video" accept=".mp4,.m4v,.mov,.webm" required></label>
|
<label>影片(mp4 建議 H.264)<input type="file" name="video" accept=".mp4,.m4v,.mov,.webm" required></label>
|
||||||
<button type="submit" id="addBtn">上傳</button>
|
<button type="submit" id="addBtn">上傳</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -146,15 +146,55 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 照片統一縮到這個長邊尺寸:對 MindAR 特徵品質足夠,
|
||||||
|
// 且掃描端(尤其 iOS Safari)的 WebGL 記憶體撐得住
|
||||||
|
const MAX_EDGE = 1280;
|
||||||
|
|
||||||
|
function loadImageFromFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
||||||
|
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('照片檔案無法讀取')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上傳前先在瀏覽器縮圖壓縮(長邊 MAX_EDGE、JPEG 90%),
|
||||||
|
// 伺服器不需裝任何影像處理套件。canvas 重新編碼時
|
||||||
|
// 瀏覽器會把 EXIF 方向烘進像素,直式照片不會轉向
|
||||||
|
async function optimizePhoto(file) {
|
||||||
|
const img = await loadImageFromFile(file);
|
||||||
|
const scale = Math.min(1, MAX_EDGE / Math.max(img.naturalWidth, img.naturalHeight));
|
||||||
|
if (scale === 1) return file;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = Math.round(img.naturalWidth * scale);
|
||||||
|
c.height = Math.round(img.naturalHeight * scale);
|
||||||
|
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
||||||
|
const blob = await new Promise((r) => c.toBlob(r, 'image/jpeg', 0.9));
|
||||||
|
if (!blob) throw new Error('照片壓縮失敗');
|
||||||
|
return new File([blob], file.name.replace(/\.\w+$/, '') + '.jpg', { type: 'image/jpeg' });
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('addForm').addEventListener('submit', async (e) => {
|
document.getElementById('addForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = document.getElementById('addBtn');
|
const btn = document.getElementById('addBtn');
|
||||||
const msg = document.getElementById('msg');
|
const msg = document.getElementById('msg');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
msg.className = '';
|
msg.className = '';
|
||||||
msg.textContent = '上傳中…';
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/pairs', { method: 'POST', body: new FormData(e.target) });
|
const rawPhoto = e.target.photo.files[0];
|
||||||
|
msg.textContent = '照片壓縮中…';
|
||||||
|
const photo = await optimizePhoto(rawPhoto);
|
||||||
|
const mb = (n) => (n / 1024 / 1024).toFixed(1) + 'MB';
|
||||||
|
msg.textContent = photo === rawPhoto
|
||||||
|
? '上傳中…'
|
||||||
|
: `上傳中…(照片 ${mb(rawPhoto.size)} → ${mb(photo.size)})`;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', photo, photo.name);
|
||||||
|
fd.append('video', e.target.video.files[0]);
|
||||||
|
const res = await fetch('/api/pairs', { method: 'POST', body: fd });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok) throw new Error(json.error || res.statusText);
|
if (!res.ok) throw new Error(json.error || res.statusText);
|
||||||
msg.textContent = '已新增,請記得重新編譯。';
|
msg.textContent = '已新增,請記得重新編譯。';
|
||||||
@@ -211,11 +251,10 @@ document.getElementById('compileBtn').addEventListener('click', async () => {
|
|||||||
pairs.map((p) => loadImage('/data/photos/' + encodeURIComponent(p.photo)))
|
pairs.map((p) => loadImage('/data/photos/' + encodeURIComponent(p.photo)))
|
||||||
);
|
);
|
||||||
|
|
||||||
// 大圖先縮到長邊 1280px 再編譯:特徵品質足夠,
|
// 大圖先縮到長邊 MAX_EDGE 再編譯(雙保險:新上傳的照片已在上傳時縮圖,
|
||||||
// 且 .mind 檔與掃描端(尤其 iOS Safari)的 WebGL 記憶體用量大幅下降。
|
// 這裡再保護縮圖修正前上傳的舊照片)。
|
||||||
// 曾發生 4 張 2244x4131 原圖直接編譯後,iPhone 掃任何照片都沒反應
|
// 曾發生 4 張 2244x4131 原圖直接編譯後,iPhone 掃任何照片都沒反應
|
||||||
// (桌面 Chrome 正常,iOS WebGL 資源上限較低,辨識引擎無聲掛掉)
|
// (桌面 Chrome 正常,iOS WebGL 資源上限較低,辨識引擎無聲掛掉)
|
||||||
const MAX_EDGE = 1280;
|
|
||||||
const compileInputs = images.map((img) => {
|
const compileInputs = images.map((img) => {
|
||||||
const scale = Math.min(1, MAX_EDGE / Math.max(img.naturalWidth, img.naturalHeight));
|
const scale = Math.min(1, MAX_EDGE / Math.max(img.naturalWidth, img.naturalHeight));
|
||||||
if (scale === 1) return img;
|
if (scale === 1) return img;
|
||||||
|
|||||||
Reference in New Issue
Block a user