最朴素的云端看图实现是这样的:拿对象的预签名 URL,塞进 <img src>,让浏览器去下载和解码。小图没问题,但一张 6000×4000、40 MB 的原图这么搞,webview 要下载整整 40 MB、再全分辨率解码到内存——滚动卡顿、切图白屏、内存飙升。网格视图更糟:早期实现给每张缩略图都拉一次整张原图,一屏几十张就是几十次全量下载,既慢又费流量和请求费。

Nebula 的思路很直接:重活全部交给 Rust,前端只拿小图。

分工:Rust 干像素,前端只做 UI

环节Rust(Tauri 后端)前端(React)
取云端字节provider.read(沿用凭证 / 区域路由 / 自定义域名)
解码 / 缩放 / 编码 / EXIFimage + kamadak-exif
磁盘缓存(按 ETag)原图与各变体落盘
缩放 / 平移 / 裁剪框 / 滑块交互CSS transform / 手势

核心是一个不依赖任何云的纯函数 crate nebula-image:解码 → 按 EXIF 方向摆正 → 缩到视口大小 → 编码。它可以 cargo test,不用起 GUI 就能验证。

/// 渲染一张「浏览用」的图:摆正 → 最长边缩到 max_edge(不放大)→ 编码。
pub fn render_view(bytes: &[u8], max_edge: u32) -> Result<Rendered> {
    let mut img = apply_orientation(decode(bytes)?, exif::orientation(bytes));
    let (orig_w, orig_h) = (img.width(), img.height());
    if max_edge > 0 && orig_w.max(orig_h) > max_edge {
        img = img.resize(max_edge, max_edge, FilterType::Lanczos3);
    }
    let (bytes, mime) = encode_display(&img)?; // 不透明→JPEG,带透明→PNG
    Ok(Rendered { bytes, mime, width: img.width(), height: img.height(), orig_width: orig_w, orig_height: orig_h })
}

那张 40 MB 的原图,Rust 解码一次 → 缩到视口(比如 2560px 长边)→ 编码成 ~300 KB 的 JPEG,前端秒开。放大到实际像素时再按需回原图,平时根本不用碰。

为什么快:四个加速点

  1. 服务端降采样:webview 永远只处理缩好的小图,不再啃几十 MB 原图。
  2. ETag 磁盘缓存:渲染结果按 账号 + 路径 + ETag + 变体 落盘。第二次看瞬开、离线也在、重启仍在;云端图变了(ETag 变)自动命不中旧缓存、重取。
  3. 缩略图只生成一次:网格缩略图改成 Rust 生成的小方图,替换掉「浏览器逐张下载整张原图」——又快又省流量和请求费。
  4. 解码放阻塞线程池:解码 / 缩放是 CPU 密集,用 tokio::spawn_blocking 跑,不占住异步运行时。

图怎么交给 webview?为避免大 base64 阻塞,渲染结果内联成 data: URL 直接塞 <img>;将来要更省内存还能切到 Tauri 资源协议按文件路径加载。

独立窗口,只开这一张

图片在独立的 Tauri 窗口里打开,而且只加载点开的那一张——不预载整个目录的缩略图。窗口跟随应用主题(连原生标题栏也用 setTheme 同步),缩放按光标位置、拖拽平移、旋转、EXIF 面板(相机 / 快门 / 光圈 / GPS 一键看地图)都在这里。

编辑器:前端调参,Rust 出图,存回云端

编辑是这套架构最能体现「无缝」的地方。前端把操作清单(裁剪框、旋转角、翻转、尺寸、亮度、对比度、饱和度、色温、锐化、灰度、反相)发给 Rust,Rust 在缓存的原图上应用后回一张预览。

大图编辑有个坑:每次拖滑块都重新解码 + 缩放整张原图,会卡。解法是缓存一张「编辑底图」——首次把原图缩到视口大小、存成无损 PNG,之后所有调参预览都在这张小图上做;只有保存才用全分辨率原图重新编码。裁剪是例外:它的坐标基于原图像素,预览走全分辨率。

