概览
打印电商的”用户传图”不是简单的 <input type="file">——它要解决:
- 单张超 100MB 的高清图怎么不卡死浏览器
- 一次几十张相册图怎么稳上传不丢
- 移动端弱网怎么续传
- 上传后怎么对齐到模板里的图片框
- 人脸怎么不被裁掉
图模工坊把这套流程封装成一条”用户操作 → 前端处理 → 直传 MinIO → 写库”的管线。
整体管线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| 用户点"上传" / 拍照 / 多选 │ ▼ ┌───────────────────────────────────────┐ │ ImageUploadSheet.vue (workshop/) │ │ ├─ 拍照 / 相册 / 批量 │ │ └─ Upload.ts: 队列、并发、状态 │ └───────────────────────────────────────┘ │ ▼ 单图 ┌───────────────────────────────────────┐ │ 前端预处理 │ │ ├─ 校验格式 (JPG/PNG/TIFF) │ │ ├─ 校验大小 (≤200MB) │ │ ├─ 大图压缩(非原图模式) │ │ ├─ EXIF 旋转校正 │ │ └─ (可选)ImageCropper 手动裁剪 │ └───────────────────────────────────────┘ │ ▼ 分片 ┌───────────────────────────────────────┐ │ pf-service: /shared/oss/upload │ │ ├─ 申请预签名 PutObject URL │ │ └─ 返回 { uploadUrl, fileKey } │ └───────────────────────────────────────┘ │ ▼ PUT 分片 ┌───────────────────────────────────────┐ │ MinIO (S3 协议) │ │ ├─ 分片合并 │ │ └─ 返回最终 URL │ └───────────────────────────────────────┘ │ ▼ 写回前端 userUploads[instanceKey][elementKey] = { url } │ ▼ 可选:faceSafe 节点 ┌───────────────────────────────────────┐ │ /shared/face/detect │ │ → 人脸框 │ │ → 重新算 cropBox │ └───────────────────────────────────────┘ │ ▼ Leafer 实时重绘 → 用户预览
|
上传模式
自适应裁剪(默认)
模板节点有 width × height 和 fit,前端按比例自动算 cropBox:
1 2 3 4 5 6 7 8 9 10
| function autoCrop(srcW, srcH, dstW, dstH, mode = "cover") { const srcRatio = srcW / srcH; const dstRatio = dstW / dstH; if (mode === "cover") { return srcRatio > dstRatio ? { w: srcH * dstRatio, h: srcH, x: (srcW - srcH * dstRatio) / 2, y: 0 } : { w: srcW, h: srcW / dstRatio, x: 0, y: (srcH - srcW / dstRatio) / 2 }; } }
|
手动裁剪
ImageCropper.vue 基于 Cropper.js 1.6:
- 触控拖动调整裁剪框
- 双指缩放
- 旋转 90° / 翻转
- 实时预览输出尺寸
- 提交时上传裁剪后的 blob
批量上传(相册场景)
Upload.ts 管理一个并发队列:
- 默认 3 并发
- 单图失败自动重试 3 次
- 任意一张失败不阻塞其它
- 全部成功后按模板顺序绑定到相册的内页模板
原图模式
跳过压缩、裁剪、EXIF 修正,原始文件直接分片上传。适用于:
- 高清海报
- 专业写真
- 用户明确要”我自己处理过的图,别再动”
分片上传
走 S3 协议的 multipart upload:
1 2 3 4 5 6 7 8 9 10
| 1. 前端 POST /shared/oss/multipart/initiate { fileName, mimeType } ← { uploadId, fileKey }
2. 前端按 5MB / 片切分,逐片 PUT 到 MinIO 每片完成后记录 ETag
3. 前端 POST /shared/oss/multipart/complete { uploadId, fileKey, parts: [{ partNumber, etag }, ...] } ← { url: "https://minio/.../filekey" }
4. 失败可断点续传:再调 initiate 不带 uploadId,复用 fileKey 重传未完成的 part
|
当前路由位于 apps/pf-service/src/routes/projects/shared/ossRoutes.ts,按需调整 part size / 并发数。
人脸避让
模板节点带 faceSafe: true 时,用户上传完成后前端额外做一次:
1 2 3 4 5 6 7 8 9
| 1. 拿到上传后的 imageUrl 2. POST /shared/face/detect { imageUrl } ← { faces: [{ x, y, w, h, score }, ...] } 归一化坐标 3. 计算"包含主要人脸"的最小窗口 4. 若窗口大于节点的目标比例 → 居中扩展到目标比例 5. 否则按目标比例从人脸中心扩展 6. 把得到的 cropBox 写入 userUploads[*][*].cropBox(可选字段) 7. Leafer 按 cropBox 渲染预览 8. 提交时连 cropBox 一起上送;后端合成时同样消费
|
后端在合成阶段对带 faceSafe 的节点会再校验一次——前端可能拿的是旧人脸结果,后端兜底再调一次 face-service。
字体的”上传管线”
字体不归用户传,但治理逻辑类似:
- 管理员在
pf-manage 上传 ttf/otf → 后端转 woff2 → 入 font_variants
- 前端按需
FontFace.load() 注入
- 合成时 Puppeteer 也走同一份 URL 加载,保证字形一致
容错策略
| 失败场景 |
处理 |
| 文件超大 |
前端拒绝并提示 |
| 格式不支持 |
前端拒绝并提示(白名单:jpg/png/tiff) |
| 单片 PUT 失败 |
自动重试 3 次,仍失败 → 整图标失败但其它图继续 |
| 网络中断 |
草稿持久化 + 断点续传 |
| face-service 不可用 |
退化为普通自适应裁剪,不阻塞用户提交 |
| 提交时素材 URL 失效 |
合成 Job 标 failReason=素材 URL 不可访问,管理员重传后重试 |
性能要点
- 预压缩阈值:移动端默认对超 5MB 的图先做有损压缩到 ≤ 5MB(原图模式跳过)
- 并发数:3 并发是 H5 + MinIO 的甜蜜点;上 10 会触发浏览器底层限速
- chunk size:5MB 是 S3 multipart 的下限,也避开 MinIO 单 chunk 复制内存峰值
- Worker:图片压缩走 OffscreenCanvas / Web Worker,避免阻塞 UI 线程
下一步