yggdrasil/docs/superpowers/specs/2026-06-22-blur-up-images-design.md
xfy 987a0d9671 docs(spec): blur-up progressive image loading design
文章图片 blur-up 渐进加载设计文档,经 brainstorming 流程产出:

- 低分辨率占位图(?w=20,SSR 内嵌)+ 高清缩略图双层叠加
- 服务端读图片 header 拿真实尺寸生成 aspect-ratio(零 CLS)
- dimensions 用 moka sync cache 缓存(TTL 可配置,默认 24h)
- 三处图片位置统一(卡片封面/详情封面/正文图)
- Markdown 渲染器把 img 转双层 wrapper,post-content.js 懒加载高清
- sanitizer 扩展放行 data-src/class/style(仅文章,评论不动)
- 外链图不处理,JS 禁用时渐进降级到模糊占位图

webp 尺寸读取风险已排除(zenwebp info() 只读 header)。
验收标准 12 条。不含实现,仅设计。
2026-06-22 17:40:08 +08:00

314 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 文章图片 Blur-up 渐进加载设计
## 背景与目标
文章页面的缩略图(卡片封面 `?thumb=400x300`、详情封面 `?w=1200`、正文图 `?w=800`)在加载过程中**没有任何占位**`<img>` 裸标签无 width/height/aspect-ratio加载时区域高度为 0加载完撑开——导致 CLS布局跳动明显尤其在列表页和长文章里。
本设计的目标:
- 加载前先显示**低分辨率模糊占位图**`?w=20`),消除空白与跳动
- 加载完高清缩略图后,**平滑过渡**到清晰opacity 淡入)
- 服务端 SSR 内嵌占位图渐进增强JS 禁用也能看到占位)
- 三处图片位置(卡片封面、详情封面、正文图)统一应用
## 关键决策
| 决策点 | 选择 |
|--------|------|
| 占位图来源 | SSR 内嵌HTML 里 img src 就是占位图 URLJS 替换高清) |
| 过渡实现 | 双层叠加 + opacity 淡入(底层占位图常驻,上层高清图淡入覆盖) |
| 作用范围 | 三处都加(卡片封面、详情封面、正文图) |
| 占位图尺寸 | 统一 `?w=20` |
| DOM 生成方式 | 方案 ASSR 渲染双层结构 + JS 接管高清加载 |
| aspect-ratio 尺寸来源 | 方案 B2渲染时实时读图片 header + moka 缓存(零迁移) |
| 外链图 | 不处理(保持原生 img无法生成占位图 |
| dimensions 缓存 TTL | 24h可通过 `IMAGE_DIMENSIONS_CACHE_TTL_SECS` 环境变量配置 |
## 技术约束(探索结论)
- **服务端图片能力现成**`/uploads/{path}` 支持 `?w=`/`?thumb=`,已有内存+磁盘两级缓存(`src/api/image.rs`)。`?w=20` 产出 <1KB 的极小图
- **sanitizer 约束**当前 `clean_post_html` img 只放行 `src/alt/width/height/align` span 放行 `class`双层结构需要额外放行 `data-src`/`class`/`style`img `style`span)。评论配置不动
- **现有缓存是异步的**`src/cache.rs` `moka::future::Cache``.get().await` `render_markdown_enhanced` 是同步函数dimensions 缓存需要用 `moka::sync::Cache`同步版本)。
- **webp 尺寸读取可行**`zenwebp::WebPDecoder::build(data)` + `.info()` 只解析 WebP headerRIFF chunk就能拿到 width/height**不需要全量解码像素** `src/webp.rs:143-145` 现有代码在 decode 前就读了 info)。开销极小GIF/PNG/JPEG `image::ImageReader::into_dimensions()`只读 header)。
- **上传后磁盘格式** gif/webp 统一转 webpgif 保持 gifwebp 保持 webp所以 dimensions 读取要支持 webp + gif/png/jpeg
## 详细设计
### 架构总览
```
SSR 渲染文章/封面时
对每张 /uploads/ 图片:
├─ get_image_dimensions(path) → (w, h) [sync moka cache + 读 header]
└─ 产出双层 DOM:
<span class="blur-img" style="--ar: W/H;">
<img class="blur-img-placeholder" src="...?w=20"> ← SSR 内嵌,立即加载
<img class="blur-img-full" data-src="...?w=800"> ← JS 懒加载
</span>
前端 JS (post-content.js / image-viewer 初始化)
├─ IntersectionObserver 监听 .blur-img 进入视口
├─ 把 .blur-img-full 的 data-src 赋给 src → 触发高清图加载
└─ 高清图 onload → 加 .is-loaded class → CSS opacity 0→1 淡入
```
### 1. Dimensions 缓存与读取(新增)
**`src/api/image.rs` 新增**`#[cfg(feature = "server")]`
```rust
use moka::sync::Cache;
use std::sync::LazyLock;
use std::time::Duration;
static IMAGE_DIMENSIONS_CACHE: LazyLock<Cache<String, (u32, u32)>> = LazyLock::new(|| {
let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(86400)); // 默认 24h
Cache::builder().time_to_live(ttl).build()
});
/// 读取图片真实尺寸(只读 header不解码像素
/// 路径如 "2026/06/22/xxx.webp"(不含 /uploads/ 前缀)。
/// 失败返回 None渲染时回退到不设 aspect-ratio
pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
return Some(dims);
}
let full_path = std::path::Path::new("uploads").join(rel_path);
let data = std::fs::read(&full_path).ok()?;
let dims = read_dimensions_from_bytes(&data, rel_path)?;
IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
Some(dims)
}
/// 按扩展名分发webp 走 zenwebp header其他走 image crate。
fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_lowercase();
match ext.as_str() {
"webp" => {
let decoder = zenwebp::WebPDecoder::build(data).ok()?;
let info = decoder.info();
Some((info.width, info.height))
}
"gif" | "png" | "jpg" | "jpeg" => {
let reader = image::ImageReader::new(std::io::Cursor::new(data))
.with_guessed_format().ok()?;
reader.into_dimensions().ok()
}
_ => None,
}
}
```
**注意**这是同步函数 server function 的异步上下文里同步读文件头文件头很小几十到几百字节开销可接受moka cache 命中时不读盘`std::fs::read` 读整个文件——优化点可改为只读前 N 字节webp/jpeg/png/gif header 都在前几百字节内但首版用 `std::fs::read` 保持简单配合 cache 降低频率
### 2. 双层 DOM 结构
```html
<span class="blur-img" style="--ar: 16/9;">
<img class="blur-img-placeholder" src="/uploads/x.webp?w=20" alt="...">
<img class="blur-img-full" data-src="/uploads/x.webp?w=800" alt="...">
</span>
```
- **`span.blur-img`**相对定位容器`aspect-ratio: var(--ar)` 预留空间 CLS`overflow: hidden` 裁剪模糊边缘
- **`img.blur-img-placeholder`**SSR 内嵌的 `?w=20` 占位图绝对定位填满容器CSS 模糊放大
- **`img.blur-img-full`**`data-src` 存高清 URL初始无 src不立即加载JS 接管绝对定位初始 `opacity: 0`加载完 `.is-loaded` `opacity: 1`
`--ar` 由服务端用 `get_image_dimensions` 拿到的真实宽高生成 `16/9`)。dimensions 读取失败时不设 `--ar`退化为无 aspect-ratio轻微 CLS但不阻塞渲染)。
### 3. Markdown 渲染器改造(`render_markdown_enhanced`
正文图转换发生在 MarkdownHTML 阶段当前用 `pulldown_cmark::html::push_html` 产出 `<img src="..." alt="...">`
**改造** `push_html` 产出 HTML `clean_html` 之前对字符串里的 `<img>` 做后处理
```rust
fn wrap_images_with_blur(html: &str) -> String {
// 用正则/简单解析找到 <img src="/uploads/..." ...> 标签
// 对每个匹配的 img:
// 1. 提取 src如 /uploads/2026/06/22/x.webp
// 2. 从 src 解析出 rel_path去掉 /uploads/ 前缀和 query
// 3. get_image_dimensions(rel_path) → (w,h),算 --ar
// 4. 生成双层结构替换原 img
// 非 /uploads/ 的外链图不处理(保持原样)
}
```
**正则选择** `regex` crate已是 server feature optional 依赖匹配 `<img\s+[^>]*src="(/uploads/[^"?]+)[^"]*"[^>]*>`注意 HTML 用正则有边界情况 img 标签由 pulldown-cmark 生成格式可控属性顺序固定src 在前alt 在后)。
### 4. ImageViewer 改造(卡片封面 / 详情封面)
`ImageViewer` 当前渲染单层 img + 灯箱改为渲染双层结构
```rust
ImageViewer {
src: cover.clone(),
thumb_params: "?w=1200", // 高清尺寸 → data-src
placeholder_params: "?w=20", // 占位图尺寸
alt: "封面图片",
}
```
ImageViewer 内部
- **dimensions 获取**组件内部调用 `get_image_dimensions``#[cfg(feature = "server")]`)。SSR 渲染时组件树在服务端执行server-only 函数可用dimensions moka 缓存命中时是内存查询miss 时读小文件头调用方`post_card.rs`/`post_cover.rs`无需改动 dimensions 相关逻辑WASM 端无法调此函数但图片的 `--ar` SSR 阶段已渲染进 HTML前端无需再算
- 底层 placeholder`src = {原图}{placeholder_params}`
- 上层 full`data-src = {原图}{thumb_params}`
- `--ar` dimensions 计算SSR 时写入 `style`
- 灯箱逻辑不变点击用原图 src 放大
`get_image_dimensions` 在组件里调用属于 SSR 渲染时的同步读盘首版接受这个阻塞小文件 + cache SSR 性能成问题后续可改为 server function 预算 + 模型字段传递
### 5. post-content.js 改造
当前职责 img.src 改成 `?w=800`加灯箱
**新职责**
- **不再改 src**渲染器已产出双层结构placeholder src 已是 `?w=20`
- **加载高清图** `IntersectionObserver` 监听 `.blur-img` 进入视口 `.blur-img-full` `data-src` 赋给 `src`
- **触发淡入**高清 img `onload` `.is-loaded` classCSS 控制 opacity 过渡
- **灯箱**保留现有点击放大灯箱用 `.blur-img-full` `data-src`高清图 URL作为放大图源——灯箱是看大图应用高清版本而非 20px 占位图
```javascript
function initBlurUp(root) {
var containers = root.querySelectorAll('.blur-img');
if (!('IntersectionObserver' in window)) {
// 不支持 IO 的浏览器:直接加载所有
containers.forEach(loadFull);
return;
}
var io = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
loadFull(entry.target);
io.unobserve(entry.target);
}
});
}, { rootMargin: '200px' });
containers.forEach(function(c) { io.observe(c); });
}
function loadFull(container) {
var full = container.querySelector('.blur-img-full');
if (!full || !full.dataset.src) return;
full.onload = function() { full.classList.add('is-loaded'); };
full.src = full.dataset.src;
}
```
### 6. CSS
新增到全局样式`input.css` 或对应样式入口
```css
.blur-img {
position: relative;
display: block;
overflow: hidden;
aspect-ratio: var(--ar);
background: var(--color-paper-code-bg, #f5f5f5); /* 加载中灰底 */
}
.blur-img-placeholder {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(20px) saturate(1.2);
transform: scale(1.1); /* 放大遮住 blur 边缘 */
}
.blur-img-full {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.4s ease;
z-index: 1;
}
.blur-img-full.is-loaded {
opacity: 1;
}
```
暗色模式`.blur-img` background 用暗色灰底
### 7. sanitizer 配置扩展
`clean_post_html`文章专用扩展放行属性
- img额外放行 `data-src``class``style`
- span额外放行 `style`用于 `--ar`
评论配置`clean_comment_html`**不动**评论无 img)。
## 实现边界与清单
### 服务端(`#[cfg(feature = "server")]`
| 文件 | 改动 |
|------|------|
| `src/api/image.rs` | 新增 `IMAGE_DIMENSIONS_CACHE`moka sync+ `get_image_dimensions` + `read_dimensions_from_bytes` |
| `src/api/markdown.rs` | `render_markdown_enhanced` 增加 `wrap_images_with_blur` 后处理 clean_html |
| `src/api/sanitizer.rs` | `clean_post_html` 扩展 img 放行 `data-src/class/style`span 放行 `style` |
### 前端Rust 组件)
| 文件 | 改动 |
|------|------|
| `src/components/image_viewer.rs` | 渲染双层结构placeholder + full接收 dimensions/aspect-ratio |
| `src/components/post_card.rs` | ImageViewer 时传入 dimensionsSSR 算好 |
| `src/components/post/post_cover.rs` | 同上 |
### 前端JS
| 文件 | 改动 |
|------|------|
| `public/js/post-content.js` | 删除改 src 逻辑新增 IntersectionObserver 懒加载 + onload 淡入灯箱改用 data-src |
### 样式
| 文件 | 改动 |
|------|------|
| `input.css`或全局样式入口 | 新增 `.blur-img*` 样式含暗色模式 |
### 配置
| 文件 | 改动 |
|------|------|
| `.env.example` | 新增 `IMAGE_DIMENSIONS_CACHE_TTL_SECS=86400` |
### 不做的事
- 不改评论图片评论无 img
- 不处理外链图 `/uploads/` 的图保持原生 img
- 不给上传流程加尺寸持久化B2 方案零迁移实时读 + 缓存
- 不做 LQIP 之外的图片优化avif 转码响应式 srcset
- 不改图片服务端点`/uploads/{path}` `?w=` 能力现成
## 实现风险
1. **正则解析 img 标签**`wrap_images_with_blur` 用正则匹配 imgpulldown-cmark 产出的 img 格式可控src 在前alt 在后但需写测试覆盖各种 src 格式/不带 query外链相对路径)。
2. **`std::fs::read` 读全文件**首版 `get_image_dimensions` 读整个文件再解析 header对大图 MB有内存/IO 开销优化改为只读前 N 字节webp/jpeg/png/gif header 都在前 100 字节内)。首版保持简单配合 cache 降低频率后续可优化
3. **SSR 阶段 ImageViewer 拿 dimensions**ImageViewer 是通用组件dimensions server-only需在 SSR 上层post_card/post_cover算好传入避免组件内部调用 server-only 函数
## 验收标准
- [ ] 文章正文图片加载时先显示模糊的 `?w=20` 占位图无布局跳动aspect-ratio 预留空间
- [ ] 高清缩略图加载完后opacity 平滑淡入覆盖占位图0.4s 过渡
- [ ] 视口外的高清图不加载IntersectionObserver 懒加载
- [ ] 卡片封面详情封面同样有 blur-up 效果
- [ ] 外链图 /uploads/保持原生 img blur-up
- [ ] JS 禁用时正文图显示 `?w=20` 模糊占位图渐进增强可读但模糊
- [ ] dimensions 缓存命中时不读盘二次访问快
- [ ] webp/gif/png/jpeg 图片的尺寸都能正确读取
- [ ] `.env.example` `IMAGE_DIMENSIONS_CACHE_TTL_SECS=86400`
- [ ] sanitizer 不误删双层结构的 `data-src`/`class`/`style` 属性
- [ ] 评论图片不受影响评论无 img配置不动
- [ ] 点击图片放大灯箱功能仍正常工作