裁剪的执行顺序也有讲究——放在旋转 / 翻转之后,坐标才对得上「你看到的图」:

// 依次:旋转 → 翻转 → 裁剪 → 亮度 → 对比度 → 灰度 → 反相
fn apply_ops(mut img: DynamicImage, ops: &Ops) -> DynamicImage { /* … */ }

前端的裁剪框有个容易做错的地方:如果把选框定位在一个「不贴合图片」的容器上,选框就会飘。正确做法是测量图片的真实屏幕矩形(getBoundingClientRect),用像素坐标把选框画在图片上,并在加载 / 窗口缩放时重新测量——这样无论图片怎么居中缩放,选框都精确贴合。

编辑操作记在一个历史栈里,支持撤销 / 重做(Cmd/Ctrl+Z,加 Shift 重做);滑块的连续微调会合并成一步,不会让一次拖动塞满几十条历史。调整尺寸作为流水线的最后一步(可锁定宽高比),裁剪、旋转这些结构性操作和亮度、对比度都进同一个栈,撤起来一致。

调色除了亮度 / 对比度,还有饱和度 / 色温 / 色相 / 锐化 / 模糊:饱和度和色温是逐像素算的(饱和度让每个通道向该像素的灰度值靠拢或远离,色温加红减蓝偏暖 / 反之偏冷),色相 / 锐化(unsharp mask)/ 模糊(高斯)直接用 image crate 的现成算子。

除了 90° 步进,还有个拉直滑块(−45°~45°)校正倾斜照片。任意角度旋转没有引第三方库,而是手写「反向映射 + 双线性插值」:对输出图的每个像素,反旋转到源坐标采样;保持画布尺寸,转出画布的边角填透明。放在翻转之后、裁剪之前——先拉直,再把透明的边角裁掉,是很自然的顺序。

标注可以在图上圈重点:自由画笔矩形框、箭头文字(可加对比色描边)、序号标记(①②③ 自动编号,教程截图常用)五种工具,配调色板、粗细和吸管取色(从图上吸颜色)。文字这块特殊:在二进制里内嵌一个中文字体不现实,所以文字走前端合成——保存时后端出全分辨率编辑图,前端用系统字体(天然支持中文)把文字画到 canvas 上再上传;其余标注仍由 Rust 烧录。标注存成相对坐标(画笔是折线,矩形 / 箭头是两点),前端画标注时用 canvas 覆盖层实时画(流畅跟手),保存才由 Rust 烧进图——画笔 / 矩形 / 箭头都归结为「圆头粗线」(沿线采样画填充圆),矩形是四条边、箭头是主线加两翼。标注模式下预览特意不烧标注,避免「前端画一遍 + 后端又烧一遍」的双重绘制与延迟闪烁。

还有马赛克打码:像画笔一样在图上涂抹(而不是框矩形),涂过的地方打码——Rust 先把整图按块像素化一份,再沿涂抹路径用圆形笔刷把像素化的像素盖上去,所以能顺着不规则形状随手涂。涂抹路径也是相对坐标,几何变换之后应用。打码是烧进保存的图里的,不是叠加层,适合上传前遮住隐私信息。

保存时选格式(JPEG / PNG / WebP)与画质,然后三个出口:覆盖原图 / 另存为新对象 / 下载到本地。前两个用 provider.write 写回同一个桶——凭证、区域路由、自定义域名全自动,7 家云一视同仁;最后一个用 tokio::fs::write 落到本地。存完自动失效缓存、刷新缩略图。

一点收获

把「解码 / 缩放 / 编码」这类 CPU 活扔给 Rust、让 webview 只当显示层,不只是快——它还让「编辑结果直接写回云端」变得顺理成章:反正字节都在 Rust 手里,编码完顺手 write 回桶即可,不用在前端和本地磁盘之间倒腾。云上的图,在云的语境里看、改、存,不落地。