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 才是真正的电子海洛因。