1.
大概从 2013 年开始,我就一直把 Tumblr 当作情绪板来使用。
不需要特定的分类,只是转发和保存那些令我感受良好的图片。配合丰富的主题模版和宽松的自定义能力,能够完全按照我的意愿来展示和阅读。
但在 Tumblr 各种自断后路的产品决策之后,出于对于平台生命和数据的担忧,我便一直在考虑迁移出去。
在没有 AI 之前,这件事情确实会令人犹豫。
在这之后某一天,站点的主题也损坏了,没有办法自动展示新的 Post 附图,我觉得是时候离开这里了。
2.
此时 Claude Code 和 Codex 还没出现,占据 Vibe Coding 生态位的是 Cursor,主力模型还是 Haiku 3 和 Sonnet 3.5。
事实上出于贫穷,大部分时候我也只用的起 Auto Mode (Haiku)。
在此之前,我已经用它构建过逻辑不复杂的 Figma 插件、Raycast 插件和一些 Next.js 页面。
3.
得益于 Tumblr 本身就有公开 API 可供获取 Post 内容,我让 Cursor 生成并运行批量下载并按时间戳命名图片的脚本,大致逻辑是:
- 遍历 Tumblr blog 的历史内容
- 找出每篇 Post 里的图片资源
- 下载原图或可获取到的最高质量版本
- 用 Post 日期给图片打标
- 避免重复下载,失败的链接单独记录 log,方便之后补扫
One-shot,很丝滑。
4.
存储方案没有太多犹豫,选择了 R2。
R2 的好处多多:它是对象存储,适合放大量图片;可以绑定自定义域名;配合 CF 的 /cdn-cgi/image 图片变换能力,可以在 URL 层直接获取不同质量和尺寸的图片。
迁移阶段,起手直接让 Cursor 调用 Wrangler 批量上传到 R2 bucket 内。
做了一点来源上的区分,将这批图片指定到了 bucket/archive/ 路径,这样后续新图和旧图就在存储层被分开了。虽然目前还不知道有什么用。
5.
我利用 Worker 来创建 KV 索引并定时更新,避免每次调用都遍历 bucket,再通过 Worker 发起 API 供前端消费,大致逻辑是:
- 从 R2 里分页列出所有对象
- 给每个对象生成公开 URL、缩略图 URL、占位图 URL 和大图 URL
- 按对象名称也就是时间倒序
- 把完整结果写进 KV 的
image-index,请求时再按 page / limit 切片返回。 - 额外提供强制刷新和缓存状态的接口
6.
从这个项目开始,我决定避开 Next.js,因为不想和 Vercel 绑定。
我的个人需求大概率也都不需要 SSR,即使真的要做预渲染,用 Astro 甚至手写 SSG 都会是更简单的选择。
所以这个项目的前端技术栈非常轻量:Vite + React ,考虑到样式较少,我甚至放弃了 Tailwind,直接手写 CSS。神清气爽。
图片加载做了点分级:
- 极小尺寸的 Placeholder 负责占位和模糊过渡
- Thumbnail 用于 Grid 展示
- 点击图片后,再加载质量更高的大图
- 如果 CF Image Transform 不可用,则 fallback 到原图地址
前端做了本地缓存,它会把已经拿到的一部分图片索引写进 localStorage,下次进入页面可以先用本地缓存快速出图。等接口返回新的 lastUpdated 后,再判断是否清理旧缓存、同步新索引。
7.
额外花了点功夫,增加了一个页面滚动时的 Edge Warp 效果。
Shader 本身实现并不困难,难点在于图片数量和状态太多。如果 WebGL 只处理已经加载完成的图片,那么分页、慢速网络或者图片刚好在滚动中完成加载时,就会在 DOM 和 Canvas 之间反复切换,表现为闪烁、抖动,甚至滚动停止后才补一次形变。
最终的方案是保留 DOM 和 WebGL 两套职责:
- DOM 继续负责 Grid 布局、图片下载、懒加载、点击交互和降级
- WebGL 只负责桌面端的视觉镜像、滚动位移和边缘形变
Renderer 读 DOM 里已经就绪的画面,但 DOM 加载完成不等于 GPU 就绪,新纹理会先异步裁切、缩放、上传,准备好后同一帧替换 layer。没准备好前,上一份已提交的视觉继续参与形变,不会退回「没有效果的 DOM 图」。placeholder 到 thumbnail 的淡入也放进 Shader,期间只更新时间 uniform,不需要每帧重建几何。
滚动使用了 Lenis,但关闭了它的自动 RAF。拖拽、Lenis 和 WebGL 共用一个帧时钟:只有滚动、惯性、形变释放、资源提交或淡入还在进行时才继续 RAF,完全静止后休眠。
纹理用 WebGL2 的 TEXTURE_2D_ARRAY 按相近尺寸分桶,prepare 和 upload 拆开,每帧最多 commit 一张,避免分页时把解码和上传挤在同一帧。已上传的 thumbnail 滚出视图也不立刻回收,向后滚时直接显示旧图,不必再走一遍 placeholder。比起显存,我还是更在意手感的连续性。
GIF 不得已使用了折中方案:滚动期间作为静态纹理参与形变,滚动结束后再恢复 DOM GIF。
整套逻辑不会接管 React 的业务状态,也不改变 DOM 的布局和交互。WebGL 渲染层与图片加载、缓存、分页和降级逻辑保持相对独立,整体耦合度很低。
最终的效果:oo.the3ash.com
但还是要感叹一句,即使有 AI,要厘清和做好这些 (对我来说) 也不是很容易。
8.
解决了展示部分,接下来是关于采集的手段。
我需要一个入口,可以让我方便地从移动端和桌面端都能上传图片到 R2,但同时也要避免在前端明文存储 Access Key 和 Secret。
Telegram 频道是不错的选择。使用 Bot 配合 GitHub Action,定时抓取并上传频道内的图片,具体方式是:
node-telegram-bot-api读取频道消息和文件axios下载图片或 URL 资源- AWS S3 SDK 连接 R2
- 通过 GitHub 后台管理 TG 和 R2 凭据
到这里,功能需求基本都能满足了。
9.
但在使用一段时间后,我开始厌恶使用 TG 的这一步,我期望能有更无缝的体验,于是我制作了 D2R2 (Drop images to R2)。
它是一个 Chrome 浏览器扩展,有了它,任意网页上的图片都可以通过右键菜单直接上传。
事实上在这之前,我找到了几个类似功能的扩展,它们无一例外需要保存 Key 和 Secret,但是我的被迫害妄想症不允许我这么做,即使我在 R2 存储的东西一文不值。
D2R2 使用 Worker 来配合作为写入层,不需要存储任何凭据,只需要配置 Account ID、Worker URL 即可。
额外的,可以选择设置多个存储路径和图片压缩质量。
10.
现在我可以随手高速偷图了,这比在 Tumblr 时代丝滑得多。
这套方案简单、零成本,不需要引入数据库或是复杂的后台。
当然也并不高明,即使是 Cursor 的 Auto Mode 也可以轻易规划和完成。
但是像这样,任意想法都能被迅速满足,所带来的直给快感也远超其他事物。Vibe Coding 才是真正的电子海洛因。