first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
/* 地点图片:在浏览器里转成 WebP 再上传。
*
* 为什么放在前端转:1 核的服务器解一张 4000px 的原图要好几秒,几张就够卡住整站;
* 而用户自己的手机/电脑转一张是几十毫秒的事。服务端只负责把收到的 WebP 字节原样写出去。
*
* 尺寸与质量策略(在真机照片上量过才定的):
* 长边 1440px,质量 0.72 → 超 220KB 再试 0.58 → 还超就把整张缩到 1080px 重编。
* 关键在于「体积大头是像素数,不是质量」:实测一张 13MB 的城市照片,
* 1600px 从 q0.74 一路降到 q0.5 只从 483KB 掉到 391KB(掉 19%,画面却明显糊了),
* 而同样 q0.72 把长边从 1600 缩到 1024 直接掉到 217KB(掉 55%)。
* 所以阶梯走到底还超标时,宁可缩尺寸也不再压质量。
* 这几张图只用于详情页画廊与灯箱(最大显示到一千出头的宽度),1440px 留了余量。
*/
(function () {
var MAX_EDGE = 1440;
var QUALITY_LADDER = [0.72, 0.58];
var TARGET_BYTES = 220 * 1024;
// 兜底档:纹理特别密的照片在 1440px 下怎么降质都超标时才用
var FALLBACK_EDGE = 1080;
var FALLBACK_QUALITY = 0.7;
function loadImage(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('这张图片读不出来')); };
img.src = url;
});
}
function encode(cv, quality) {
return new Promise(function (resolve, reject) {
cv.toBlob(function (blob) {
// 不支持 WebP 编码的浏览器会静默给回 PNG,这里必须显式挡住——
// 否则上传的是 PNG,服务端会以「只接受 WebP」拒掉,用户却看不出原因
if (!blob || blob.type !== 'image/webp') {
reject(new Error('这个浏览器不支持 WebP 转码,请换新版 Chrome / Edge / Safari'));
return;
}
resolve(blob);
}, 'image/webp', quality);
});
}
// render 把原图按长边上限放到一张画布上,返回画布与落地的宽高
function render(img, limit) {
var w = img.naturalWidth || img.width || 1;
var h = img.naturalHeight || img.height || 1;
var scale = Math.min(1, limit / Math.max(w, h));
var tw = Math.max(1, Math.round(w * scale));
var th = Math.max(1, Math.round(h * scale));
var cv = document.createElement('canvas');
cv.width = tw;
cv.height = th;
var g = cv.getContext('2d');
if (g.imageSmoothingQuality !== undefined) g.imageSmoothingQuality = 'high';
// 白底:源图带透明通道时(截图、PNG)不会在 WebP 里变成黑块
g.fillStyle = '#fff';
g.fillRect(0, 0, tw, th);
g.drawImage(img, 0, 0, tw, th);
return { cv: cv, width: tw, height: th };
}
// toWebP(file) -> Promise<{blob, width, height, quality, bytes}>
function toWebP(file, opts) {
opts = opts || {};
var maxEdge = opts.maxEdge || MAX_EDGE;
var target = opts.targetBytes || TARGET_BYTES;
var ladder = opts.ladder || QUALITY_LADDER;
var fallbackEdge = opts.fallbackEdge === undefined ? FALLBACK_EDGE : opts.fallbackEdge;
var fallbackQuality = opts.fallbackQuality || FALLBACK_QUALITY;
return loadImage(file).then(function (img) {
var box = render(img, maxEdge);
function attempt(i) {
var q = ladder[i];
return encode(box.cv, q).then(function (blob) {
var out = { blob: blob, width: box.width, height: box.height, quality: q, bytes: blob.size };
if (out.bytes <= target || i === ladder.length - 1) return out;
return attempt(i + 1);
});
}
return attempt(0).then(function (out) {
// 阶梯走到底还超标 ⇒ 这张图纹理特别密,再降质量只会又糊又大,改成缩尺寸
var longest = Math.max(box.width, box.height);
if (out.bytes <= target || !fallbackEdge || longest <= fallbackEdge) return out;
var small = render(img, fallbackEdge);
return encode(small.cv, fallbackQuality).then(function (blob) {
// 兜底档同样是有损重编,纹理极端的图上未必比阶梯档小,所以比一次体积、取小的
if (blob.size >= out.bytes) return out;
return { blob: blob, width: small.width, height: small.height, quality: fallbackQuality, bytes: blob.size };
});
});
});
}
function post(url, form) {
return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
.then(function (r) { return r.json(); });
}
// upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}>
function upload(srcPK, blob, w, h) {
var fd = new FormData();
fd.append('src_pk', srcPK);
fd.append('w', w);
fd.append('h', h);
fd.append('file', blob, 'image.webp');
return post('/admin/place/images', fd);
}
function remove(id) {
var fd = new FormData();
fd.append('id', id);
return post('/admin/place/images/delete', fd);
}
window.rondImage = { toWebP: toWebP, upload: upload, remove: remove };
})();