feat(mp): 前端压缩做完 —— 上传体积降到 1/8,顺带修掉两个真 bug
一、效果(浏览器实测,真存储、真接口)
手机竖拍 3200×2400 + EXIF Orientation=6,3699KB
→ 1200×1600(竖的,转正正确)· 438KB · q88 · 73ms
服务端**原样收下**:collect_file 里 raw_size=448940、final_size=448940、
quality=0(NOT_REENCODED)—— 字节完全相同,没有二次编码。
二、🐛 bug 一:浏览器对 EXIF 的处理**不一致**,猜必错一半
写完前端旋正后实测,输出是 1600×1200(横的)—— 又躺倒了。量出来:
文件里的像素 3200×2400(横)+ Orientation=6
img.naturalWidth/Height 2400×3200 ← 浏览器**已经自己按 EXIF 转正了**
createImageBitmap('none') 2400×3200 ← 连明确要求不给转也被忽略
现代浏览器会把 EXIF 先转正再交给 drawImage,我又转一次 → 转两下 = 又躺倒;
而老实现(旧 X5 / 旧 WKWebView)给的是原始像素,不转就是躺倒。
两端行为相反,**任何写死的做法都必错一半**。
解法:不猜,量出来再决定 —— 自己从 JPEG 的 SOF 段读**文件里的原始像素**
(readJpegSize,与 EXIF 无关),再和 naturalWidth/Height 比:
· 与原始尺寸互换 → 浏览器已经转过 → 不要再转
· 两者相同 → 给的是原始像素 → 我们自己转
· 都对不上 → 按现代浏览器的默认(已转正)处理
这和第 §一·二十 的 EXIF bug 是同一件事的两面:
后端不转是躺倒,前端多转一次也是躺倒。
三、🐛 bug 二:按后缀判格式 → 前端压缩一上线,学生就传不了材料
上传被拒:「身份证」只接受 jpg/jpeg/png,你传的是 .bin。
H5 用 **blob 地址**调 uni.uploadFile,multipart 里的文件名是一串 UUID、
**没有后缀**,而后端当时按 extOf(name) 判白名单 → 判成 .bin → 拒。
这不是脚手架的问题:前端压缩产出的就是 blob 地址,一上线所有压缩过的照片
都会被拒。两处一起修:
· 后端改为按**字节幻数**判格式(sniffExt),幻数认不出再退回看后缀
(顺带变强:PNG 改名成 .jpg 也能正确识别)
· 接口新增可选 fileName 字段,客户端显式声明文件名;
original_name 只用于展示留痕、不进路径拼接,并截断防超长
四、档位 → 前端行为的映射(**不按材料码猜**,档位是管理员可改的配置)
P2 证件翻拍 → 压:长边 1600 + q88 起阶梯
P3 扫描件 → 压:长边 3508 + 降质量(别套 P2 的 1600,会看不清字)
AUTO/P5 → 只降质量不缩尺寸(未知档位擅自缩尺寸是危险的)
P1 证件照 → 不压(服务端要 295×413 裁切 + 补白底)
P4 截图 → 不压(关键是保持 PNG,canvas 只能出 JPEG 会糊掉表格线)
与服务端配合的关键:必须压成**正好落在服务端那套目标里** ——
压狠了白掉一次画质,压轻了服务端再压一次、前端白干(实测 quality=0 证明走对了)。
五、小程序端:**刻意不做 canvas 旋正**(真机未验证)
orientation === 'up' → compressImage 压;其它朝向 → 不压、直传,交服务端转正。
理由:小程序 canvas 的旋正在真机上无法验证,转错了的后果是机构收到躺倒的身份证;
宁可慢一点,也不能赌。
六、E2E
e2e-m3-file 39/39,新增第 8 节守这两个 bug:
✓ **没有后缀**的真 JPEG 被接受(按字节判格式,不看后缀)
✓ 字节是 PNG、后缀写着 .txt → 按字节认作 png 并接受
全量 469 条断言、0 失败。
七、验证脚手架已拆
pages/dev/compress-check.vue + static/__devcheck/ + 造图脚本都是一次性的,
已全部删除并重建 H5(自查过产物里不再有 compress-check / __devcheck)。
它的价值在于:文件选择框没法自动化(chooseImage 会弹系统对话框),
所以那页绕开"选文件",直接拿真构造的竖拍照片喂给**真正的压缩模块**、
走**真正的上传通道** —— 验的比"尺寸对不对"更硬:
**定位色块落在右上角**(证明转的是 90° 顺时针,不只是宽高换了位置)。
This commit is contained in:
@@ -175,6 +175,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { request } from '@/utils/request'
|
||||
import { compressForUpload, planFor, releaseCompressedPath, type CompressOutcome } from '@/utils/image-compress'
|
||||
import { API_BASE_URL as API_BASE } from '@/config'
|
||||
|
||||
/**
|
||||
@@ -395,6 +396,13 @@ interface Material {
|
||||
fileTypes?: string[]
|
||||
maxSizeKb?: number
|
||||
maxCount?: number
|
||||
/**
|
||||
* 压缩档位(`AUTO`/`P1`~`P5`)。**前端压缩目标由它推导**,不按材料码猜 ——
|
||||
* 档位是管理员可改的配置,猜材料码的话管理员一改前端就错,而且错得悄无声息。
|
||||
*/
|
||||
compressProfile?: string | null
|
||||
/** 质量下限。低于它就不再降质量(与服务端同口径)。 */
|
||||
minQuality?: number | null
|
||||
tipText?: string | null
|
||||
/** 条件显示规则;null = 无条件显示。**必须与字段一样求值** —— 详见 visibleMaterials */
|
||||
visibleWhen?: Record<string, unknown> | null
|
||||
@@ -489,11 +497,16 @@ async function loadFiles() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 选文件 → 上传 → 立刻处理。
|
||||
* 选文件 → (前端压)→ 上传。
|
||||
*
|
||||
* <p>**为什么上传后马上处理**:学生要能看到"压到了多大、叫什么名字"才算完成。
|
||||
* 若把处理留到提交时,学生上传时会以为"传上去了",提交时才发现体积超标 ——
|
||||
* 而那时他可能已经在回家的路上。
|
||||
* <p>**为什么要在前端压**:上传这一步发生在服务端压缩**之前** —— 服务端压得再好,
|
||||
* 学生的 3MB 也已经从流量里走完了。前端先压到几百 KB,上传时间大致降到 1/4。
|
||||
* 压不了的(证件照、截图、需要转正的小程序照片)就原样上传,由服务端兜底 ——
|
||||
* **前端压缩只是优化,不该成为新的失败点**(见 utils/image-compress.ts)。
|
||||
*
|
||||
* <p>**不再调 `/process`**:服务端改成"收上来即压缩、只存规范件"了(见
|
||||
* docs/交接摘要.md §一·二十)。所以原来那次"上传完再让服务端处理"的往返
|
||||
* 现在没有任何意义 —— 少一次请求,学生少等一个来回。
|
||||
*/
|
||||
function uploadFor(m: Material) {
|
||||
uni.chooseImage({
|
||||
@@ -501,16 +514,35 @@ function uploadFor(m: Material) {
|
||||
success: async (res) => {
|
||||
const path = (res.tempFilePaths || [])[0]
|
||||
if (!path || !submissionId.value) return
|
||||
uni.showLoading({ title: '上传中…' })
|
||||
let outcome: CompressOutcome | null = null
|
||||
let sendPath = path
|
||||
// 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、
|
||||
// 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。
|
||||
const pickedName = ((res.tempFiles || [])[0] as { name?: string } | undefined)?.name || ''
|
||||
try {
|
||||
const uploaded = await new Promise<{ id: number }>((resolve, reject) => {
|
||||
const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality)
|
||||
if (plan) {
|
||||
uni.showLoading({ title: '压缩中…' })
|
||||
outcome = await compressForUpload(path, plan)
|
||||
sendPath = outcome.path
|
||||
// 结论写日志:学生说"我的图怎么糊了"时,这是唯一能回看"压过几次、压到多少"的地方
|
||||
console.log('[材料压缩]', m.materialCode, outcome.note)
|
||||
}
|
||||
uni.showLoading({ title: '上传中…' })
|
||||
// 压缩后内容是 JPEG,所以扩展名必须跟着改成 .jpg(原名可能是 .png/.heic)
|
||||
const baseName = pickedName ? pickedName.replace(/\.[^.]+$/, '') : m.materialName
|
||||
const sendName = outcome?.compressed
|
||||
? baseName + '.jpg'
|
||||
: (pickedName || m.materialName + '.jpg')
|
||||
await new Promise<{ id: number }>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: API_BASE + '/api/v1/mp/files/upload',
|
||||
filePath: path,
|
||||
filePath: sendPath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
submissionId: String(submissionId.value),
|
||||
materialCode: m.materialCode
|
||||
materialCode: m.materialCode,
|
||||
fileName: sendName
|
||||
},
|
||||
header: token() ? { Authorization: `Bearer ${token()}` } : {},
|
||||
success: (r) => {
|
||||
@@ -525,14 +557,13 @@ function uploadFor(m: Material) {
|
||||
fail: (e) => reject(new Error(e.errMsg || '上传失败'))
|
||||
})
|
||||
})
|
||||
uni.showLoading({ title: '处理中…' })
|
||||
await request<void>({
|
||||
url: `/api/v1/mp/files/${uploaded.id}/process`,
|
||||
method: 'POST',
|
||||
token: token()
|
||||
})
|
||||
await loadFiles()
|
||||
uni.showToast({ title: '已压缩并改名', icon: 'success' })
|
||||
// 如实告诉学生发生了什么:压过了就说压到多大,没压就说已上传 ——
|
||||
// 不说清的话,他看到"体积比原图小"会以为材料被换了
|
||||
uni.showToast({
|
||||
title: outcome?.compressed ? `已压缩 ${kb(outcome.afterBytes)}` : '已上传',
|
||||
icon: 'success'
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showModal({
|
||||
title: '上传未完成',
|
||||
@@ -540,6 +571,7 @@ function uploadFor(m: Material) {
|
||||
showCancel: false
|
||||
})
|
||||
} finally {
|
||||
if (outcome) releaseCompressedPath(sendPath, outcome)
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 上传前的图片压缩(H5 + 小程序共用)。
|
||||
*
|
||||
* ── 为什么要有它 ────────────────────────────────────────
|
||||
* 学生用手机流量传一张 3MB 的原图:慢、容易失败。而且**上传这一步发生在服务端
|
||||
* 压缩之前** —— 服务端压得再好,那 3MB 也已经从学生的流量里走完了。
|
||||
* 前端先压到几百 KB,上传时间大致降到 1/4。
|
||||
*
|
||||
* ── ⚠️ 最容易踩的坑:重绘必须同时旋正 ──────────────────────
|
||||
* 手机竖着拍时,**文件里的像素其实是横的**,外加一个 EXIF `Orientation=6`
|
||||
* (意思是"显示时顺时针转 90°")。浏览器认这个标记,所以学生自己看着是正的。
|
||||
*
|
||||
* 而 canvas / `compressImage` 都会**重编码并丢掉 EXIF** —— 只压不转,出来的
|
||||
* 就是一张没有标记的躺倒图,**机构看到的是一张转了 90° 的身份证**。
|
||||
* 这个坑服务端刚踩过一次(见 docs/交接摘要.md §一·二十),前端不能再踩第二次。
|
||||
*
|
||||
* 所以两端策略不同,且**这个差别是刻意的**:
|
||||
* · **H5**:自己读 EXIF、自己在 canvas 里转正 —— 完整能力,且可自动化验证。
|
||||
* · **小程序**:`getImageInfo().orientation` 不是 `'up'` 时**不压**,直接传原图,
|
||||
* 交给服务端(服务端已经会正确转正)。理由:小程序 canvas 的旋正行为我在
|
||||
* 真机上无法验证,**宁可慢一点,也不能赌"机构收到的身份证是正的"**。
|
||||
*
|
||||
* ── 与服务端的配合(这条决定了参数怎么取)───────────────────
|
||||
* 服务端收到上传件后会判断"是不是已经达标"(没触发缩放 && 没有 EXIF 需转正 &&
|
||||
* 体积在上限内)→ **原样收下,不做二次编码**(见 `FileProcessService.compress`)。
|
||||
* 所以前端压出来的东西必须**正好落在服务端的那套目标里**:长边、质量阶梯、
|
||||
* 体积上限与 `FileProcessService` 同一口径 ——
|
||||
* 前端压得比服务端狠 = 白掉一次画质;比服务端轻 = 服务端再压一次,等于白干。
|
||||
*
|
||||
* > 后端档位常量见 `FileProcessService`:P2 长边 1600、P3 长边 3508、
|
||||
* > 质量阶梯 {88,85,80,75,70,65}、质量下限 `max(minQuality, 60)`。
|
||||
*/
|
||||
|
||||
/** 压缩目标。由材料项的档位推导(见 `planFor`)。 */
|
||||
export interface CompressPlan {
|
||||
/**
|
||||
* 长边上限(px)。`null` = 本档位不缩尺寸,只降质量。
|
||||
* 与服务端同名常量保持一致。
|
||||
*/
|
||||
longSide: number | null
|
||||
/** 起始质量(JPEG)。 */
|
||||
quality: number
|
||||
/** 质量下限。低于它就不再降质量,改为折中放弃前端压缩。 */
|
||||
floor: number
|
||||
/** 体积上限(字节)。**硬要求**,与服务端的 `maxSizeKb` 同一口径。 */
|
||||
maxBytes: number
|
||||
/** 给日志/排查用的档位说明。 */
|
||||
label: string
|
||||
/** 跳过前端压缩的原因;`null` = 应该压。 */
|
||||
skip: string | null
|
||||
}
|
||||
|
||||
/** 压缩结果。**永远不抛异常**:压不动就如实返回原路径,由服务端兜底。 */
|
||||
export interface CompressOutcome {
|
||||
/** 拿来上传的路径。压缩成功是新文件;跳过/失败时是原路径。 */
|
||||
path: string
|
||||
/** 是否真的压过。 */
|
||||
compressed: boolean
|
||||
/** 压前字节数(拿不到时为 0)。 */
|
||||
beforeBytes: number
|
||||
/** 压后字节数(没压时等于压前;拿不到时为 0)。 */
|
||||
afterBytes: number
|
||||
/** 压缩后的像素尺寸(没压时为 0)。 */
|
||||
width: number
|
||||
/** 压缩后的像素尺寸(没压时为 0)。 */
|
||||
height: number
|
||||
/** 源文件的 EXIF 朝向(1 = 正常)。 */
|
||||
orientation: number
|
||||
/** 一句话说明发生了什么 —— 会写进日志,也用于界面上如实告知学生。 */
|
||||
note: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为 H5 环境。
|
||||
*
|
||||
* <p>用**运行期判断**而不是 `#ifdef` 条件编译:条件编译要靠重复声明变量来分叉,
|
||||
* 会让 `vue-tsc` 报"重复声明"(`config/index.ts` 里就有两条这样的历史遗留报错)。
|
||||
* 代价是两个分支的实现都会进包(几 KB),换来的是类型干净、代码好读。
|
||||
*/
|
||||
const IS_H5 = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
|
||||
/**
|
||||
* 质量阶梯。服务端是 `{88,85,80,75,70,65}`(6 档 + 缩尺寸兜底)。
|
||||
*
|
||||
* <p>前端取其中几档就够:每次编码学生都在等,而且**前端压不到目标时有退路**
|
||||
* (传原图、由服务端压)。前端的目标是"一次压到位",不是"穷尽所有档位"。
|
||||
*/
|
||||
const QUALITY_STEPS = [88, 82, 76, 68]
|
||||
|
||||
/** 长边(px)。与服务端 `FileProcessService` 的 P2/P3 常量必须一致。 */
|
||||
const P2_MAX_LONG_SIDE = 1600
|
||||
const P3_MAX_LONG_SIDE = 3508
|
||||
|
||||
/**
|
||||
* 按材料项的压缩档推导前端压缩目标。
|
||||
*
|
||||
* <p>**档位来自后端配置,不写死材料码** —— 材料码对应的档位是管理员可改的
|
||||
* (`collect_material_def.compress_profile`),前端要是按 `IDCARD`/`PHOTO1`
|
||||
* 之类的码去猜,管理员一改配置前端就错,而且错得悄无声息。
|
||||
*
|
||||
* @param profile 材料项的 `compressProfile`(`AUTO`/`P1`~`P5`)
|
||||
* @param maxSizeKb 材料项的 `maxSizeKb`
|
||||
* @param minQuality 材料项的 `minQuality`
|
||||
* @returns 目标是 `null` 表示**前端一律不压**(见各分支的注释)
|
||||
*/
|
||||
export function planFor(
|
||||
profile?: string | null,
|
||||
maxSizeKb?: number | null,
|
||||
minQuality?: number | null
|
||||
): CompressPlan | null {
|
||||
const maxBytes = (maxSizeKb && maxSizeKb > 0 ? maxSizeKb : 1024) * 1024
|
||||
const floor = Math.max(minQuality && minQuality > 0 ? minQuality : 70, 60)
|
||||
const p = (profile || 'AUTO').toUpperCase()
|
||||
|
||||
if (p === 'P1') {
|
||||
// 一寸/两寸证件照:服务端要按 295×413 裁切并**补白底**,前端做不了这件事。
|
||||
// 硬压只会把学生的原照片压小,服务端再裁出来的头像是糊的。
|
||||
return null
|
||||
}
|
||||
if (p === 'P4') {
|
||||
// 截图/文档类:服务端的关键处理是**保持 PNG**(转 JPG 会把表格线糊掉),
|
||||
// 而 canvas 只能出 JPEG —— 前端压就等于毁掉这一档的意义。
|
||||
return null
|
||||
}
|
||||
if (p === 'P2') {
|
||||
return { longSide: P2_MAX_LONG_SIDE, quality: QUALITY_STEPS[0], floor, maxBytes, label: 'P2 证件翻拍', skip: null }
|
||||
}
|
||||
if (p === 'P3') {
|
||||
// 扫描件:长边上限很宽(3508 ≈ A4@300dpi),前端的收益主要在"降质量",
|
||||
// 尺寸几乎不动 —— 千万别拿 P2 的 1600 去套,扫描件缩到 1600 就看不清字了。
|
||||
return { longSide: P3_MAX_LONG_SIDE, quality: QUALITY_STEPS[0], floor, maxBytes, label: 'P3 扫描件', skip: null }
|
||||
}
|
||||
// AUTO / P5 / 其它未知值:与服务端同口径 —— **不缩尺寸**,只降质量。
|
||||
// 未知档位下擅自缩尺寸是危险的(万一它是文档类,缩完就不可读了)。
|
||||
return { longSide: null, quality: QUALITY_STEPS[0], floor, maxBytes, label: 'AUTO 通用', skip: null }
|
||||
}
|
||||
|
||||
// ── EXIF ────────────────────────────────────────────────
|
||||
//
|
||||
// 只读一个 tag(`Orientation` = 0x0112),所以自己解析 JPEG 的 APP1 段,
|
||||
// 不引第三方库。逻辑与后端 `ExifOrientation.java` **逐条对应** ——
|
||||
// 两端读同一张图必须得到同一个结论,否则会出现"前端转一次、后端再转一次"。
|
||||
|
||||
/** 从 JPEG 字节里读 EXIF 朝向。读不到一律返回 1(=不转)。 */
|
||||
export function readExifOrientation(buf: ArrayBuffer): number {
|
||||
try {
|
||||
const b = new Uint8Array(buf)
|
||||
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return 1
|
||||
let i = 2
|
||||
while (i + 4 <= b.length) {
|
||||
if (b[i] !== 0xff) return 1
|
||||
const marker = b[i + 1]
|
||||
if (marker === 0xff) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// SOS 之后是压缩数据,不会再有元数据段
|
||||
if (marker === 0xda || marker === 0xd9) return 1
|
||||
const len = (b[i + 2] << 8) | b[i + 3]
|
||||
if (len < 2) return 1
|
||||
const segStart = i + 4
|
||||
const segEnd = i + 2 + len
|
||||
if (segEnd > b.length) return 1
|
||||
if (
|
||||
marker === 0xe1 &&
|
||||
segEnd - segStart >= 14 &&
|
||||
b[segStart] === 0x45 && b[segStart + 1] === 0x78 && // 'E' 'x'
|
||||
b[segStart + 2] === 0x69 && b[segStart + 3] === 0x66 && // 'i' 'f'
|
||||
b[segStart + 4] === 0 && b[segStart + 5] === 0
|
||||
) {
|
||||
const o = readTiffOrientation(b, segStart + 6, segEnd)
|
||||
if (o >= 1 && o <= 8) return o
|
||||
}
|
||||
i = segEnd
|
||||
}
|
||||
} catch {
|
||||
// 读不出来就当"正常" —— **宁可保持原样,也不要凭猜测把图转错**
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function readTiffOrientation(b: Uint8Array, start: number, end: number): number {
|
||||
if (start + 8 > end) return 1
|
||||
let little: boolean
|
||||
if (b[start] === 0x49 && b[start + 1] === 0x49) little = true
|
||||
else if (b[start] === 0x4d && b[start + 1] === 0x4d) little = false
|
||||
else return 1
|
||||
if (u16(b, start + 2, little) !== 0x002a) return 1
|
||||
const ifd = start + u32(b, start + 4, little)
|
||||
if (ifd + 2 > end) return 1
|
||||
const count = u16(b, ifd, little)
|
||||
for (let k = 0; k < count; k++) {
|
||||
const e = ifd + 2 + k * 12
|
||||
if (e + 12 > end) return 1
|
||||
if (u16(b, e, little) === 0x0112) {
|
||||
// 类型 3 = SHORT,值直接放在前两字节
|
||||
return u16(b, e + 8, little)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function u16(b: Uint8Array, i: number, little: boolean): number {
|
||||
return little ? (b[i] & 0xff) | ((b[i + 1] & 0xff) << 8) : ((b[i] & 0xff) << 8) | (b[i + 1] & 0xff)
|
||||
}
|
||||
|
||||
function u32(b: Uint8Array, i: number, little: boolean): number {
|
||||
return little
|
||||
? (b[i] & 0xff) | ((b[i + 1] & 0xff) << 8) | ((b[i + 2] & 0xff) << 16) | ((b[i + 3] & 0xff) << 24)
|
||||
: ((b[i] & 0xff) << 24) | ((b[i + 1] & 0xff) << 16) | ((b[i + 2] & 0xff) << 8) | (b[i + 3] & 0xff)
|
||||
}
|
||||
|
||||
/**
|
||||
* 朝向会互换宽高(5~8 是转置类)。
|
||||
*
|
||||
* <p>转正后的尺寸必须**先算对再开画布**:canvas 一旦定了尺寸就不能改,
|
||||
* 而"先按原尺寸画、再转"会得到一张被裁掉一半的图。
|
||||
*/
|
||||
function rotatedSize(w: number, h: number, o: number): { w: number; h: number } {
|
||||
return o >= 5 && o <= 8 ? { w: h, h: w } : { w, h }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把源图画到 2D 上下文里,**同时完成"按 EXIF 转正"和"缩到目标尺寸"**。
|
||||
*
|
||||
* <p>八种朝向用 canvas 的标准变换矩阵,与后端 `ExifOrientation.apply` 的
|
||||
* 逐像素索引映射**等价**(后端的 case 6 是 `sx=y, sy=h-1-x`,
|
||||
* 这里 case 6 是 `transform(0,1,-1,0,h,0)` —— 把目标点代进去是同一件事)。
|
||||
*
|
||||
* <p>⚠️ `o` 必须是**"还要不要再转"**,不是文件里的 EXIF 值:现代浏览器已经
|
||||
* 按 EXIF 转正过了,此时传 1(不再转)。判断逻辑见 `compressOnH5`。
|
||||
* 传错就是"转两次"或"一次没转" —— 两种都是一张躺倒的身份证。
|
||||
*/
|
||||
function drawRotated(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: CanvasImageSource,
|
||||
w: number,
|
||||
h: number,
|
||||
o: number,
|
||||
scale: number
|
||||
): void {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(scale, scale)
|
||||
switch (o) {
|
||||
case 2: ctx.transform(-1, 0, 0, 1, w, 0); break
|
||||
case 3: ctx.transform(-1, 0, 0, -1, w, h); break
|
||||
case 4: ctx.transform(1, 0, 0, -1, 0, h); break
|
||||
case 5: ctx.transform(0, 1, 1, 0, 0, 0); break
|
||||
case 6: ctx.transform(0, 1, -1, 0, h, 0); break
|
||||
case 7: ctx.transform(0, -1, -1, 0, h, w); break
|
||||
case 8: ctx.transform(0, -1, 1, 0, 0, w); break
|
||||
default: break
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
}
|
||||
|
||||
/** 由长边上限推算缩放比。`null` = 不缩。 */
|
||||
function scaleFor(w: number, h: number, longSide: number | null): number {
|
||||
if (!longSide) return 1
|
||||
const cur = Math.max(w, h)
|
||||
return cur <= longSide ? 1 : longSide / cur
|
||||
}
|
||||
|
||||
// ── H5 实现 ─────────────────────────────────────────────
|
||||
//
|
||||
// 用浏览器原生 canvas,而不是 `uni.compressImage`:H5 的 `uni.compressImage`
|
||||
// 在部分端上不支持,而且它给的 `quality` 语义各端不一致;
|
||||
// 原生 canvas 的 `toBlob(quality)` 行为是确定的,也能顺手把 EXIF 转正做掉。
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('图片读取失败'))
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function toBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
// 不用 toDataURL:base64 会比原图大 33%,上传前白白多出一截内存和体积
|
||||
canvas.toBlob((b) => resolve(b), 'image/jpeg', quality / 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JPEG 的 SOF 段读**文件中原始的像素尺寸**(不含 EXIF 朝向)。读不到返回 null。
|
||||
*
|
||||
* <p>为什么需要它:见 `compressOnH5` 里那段"浏览器到底替我们转了没有"的判断 ——
|
||||
* 判断依据是"浏览器给的尺寸"与"文件里的原始尺寸"是否互换,
|
||||
* 所以必须能直接读出原始尺寸,不能从浏览器给的结果反推(反推不出来)。
|
||||
*/
|
||||
export function readJpegSize(buf: ArrayBuffer): { w: number; h: number } | null {
|
||||
try {
|
||||
const b = new Uint8Array(buf)
|
||||
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null
|
||||
let i = 2
|
||||
while (i + 4 <= b.length) {
|
||||
if (b[i] !== 0xff) return null
|
||||
const marker = b[i + 1]
|
||||
if (marker === 0xff) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (marker === 0xd9 || marker === 0xda) return null
|
||||
const len = (b[i + 2] << 8) | b[i + 3]
|
||||
if (len < 2) return null
|
||||
const segEnd = i + 2 + len
|
||||
if (segEnd > b.length) return null
|
||||
// SOF0~SOF15(C0~CF),但 C4=DHT、C8=JPG、CC=DAC 不是尺寸段
|
||||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||
if (i + 9 > b.length) return null
|
||||
const h = (b[i + 5] << 8) | b[i + 6]
|
||||
const w = (b[i + 7] << 8) | b[i + 8]
|
||||
return w > 0 && h > 0 ? { w, h } : null
|
||||
}
|
||||
i = segEnd
|
||||
}
|
||||
} catch {
|
||||
// 读不出来就退回"按浏览器已转正处理"(现代浏览器的默认行为)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function compressOnH5(filePath: string, plan: CompressPlan): Promise<CompressOutcome> {
|
||||
const resp = await fetch(filePath)
|
||||
const buf = await resp.arrayBuffer()
|
||||
const beforeBytes = buf.byteLength
|
||||
const orientation = readExifOrientation(buf)
|
||||
|
||||
const img = await loadImage(filePath)
|
||||
const dw = img.naturalWidth || img.width
|
||||
const dh = img.naturalHeight || img.height
|
||||
if (!dw || !dh) {
|
||||
return miss(filePath, beforeBytes, orientation, '读不到图片尺寸')
|
||||
}
|
||||
|
||||
// ★ 浏览器到底替我们转了没有 —— **必须量,不能猜**。
|
||||
//
|
||||
// 实测(Chromium,2026):文件里 3200×2400 + Orientation=6,
|
||||
// 而 `img.naturalWidth/Height` 给的是 **2400×3200** —— 也就是说现代浏览器
|
||||
// **已经按 EXIF 转正**,再交给 `drawImage` 的就是转正后的像素;
|
||||
// 此时我们要是再转一次,出来就是一张**躺倒的图**(这个 bug 真的出现过:
|
||||
// 修复前输出 1600×1200,修复后 1200×1600)。
|
||||
//
|
||||
// 而老实现(旧 X5 / 旧 WKWebView)给的是**原始像素**,不转就是躺倒的。
|
||||
// 两端行为相反,**任何写死的做法都必错一半**,所以拿尺寸对比来判定:
|
||||
// · 浏览器给的尺寸与文件原始尺寸**互换** → 它已经转过了 → 不要转
|
||||
// · 两者**相同** → 它给的是原始像素 → 我们自己转
|
||||
// · 都对不上(非 JPEG、或尺寸被改过)→ 按现代浏览器的默认(已转正)处理
|
||||
//
|
||||
// 注:正方形图(原始宽高相等)两种情况分辨不出来,此时同样按"已转正"处理 ——
|
||||
// 现代浏览器下这是对的,而正方形带朝向标记的图本身极罕见(一般是裁剪图)。
|
||||
const raw = readJpegSize(buf)
|
||||
let drawOrientation = 1
|
||||
if (orientation > 1 && raw) {
|
||||
if (dw === raw.h && dh === raw.w && raw.w !== raw.h) {
|
||||
drawOrientation = 1
|
||||
} else if (dw === raw.w && dh === raw.h) {
|
||||
drawOrientation = orientation
|
||||
}
|
||||
}
|
||||
|
||||
// 呈现尺寸:浏览器已转正时就是它给的尺寸;要我们自己转时是互换后的
|
||||
const shown = drawOrientation > 1 ? rotatedSize(dw, dh, drawOrientation) : { w: dw, h: dh }
|
||||
const needScale = scaleFor(shown.w, shown.h, plan.longSide) < 1
|
||||
|
||||
// 已经达标 → **原样上传**,一个字节都不重编码。
|
||||
// 服务端也因此走"原样收下"那条路(见模块头注释)。
|
||||
if (orientation <= 1 && !needScale && beforeBytes <= plan.maxBytes) {
|
||||
return miss(filePath, beforeBytes, orientation, '本来就达标,未改动')
|
||||
}
|
||||
|
||||
// 长边缩两次还不够就放弃(宁可让学生多等一次上传,也不要交一张糊图)
|
||||
const scales = [scaleFor(shown.w, shown.h, plan.longSide), 0.8, 0.64]
|
||||
let lastBytes = 0
|
||||
let lastSize = { w: 0, h: 0 }
|
||||
|
||||
for (let si = 0; si < scales.length; si++) {
|
||||
const scale = si === 0 ? scales[0] : Math.min(scales[si], scales[si - 1])
|
||||
const tw = Math.max(1, Math.round(shown.w * scale))
|
||||
const th = Math.max(1, Math.round(shown.h * scale))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = tw
|
||||
canvas.height = th
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return miss(filePath, beforeBytes, orientation, '拿不到 canvas 上下文')
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
drawRotated(ctx, img, dw, dh, drawOrientation, scale)
|
||||
|
||||
for (const q of QUALITY_STEPS) {
|
||||
if (q < plan.floor) break
|
||||
const blob = await toBlob(canvas, q)
|
||||
if (!blob) break
|
||||
lastBytes = blob.size
|
||||
lastSize = { w: tw, h: th }
|
||||
if (blob.size <= plan.maxBytes) {
|
||||
return {
|
||||
path: URL.createObjectURL(blob),
|
||||
compressed: true,
|
||||
beforeBytes,
|
||||
afterBytes: blob.size,
|
||||
width: tw,
|
||||
height: th,
|
||||
orientation,
|
||||
note: `${plan.label}:${kb(beforeBytes)} → ${kb(blob.size)}(${tw}×${th},q${q}`
|
||||
+ `${drawOrientation > 1 ? ',已按 EXIF 转正' : ',浏览器已转正'})`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 压不动:传原图,让服务端那套"质量到底 + 缩尺寸"的重手段去处理。
|
||||
// **不能在这里报错** —— 学生什么也没做错,而服务端本来就能压下来。
|
||||
return {
|
||||
path: filePath,
|
||||
compressed: false,
|
||||
beforeBytes,
|
||||
afterBytes: beforeBytes,
|
||||
width: lastSize.w,
|
||||
height: lastSize.h,
|
||||
orientation,
|
||||
note: `压到 ${kb(lastBytes)} 仍超过 ${kb(plan.maxBytes)},交服务端处理`
|
||||
}
|
||||
}
|
||||
|
||||
// ── 小程序实现 ───────────────────────────────────────────
|
||||
//
|
||||
// 只用 `compressImage`,**不做 canvas 旋正**:真机上的行为无法验证,
|
||||
// 而"转错了"的后果是机构收到躺倒的身份证。所以:
|
||||
// · orientation === 'up' → 放心压(无需转正,重编码不会改变朝向,即便丢掉 EXIF)
|
||||
// · 其它朝向 → 不压,传原图,由服务端转正 + 压缩
|
||||
|
||||
interface MpImageInfo {
|
||||
width: number
|
||||
height: number
|
||||
orientation?: string
|
||||
}
|
||||
|
||||
function mpImageInfo(src: string): Promise<MpImageInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.getImageInfo({
|
||||
src,
|
||||
success: (r) => resolve({ width: r.width, height: r.height, orientation: (r as { orientation?: string }).orientation }),
|
||||
fail: () => reject(new Error('图片读取失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function mpCompress(src: string, quality: number, longSide: number | null, w: number, h: number): Promise<string> {
|
||||
const opt: Record<string, unknown> = { src, quality }
|
||||
// 只给**长边那一维**,另一维由微信按比例算 —— 同时给两维有把图拉变形的风险
|
||||
if (longSide && Math.max(w, h) > longSide) {
|
||||
if (w >= h) opt.compressedWidth = longSide
|
||||
else opt.compressedHeight = longSide
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.compressImage({
|
||||
...(opt as unknown as UniApp.CompressImageOptions),
|
||||
success: (r) => resolve(r.tempFilePath),
|
||||
fail: () => reject(new Error('压缩失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function mpFileSize(path: string): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
uni.getFileSystemManager().getFileInfo({
|
||||
filePath: path,
|
||||
success: (r) => resolve(r.size || 0),
|
||||
fail: () => resolve(0)
|
||||
})
|
||||
} catch {
|
||||
resolve(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function compressOnMp(filePath: string, plan: CompressPlan): Promise<CompressOutcome> {
|
||||
const before = await mpFileSize(filePath)
|
||||
const info = await mpImageInfo(filePath)
|
||||
const o = orientationFromMp(info.orientation)
|
||||
const shown = rotatedSize(info.width, info.height, o)
|
||||
|
||||
if (o > 1) {
|
||||
return {
|
||||
path: filePath,
|
||||
compressed: false,
|
||||
beforeBytes: before,
|
||||
afterBytes: before,
|
||||
width: 0,
|
||||
height: 0,
|
||||
orientation: o,
|
||||
note: '照片需要转正:小程序端不压,交服务端处理(服务端会按 EXIF 转正)'
|
||||
}
|
||||
}
|
||||
if (!plan.longSide && before > 0 && before <= plan.maxBytes) {
|
||||
return miss(filePath, before, o, '本来就达标,未改动')
|
||||
}
|
||||
|
||||
let last = filePath
|
||||
let lastSize = before
|
||||
for (const q of QUALITY_STEPS) {
|
||||
if (q < plan.floor) break
|
||||
try {
|
||||
const out = await mpCompress(filePath, q, plan.longSide, info.width, info.height)
|
||||
const size = await mpFileSize(out)
|
||||
last = out
|
||||
lastSize = size
|
||||
if (size <= 0 || size <= plan.maxBytes) {
|
||||
return {
|
||||
path: out,
|
||||
compressed: true,
|
||||
beforeBytes: before,
|
||||
afterBytes: size,
|
||||
width: shown.w,
|
||||
height: shown.h,
|
||||
orientation: o,
|
||||
note: `${plan.label}:${kb(before)} → ${size > 0 ? kb(size) : '未知'}(q${q})`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
compressed: false,
|
||||
beforeBytes: before,
|
||||
afterBytes: before,
|
||||
width: 0,
|
||||
height: 0,
|
||||
orientation: o,
|
||||
note: `压到 ${kb(lastSize)} 仍偏大,交服务端处理`
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信 `getImageInfo().orientation` → EXIF 朝向数字(未识别按 1 = 正常)。 */
|
||||
function orientationFromMp(o?: string): number {
|
||||
switch (o) {
|
||||
case 'up': return 1
|
||||
case 'down': return 3
|
||||
case 'left': return 8
|
||||
case 'right': return 6
|
||||
case 'up-mirrored': return 2
|
||||
case 'down-mirrored': return 4
|
||||
case 'left-mirrored': return 5
|
||||
case 'right-mirrored': return 7
|
||||
default: return 1
|
||||
}
|
||||
}
|
||||
|
||||
// ── 对外入口 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 压到能直接上传的程度。
|
||||
*
|
||||
* <p>**这个函数永远不抛异常**:任何一步失败都退化成"传原图、由服务端压"。
|
||||
* 学生在等着交材料,前端压缩只是优化,绝不该成为新的失败点。
|
||||
*/
|
||||
export async function compressForUpload(filePath: string, plan: CompressPlan): Promise<CompressOutcome> {
|
||||
if (plan.skip) {
|
||||
return miss(filePath, 0, 1, plan.skip)
|
||||
}
|
||||
try {
|
||||
return IS_H5 ? await compressOnH5(filePath, plan) : await compressOnMp(filePath, plan)
|
||||
} catch (e) {
|
||||
return miss(filePath, 0, 1, `前端压缩没走通(${e instanceof Error ? e.message : '未知原因'}),传原图由服务端处理`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放压缩产生的临时对象地址。
|
||||
*
|
||||
* <p>H5 的 `URL.createObjectURL` 不释放会一直占着内存(学生连传 10 张材料
|
||||
* 就是 10 份几百 KB 的 blob 留在内存里)。小程序没有这个 API,空实现。
|
||||
*/
|
||||
export function releaseCompressedPath(path: string, outcome: CompressOutcome): void {
|
||||
if (IS_H5 && outcome.compressed && path && path.indexOf('blob:') === 0) {
|
||||
try {
|
||||
URL.revokeObjectURL(path)
|
||||
} catch {
|
||||
// 释放失败无所谓,不能因此让上传流程报错
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function miss(path: string, bytes: number, orientation: number, note: string): CompressOutcome {
|
||||
return {
|
||||
path,
|
||||
compressed: false,
|
||||
beforeBytes: bytes,
|
||||
afterBytes: bytes,
|
||||
width: 0,
|
||||
height: 0,
|
||||
orientation,
|
||||
note
|
||||
}
|
||||
}
|
||||
|
||||
function kb(n: number): string {
|
||||
if (!n) return '0KB'
|
||||
return n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + 'MB' : Math.round(n / 1024) + 'KB'
|
||||
}
|
||||
Reference in New Issue
Block a user