/** * imageCompress.js * 업로드 전 브라우저에서 이미지를 리사이즈 + JPEG 압축 * Canvas API 사용 — 외부 라이브러리 불필요 */ const ImageCompressor = (() => { // 서버에서 가져온 설정 캐시 var _cfg = null; var DEFAULT_CFG = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 }; /** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출, 5초 타임아웃) */ async function loadConfig() { if (_cfg) return _cfg; try { var controller = new AbortController(); var tid = setTimeout(function() { controller.abort(); }, 5000); var res = await fetch('/api/settings/public', { signal: controller.signal }); clearTimeout(tid); _cfg = await res.json(); } catch (e) { _cfg = DEFAULT_CFG; } return _cfg; } /** * File 객체 하나를 압축해서 새 File 로 반환 * @param {File} file - 원본 이미지 파일 * @param {Object} cfg - { image_compress_enabled, image_max_px, image_quality } * @returns {Promise} */ function compressOne(file, cfg) { return new Promise(function(resolve) { // 압축 비활성 or 이미지가 아닌 파일은 그대로 반환 if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) { return resolve(file); } // createObjectURL 사용: readAsDataURL 대비 메모리 1/4 이하 // (base64 변환 없이 브라우저가 파일을 직접 디코딩) var objUrl = URL.createObjectURL(file); var img = new Image(); img.onerror = function() { URL.revokeObjectURL(objUrl); resolve(file); // 디코딩 실패(HEIF 등) → 원본 사용 }; img.onload = function() { URL.revokeObjectURL(objUrl); // 디코딩 완료 즉시 해제 if (!img.naturalWidth || !img.naturalHeight) { resolve(file); return; } var maxPx = cfg.image_max_px; var width = img.naturalWidth; var height = img.naturalHeight; // 긴 변이 maxPx 초과하면 비율 유지하며 축소 if (width > maxPx || height > maxPx) { if (width >= height) { height = Math.round((height / width) * maxPx); width = maxPx; } else { width = Math.round((width / height) * maxPx); height = maxPx; } } var canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; var ctx = canvas.getContext('2d'); if (!ctx) { resolve(file); return; } img.onerror = null; // 이후 src 변경에 의한 onerror 재실행 방지 ctx.drawImage(img, 0, 0, width, height); canvas.toBlob( function(blob) { canvas.width = 0; canvas.height = 0; // canvas 메모리 해제 if (!blob || blob.size < 500) { resolve(file); return; } // blob이 실제로 렌더 가능한 이미지인지 검증 // (OOM으로 drawImage가 빈 캔버스를 만들었을 경우를 잡기 위해) var blobUrl = URL.createObjectURL(blob); var check = new Image(); check.onerror = function() { URL.revokeObjectURL(blobUrl); resolve(file); // 유효하지 않은 JPEG → 원본 사용 }; check.onload = function() { URL.revokeObjectURL(blobUrl); if (!check.naturalWidth || !check.naturalHeight) { resolve(file); // 빈 이미지 → 원본 사용 return; } resolve(new File( [blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: Date.now() } )); }; check.src = blobUrl; }, 'image/jpeg', cfg.image_quality / 100 // 0~1 범위 ); }; img.src = objUrl; }); } /** * FileList / File[] 전체를 순차 압축 (병렬 처리 시 모바일 메모리 부족 방지) * @param {FileList|File[]} files * @returns {Promise} */ async function compressAll(files) { var cfg = await loadConfig(); var arr = Array.from(files); var result = []; for (var i = 0; i < arr.length; i++) { result.push(await compressOne(arr[i], cfg)); } return result; } /** * input[type=file] + 미리보기 div 에 압축 프리뷰 설정 * @param {string} inputId - file input 요소 id * @param {string} previewId - 미리보기 컨테이너 id * @param {string} infoId - 용량 정보 표시 span id (선택) */ function setupPreview(inputId, previewId, infoId) { var input = document.getElementById(inputId); var preview = document.getElementById(previewId); var info = infoId ? document.getElementById(infoId) : null; if (!input || !preview) return; input.addEventListener('change', async function() { preview.innerHTML = ''; if (info) info.textContent = '압축 중...'; var cfg = await loadConfig(); var origBytes = Array.from(this.files).reduce(function(s, f) { return s + f.size; }, 0); var compressed = await compressAll(this.files); var compBytes = compressed.reduce(function(s, f) { return s + f.size; }, 0); // DataTransfer 로 input.files 교체 (압축된 파일로) var dt = new DataTransfer(); compressed.forEach(function(f) { dt.items.add(f); }); this.files = dt.files; // 미리보기 렌더링 compressed.forEach(function(f, i) { var url = URL.createObjectURL(f); var wrap = document.createElement('div'); wrap.style.cssText = 'position:relative;display:inline-block;'; var img = document.createElement('img'); img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;background:#f0f0f0;'; img.onload = function() { URL.revokeObjectURL(url); }; img.onerror = function() { // 이미지 렌더 실패 시 플레이스홀더 표시 URL.revokeObjectURL(url); img.style.cssText = 'width:80px;height:80px;border-radius:6px;border:1px solid #C5CFE0;background:#f5f5f5;display:flex;align-items:center;justify-content:center;font-size:10px;color:#999;'; img.removeAttribute('src'); img.alt = '미리보기\n불가'; }; img.src = url; // 삭제 버튼 — 클로저로 이 파일(f)을 직접 참조 var del = document.createElement('button'); del.textContent = '×'; del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;'; (function(targetFile) { del.onclick = function() { var remaining = Array.from(input.files).filter(function(f2) { return f2.name !== targetFile.name || f2.size !== targetFile.size; }); var dt2 = new DataTransfer(); remaining.forEach(function(f2) { dt2.items.add(f2); }); input.files = dt2.files; wrap.remove(); updateInfo(input, info); }; })(f); wrap.appendChild(img); wrap.appendChild(del); preview.appendChild(wrap); }); // 용량 정보 if (info) { var pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0; if (cfg.image_compress_enabled && pct > 0) { info.textContent = compressed.length + '장 | ' + fmt(origBytes) + ' → ' + fmt(compBytes) + ' (' + pct + '% 절약) | 최대 ' + cfg.image_max_px + 'px / 품질 ' + cfg.image_quality + '%'; info.style.color = '#00875A'; } else { info.textContent = compressed.length + '장 | ' + fmt(compBytes) + ' (압축 비활성)'; info.style.color = '#8899BB'; } } }); } function updateInfo(input, info) { if (!info) return; var bytes = Array.from(input.files).reduce(function(s, f) { return s + f.size; }, 0); info.textContent = input.files.length + '장 | ' + fmt(bytes); } function fmt(bytes) { return bytes < 1024 * 1024 ? Math.round(bytes / 1024) + ' KB' : (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } /** * 카메라 input(capture)이 찍은 사진을 갤러리 input에 병합 후 change 이벤트 발생 * → setupPreview는 갤러리 input 하나만 바라보면 됨 * @param {string} cameraId - capture="environment" input id * @param {string} galleryId - 기존 multiple input id (setupPreview 대상) */ function setupCameraAppend(cameraId, galleryId) { var cam = document.getElementById(cameraId); var main = document.getElementById(galleryId); if (!cam || !main) return; cam.addEventListener('change', function() { if (!this.files.length) return; var dt = new DataTransfer(); Array.from(main.files).forEach(function(f) { dt.items.add(f); }); // 기존 파일 유지 Array.from(this.files).forEach(function(f) { dt.items.add(f); }); // 새 사진 추가 main.files = dt.files; main.dispatchEvent(new Event('change')); // setupPreview 재실행 this.value = ''; }); } return { compressAll, setupPreview, loadConfig, setupCameraAppend }; })();