图像裁剪旋转与滤镜
图像编辑器是 处理型表面 + 预览型表面。滤镜和旋转在离屏完成,视口只 blit。不要对正在手势缩放的那张可见 canvas getImageData。
1. 文档
export interface EditState {
source: ImageBitmap;
/** 相对 source 像素 */
crop: { x: number; y: number; w: number; h: number };
/** 90° 的倍数,顺时针 */
quarterTurns: 0 | 1 | 2 | 3;
/** CSS filter 子集,导出时烘焙 */
filter: string;
}
非 90° 自由旋转要插值,导出尺寸按 AABB 重算。产品没要求就只提供 90°,避免质量扯皮。
EXIF orientation:createImageBitmap / <img> 在各浏览器是否自动尊重 orientation 不一致。上传管线应在解码前用库剥 EXIF,或固定「按像素阵列显示」,并在 UI 标明。不要假设 iPhone 照片方向一定对。
2. 离屏烘焙
export async function rasterizeEdit(state: EditState): Promise<ImageBitmap> {
const { crop, quarterTurns, source, filter } = state;
const swapped = quarterTurns % 2 === 1;
const outW = swapped ? crop.h : crop.w;
const outH = swapped ? crop.w : crop.h;
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext("2d", { willReadFrequently: false });
if (!ctx) throw new Error("2d unavailable");
ctx.filter = filter || "none";
ctx.translate(outW / 2, outH / 2);
ctx.rotate((quarterTurns * Math.PI) / 2);
ctx.drawImage(
source,
crop.x,
crop.y,
crop.w,
crop.h,
-crop.w / 2,
-crop.h / 2,
crop.w,
crop.h,
);
return canvas.transferToImageBitmap();
}
预览:手势中只画「源 + crop 框 + CSS transform 模拟旋转」,松手或点应用再 rasterizeEdit。实时 ctx.filter 全图 blur 会打满主线程。
直方图、曲线:必须 willReadFrequently: true 的小图(先缩小再采样),不要对 4k 原图每帧 getImageData。
3. 裁剪交互
crop 矩形在图像像素空间。手柄逻辑同 框选。Shift 锁比例。双击原图重置 crop 为全图。
输出尺寸封顶:Math.min(crop.w, maxEdge),移动端导出 4k×4k×4 字节会杀页。超限先缩再滤镜。
4. 失败形态
| 症状 | 原因 |
|---|---|
| 旋转后裁切空了 | 仍按未旋转的 crop 去 draw |
| iPhone 图倒了 | EXIF 未处理 |
| 预览流畅导出发糊 | 预览用了 CSS 缩放的低清层当源 |
| 调滤镜卡死 | 在视图 canvas 全分辨率 getImageData |
| JPEG 黑边 | 透明 + JPEG,见导出章 |
权威资料
- createImageBitmap()
- CanvasRenderingContext2D.filter
- EXIF orientation 与浏览器(
imageOrientation在 img/CSS 侧,canvas 解码仍要自己验证)
核对日期:2026-08-26