概览

打印电商的”用户传图”不是简单的 <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 × heightfit,前端按比例自动算 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 };
}
// contain / fill ...
}

手动裁剪

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 线程

下一步