feat(ux): 图片选图即压缩,上传体积从 MB 级降到 KB 级
服务端早已把静态图转 WebP、长边限 1600(storage.GeneralEncodeSpec),但上传这一跳 发生在压缩之前:手机拍的 4~8MB 照片要先原样传完才能被缩小,移动网络 1~2 Mbps 下 要等几十秒,超过 5MB 校验的直接被拒、连写好的正文一起丢。 现在选图后先在浏览器内压缩,再随表单上传: - canvas → WebP,长边 1600、质量 0.8(与服务端同档),一张照片通常降到 200~400KB - createImageBitmap + imageOrientation:from-image 摆正 EXIF,老浏览器回退不带选项 - submit capture 兜底:选图立刻提交时先等压缩完成再真正 submit 三条安全边界(缺一不可,否则会「压出比原图更差的结果」): - GIF/SVG 跳过:canvas 会把 GIF 画成静止首帧、SVG 栅格化 - 压完不到原体积 85% 退回原图:已优化小图重编码反而更大 - 透明 PNG 优先 WebP(支持 alpha),不支持 WebP 编码时退回 PNG 而非 JPEG(否则透明变黑) 实测(WSL headless Chromium + CDP):4.6MB PNG → 11.7KB WebP(0.3%), 小图原样保留、GIF 跳过。 顺带加 dev/sync-wsl.sh:把同步脚本里 rsync --delete 误删目标机 conf/app.ini 的坑 固化掉(该文件含 DB 密码与对象存储密钥,不在源目录里,必须 exclude)。
This commit is contained in:
@@ -45,3 +45,8 @@ __pycache__/
|
|||||||
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
|
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
|
||||||
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
|
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
|
||||||
/.workbuddy/local/
|
/.workbuddy/local/
|
||||||
|
|
||||||
|
# WSL 测试环境的 app.ini 模板:含数据库密码与对象存储密钥,绝不能进版本库。
|
||||||
|
# 用法见 dev/sync-wsl.sh(同步时必须排除 conf/app.ini,否则会被 rsync --delete 删掉)。
|
||||||
|
/dev/wsl-app.ini
|
||||||
|
/dev/_*.py
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 把 Windows 侧工作区同步到 WSL 里的测试部署目录。
|
||||||
|
#
|
||||||
|
# 为什么不能直接 rsync --delete:WSL 侧有几份「只存在于目标机」的文件,
|
||||||
|
# 源目录里没有它们,--delete 会把它们当多余文件删掉:
|
||||||
|
# - conf/app.ini 本地测试配置(数据库密码、对象存储密钥;模板见 dev/wsl-app.ini)
|
||||||
|
# 第一次踩这个坑时整份 app.ini 被删,服务器重启后起不来。
|
||||||
|
#
|
||||||
|
# 用法:在仓库根目录执行
|
||||||
|
# bash dev/sync-wsl.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DST=/home/tamakyi/tb/src
|
||||||
|
|
||||||
|
cd "$SRC"
|
||||||
|
tar -cf - \
|
||||||
|
--exclude=.git \
|
||||||
|
--exclude=outputs \
|
||||||
|
--exclude=.workbuddy \
|
||||||
|
. 2>/dev/null |
|
||||||
|
wsl -d Debian -e bash -c '
|
||||||
|
set -e
|
||||||
|
DST=/home/tamakyi/tb/src
|
||||||
|
rm -rf /tmp/tbsync && mkdir -p /tmp/tbsync
|
||||||
|
tar -xf - -C /tmp/tbsync
|
||||||
|
# 只同步代码与资源,绝不动目标侧的 conf/app.ini
|
||||||
|
rsync -a --delete --exclude "conf/app.ini" /tmp/tbsync/ "$DST"/
|
||||||
|
rm -rf /tmp/tbsync
|
||||||
|
echo "SYNCED -> $DST"
|
||||||
|
'
|
||||||
@@ -122,5 +122,191 @@
|
|||||||
// bfcache 还原时复位按钮
|
// bfcache 还原时复位按钮
|
||||||
window.addEventListener('pageshow', tbResetSubmitButtons);
|
window.addEventListener('pageshow', tbResetSubmitButtons);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 图片前端压缩:选图后先在浏览器里把大图缩到服务端的上限,再随表单上传。
|
||||||
|
//
|
||||||
|
// 为什么值得做:服务端已经把静态图转 WebP 并把长边限到 1600(storage.GeneralEncodeSpec),
|
||||||
|
// 但**上传这一跳是先发生的** —— 手机拍的 4~8MB 照片要先原样传完,才能被服务端缩小。
|
||||||
|
// 于是出现两个真实问题:
|
||||||
|
// 1. 超过 form.MaxRequestBodySize / 服务端 5MB 校验的图直接被拒,用户写好的正文一起丢;
|
||||||
|
// 2. 移动网络上行为 1~2 Mbps,传一张 6MB 图要 30 秒以上,期间页面只能干等。
|
||||||
|
// 前端先缩到 1600 长边、质量 0.8(与服务端同一档),一张手机照片通常降到 200~400KB,
|
||||||
|
// 上传时间从几十秒变成一两秒,且不再触碰体积上限。
|
||||||
|
//
|
||||||
|
// 三条安全边界(都不能省,否则会「压缩」出比原图更差的结果):
|
||||||
|
// - GIF 与 SVG 直接跳过:canvas 会把 GIF 画成静止首帧、把 SVG 栅格化;
|
||||||
|
// - 压完不比原图小就退回原图:已经优化过的小图重编码反而更大;
|
||||||
|
// - PNG 的透明通道保留:优先输出 WebP(支持 alpha),浏览器不支持 WebP 编码时
|
||||||
|
// 用 PNG 而不是 JPEG(JPEG 会把透明变成黑底)。
|
||||||
|
(function () {
|
||||||
|
var MAX_SIDE = 1600; // 与服务端 GeneralEncodeSpec.MaxSide 对齐
|
||||||
|
var QUALITY = 0.8; // 与服务端 Quality 对齐
|
||||||
|
var MIN_BYTES = 300 * 1024; // 小于这个体积重编码收益有限,直接放行
|
||||||
|
var MIN_SAVING = 0.85; // 压完不到原体积 85% 就不采用
|
||||||
|
|
||||||
|
// 每个 input 最近的压缩任务,供 submit 前等待。
|
||||||
|
var pending = new WeakMap();
|
||||||
|
|
||||||
|
var webpOK = null;
|
||||||
|
function supportsWebP() {
|
||||||
|
if (webpOK !== null) return webpOK;
|
||||||
|
try {
|
||||||
|
var c = document.createElement('canvas');
|
||||||
|
c.width = c.height = 1;
|
||||||
|
webpOK = c.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
||||||
|
} catch (e) {
|
||||||
|
webpOK = false;
|
||||||
|
}
|
||||||
|
return webpOK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompressible(file) {
|
||||||
|
if (!file || !file.type) return false;
|
||||||
|
// GIF(可能动图)与 SVG(矢量)不能用 canvas 重编码
|
||||||
|
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return false;
|
||||||
|
if (file.type.indexOf('image/') !== 0) return false;
|
||||||
|
return file.size > MIN_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBitmap(file) {
|
||||||
|
if (self.createImageBitmap) {
|
||||||
|
// imageOrientation: 'from-image' 让手机竖拍照片按 EXIF 摆正;
|
||||||
|
// 老浏览器不认这个选项会抛错,退回不带选项的调用。
|
||||||
|
return createImageBitmap(file, { imageOrientation: 'from-image' })
|
||||||
|
.catch(function () { return createImageBitmap(file); });
|
||||||
|
}
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var img = new Image();
|
||||||
|
var url = URL.createObjectURL(file);
|
||||||
|
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||||||
|
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('decode failed')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBlob(canvas, type, quality) {
|
||||||
|
return new Promise(function (resolve) { canvas.toBlob(resolve, type, quality); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reencode(file) {
|
||||||
|
return loadBitmap(file).then(function (bmp) {
|
||||||
|
var w = bmp.width, h = bmp.height;
|
||||||
|
if (!w || !h) throw new Error('empty image');
|
||||||
|
var scale = Math.min(1, MAX_SIDE / Math.max(w, h));
|
||||||
|
var tw = Math.max(1, Math.round(w * scale));
|
||||||
|
var th = Math.max(1, Math.round(h * scale));
|
||||||
|
|
||||||
|
// PNG 走 WebP(两者都支持透明);不支持 WebP 编码时宁可继续用 PNG,
|
||||||
|
// 也不要转成 JPEG —— 透明区域会变成黑块。
|
||||||
|
var transparentCapable = file.type === 'image/png';
|
||||||
|
var target = 'image/jpeg';
|
||||||
|
if (supportsWebP()) {
|
||||||
|
target = 'image/webp';
|
||||||
|
} else if (transparentCapable) {
|
||||||
|
target = 'image/png';
|
||||||
|
}
|
||||||
|
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = tw;
|
||||||
|
canvas.height = th;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('no 2d context');
|
||||||
|
if (target === 'image/jpeg') {
|
||||||
|
// JPEG 没有 alpha:先铺白底,否则透明部分变黑。
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillRect(0, 0, tw, th);
|
||||||
|
}
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(bmp, 0, 0, tw, th);
|
||||||
|
if (bmp.close) bmp.close();
|
||||||
|
|
||||||
|
return toBlob(canvas, target, QUALITY);
|
||||||
|
}).then(function (blob) {
|
||||||
|
if (!blob || blob.size >= file.size * MIN_SAVING) return file;
|
||||||
|
// 文件名保持不变:FormData 里的文件名只用于展示与日志,
|
||||||
|
// 服务端按文件头嗅探真实格式(image.DecodeConfig),不看扩展名。
|
||||||
|
return new File([blob], file.name, { type: blob.type, lastModified: Date.now() });
|
||||||
|
}).catch(function () {
|
||||||
|
// 任何一步失败(不支持、内存不足、编码器缺失)都退回原图,绝不阻断提交。
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function swap(input, file) {
|
||||||
|
try {
|
||||||
|
var dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
input.files = dt.files;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// 老浏览器不支持对 input.files 赋值:放弃替换,按原图提交。
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind(input) {
|
||||||
|
if (input.dataset.tbImgBound === '1') return;
|
||||||
|
input.dataset.tbImgBound = '1';
|
||||||
|
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!isCompressible(file)) {
|
||||||
|
pending.delete(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var job = reencode(file).then(function (out) {
|
||||||
|
if (out !== file) swap(input, out);
|
||||||
|
});
|
||||||
|
pending.set(input, job);
|
||||||
|
// 无论成功失败都清掉,避免下一次提交白等一个已完成的 Promise。
|
||||||
|
job.then(function () {
|
||||||
|
if (pending.get(input) === job) pending.delete(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(root) {
|
||||||
|
(root || document).querySelectorAll('input[type="file"]').forEach(bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(document);
|
||||||
|
// 后插入的表单(如「点击回答」后展开的编辑器)不会触发上面的扫描,
|
||||||
|
// 用 MutationObserver 兜住;只在新增节点里找 input,开销可以忽略。
|
||||||
|
if (window.MutationObserver) {
|
||||||
|
new MutationObserver(function (records) {
|
||||||
|
records.forEach(function (r) {
|
||||||
|
Array.prototype.forEach.call(r.addedNodes, function (node) {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
if (node.matches && node.matches('input[type="file"]')) bind(node);
|
||||||
|
if (node.querySelectorAll) scan(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:极少数情况下用户选完图立刻提交(压缩还没跑完)。
|
||||||
|
// capture 阶段先拦下,等压缩结束再真正提交 —— 用 form.submit() 而不是
|
||||||
|
// 再走一次 submit 事件,天然避开死循环,也不会二次触发上面的防重复逻辑。
|
||||||
|
document.addEventListener('submit', function (e) {
|
||||||
|
var form = e.target;
|
||||||
|
if (!form || form.tagName !== 'FORM') return;
|
||||||
|
|
||||||
|
var waits = [];
|
||||||
|
form.querySelectorAll('input[type="file"]').forEach(function (input) {
|
||||||
|
var job = pending.get(input);
|
||||||
|
if (job) waits.push(job);
|
||||||
|
});
|
||||||
|
if (!waits.length) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
Promise.all(waits).then(function () {
|
||||||
|
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user