‹ 返回首页技术

一个本地优先的私人媒体库:原生 JavaScript、IndexedDB、Python 单文件后端和 SwiftUI 客户端,如何一起处理海量照片、视频、缩略图与文件夹。

2026年10月18日·14 分钟阅读·阅读 …

文章目录
  1. 一、它不是“云相册”
  2. 二、为什么最后没有用 React
  3. 三、万级文件,真正的问题不是“能不能显示”
  4. 四、四种视图,其实对应四种找文件的方式
  5. 五、图片和视频,不该被拆成两个应用
  6. 六、从“分组”到真正的文件夹
  7. 七、几个看起来很小、实际很难的坑
  8. iOS 安全区把底部导航挤坏
  9. 视频进度条悬停预览不能每次都 seek
  10. 自定义播放器不一定比原生好
  11. 八、隐私不是一句口号
  12. 九、现在做到哪了
  13. 十、写在最后
  14. 开源项目与第三方组件声明
  15. Python
  16. Pillow
  17. FFmpeg / FFprobe
  18. Docker Engine
  19. Nginx / OpenResty
  20. 1Panel
  21. Let’s Encrypt
  22. Apple 系统框架
  23. Web 平台能力
  24. 项目自身

项目信息

  • 项目:Henry 媒体库 / Henry Media Library
  • 作者:Henry
  • 当前版本:Web v160 / macOS 2.22.0
  • 技术组成:原生 ES Modules、IndexedDB、Python 标准库后端、SwiftUI
  • 项目性质:个人本地媒体管理工具,源码目前未公开
  • 本文作者:Henry

我的硬盘里有几万张照片和几百上千个视频。

婚礼跟拍、商拍、项目素材、日常记录,散落在几十个文件夹里。时间久了之后,找一张照片变成了一件很笨的事情:先回忆它在哪个盘,再猜它可能在哪个目录,最后用访达的搜索框碰运气。

系统自带的“照片”并不是为这种使用方式设计的。它会想把资料导入自己的库,建索引、同步、分析,还会不断提醒我使用云服务。QuickTime 和 IINA 能播放视频,却不能替我管理几万条素材。

我真正想要的其实很简单:

  • 所有数据留在自己的设备或服务器上;
  • 打开网页就能搜索、筛选和浏览;
  • 图片和视频在同一个界面里;
  • 手机上也能看,但不需要把媒体上传到第三方云盘;
  • 我的文件仍然是我的文件,不被某个应用锁进私有数据库。

于是有了 Henry 媒体库。

一、它不是“云相册”

Henry 媒体库的第一条原则是本地优先。

所有索引、缩略图、标签、分组和设置都保存在浏览器的 IndexedDB 和 LocalStorage 中。本机模式下,它不联网、不上传、不依赖任何外部 CDN,断网也能打开。

但它也不是一个纯浏览器玩具。

浏览器可以读取文件、生成图片缩略图、播放视频、做检索和虚拟滚动,却不能直接删除原文件、打开文件所在位置、重命名磁盘上的文件。于是项目里有一个很薄的 Python 后端,只负责浏览器做不到的事情:

  • 打开原位置;
  • 删除或重命名原文件;
  • 导出文件到指定目录;
  • 远程模式下提供媒体上传和 Range 流式播放。

换句话说,能放在前端的能力全部放前端,只有必须碰磁盘的操作才交给后端。

这让项目拥有了两种形态:

  1. 在本机运行 python3 server.py,浏览器就是媒体库界面;
  2. 部署到自己的服务器,手机、平板和另一台电脑都能访问。

它没有账号系统,也不需要订阅。远程服务器就是自己的电脑,所谓的“云”,只是自己机器在公网上的一个入口。

二、为什么最后没有用 React

这个项目没有构建步骤,没有 npm,没有打包器。

前端由 30 多个原生 ES Module 组成,大约 8600 行 JavaScript。打开页面就是最终产物,改一行代码刷新就生效。

这不是为了追求“纯原生”的浪漫,而是一个非常实际的判断:这是一个个人长期维护的工具,不是需要多人协作、频繁发布的产品。

不引入框架带来了几个好处:

  • 两年后打开项目,不需要先修复依赖树;
  • 页面可以直接运行,不需要 Node 环境;
  • 状态、视图和数据库之间的关系是可见的;
  • 出问题时,定位路径更短。

代价也很明显:路由、状态管理、事件总线、组件更新都要自己写。

最终的结构大概是:

app.js          主入口:导航、工具栏、筛选、快捷键
store.js        响应式状态 + 事件总线 + 检索管线
db.js           IndexedDB 封装
scanner.js      增量扫描 + 元数据调度
thumbnails.js   缩略图 Worker 池 + 视频抽帧
views.js        四种视图 + 虚拟滚动
viewer.js       图片查看器
player.js       视频播放器
manage.js       重命名 / 删除 / 回收站 / 标签
advanced.js     重复检测 / 地图 / 密码锁
remote.js       远程模式客户端

这套结构没有“框架感”,但足够清楚。对于一个自己每天都会用的工具来说,清楚比先进更重要。

三、万级文件,真正的问题不是“能不能显示”

最早的版本会把所有媒体卡片一次性渲染出来。几千张图片还能勉强滚动,文件数量继续增加之后,页面就开始明显掉帧。

问题不在于浏览器不能显示图片,而在于它一次性创建了太多 DOM 节点。

后来视图层改成虚拟滚动:

  • 根据滚动位置计算当前可见范围;
  • 只渲染可视区域和前后少量缓冲项;
  • 用一个足够高的占位元素维持滚动条;
  • 切换视图或筛选条件时重新计算范围,而不是重建整个列表。

现在媒体数量超过 500 条就会自动进入虚拟滚动。万级索引不会创建万个卡片节点,首屏也不会因为生成缩略图而卡死。

缩略图也是同样的思路。

图片压缩和视频抽帧放在 Web Worker 中执行,主线程只负责排队和渲染。视频封面用 <video> 定位到指定时间,再用 <canvas> 画出来;远程模式下优先由服务器端的 ffmpeg 或 Pillow 生成 2~50KB 的小图,客户端不再下载整个视频来截一张封面。

媒体管理器的性能上限,往往不是解码速度,而是“你同时创建了多少东西”。

四、四种视图,其实对应四种找文件的方式

同一个媒体库,用户找文件的方式并不只有一种。

所以页面提供了四种视图:

  • 网格 / 瀑布流:适合快速浏览和看整体氛围;
  • 列表:适合看文件名、格式、大小、时间和时长;
  • 时间线:按年 → 月 → 日折叠,适合回忆某次拍摄;
  • 文件夹:还原磁盘目录结构,带面包屑导航。

前三种视图解决“我想看什么”,文件夹视图解决“我知道它在哪”。

筛选和搜索也尽量贴近真实使用:

  • 按图片 / 视频筛选;
  • 按时间、格式、大小、分辨率筛选;
  • 文件名模糊搜索;
  • 拼音首字母匹配,例如 wjgp 可以找到“婚礼跟拍”;
  • #标签 搜索;
  • 多个条件叠加。

对于中文文件名来说,拼音首字母匹配是非常实用的小功能。它不要求用户切换输入法,也不要求记住完整标题,只要记得大概的发音顺序就能找到文件。

五、图片和视频,不该被拆成两个应用

图片查看器支持滚轮缩放、拖拽平移、90° 旋转、水平 / 垂直翻转、适应窗口、适应宽度和 1:1 原图模式,也可以直接查看 EXIF 和 GPS 信息。

视频播放器支持:

  • 播放、暂停、进度拖动、音量和静音;
  • 0.5x 到 2x 倍速;
  • 逐帧前进 / 后退;
  • 前后 10 秒跳转;
  • 进度条悬停显示对应帧缩略图;
  • 播放记忆,下次打开自动回到上次位置;
  • 单曲循环和列表循环。

这里有一个很典型的平台取舍。

桌面端使用自定义播放器控制栏,因为可以做得更符合媒体库的整体界面;但在 iPhone 上,竖屏内嵌的 <video> 原生控件非常精简,和桌面端功能差距很大。我曾尝试完全用自定义控件接管手机播放器,结果加载速度、手势和全屏行为都变得不稳定。

最后的选择是:手机端回到原生控件,桌面端保留自定义控制栏。

这不是技术上的退让,而是承认平台边界。网页可以模仿原生,但没必要在每个平台上都和系统控件对抗。

六、从“分组”到真正的文件夹

项目早期使用的是虚拟分组:一个媒体可以同时属于多个 albumIds。它适合做收藏和主题集合,但不像电脑上的文件夹。

后来我把它改成了真正的文件管理器结构:

  • 每个文件夹有 parentId,支持无限层级;
  • 每个媒体有一个固定主位置;
  • 支持新建、重命名、删除、移动和拖拽;
  • 顶部显示面包屑;
  • 文件夹封面保留原来的 2×2 缩略图;
  • 删除文件夹时进入回收站,恢复时保留原层级。

这次改造不是换一套 UI,而是数据模型迁移。

原来的分组允许一个文件同时出现在多个集合里;真正的文件夹只允许一个主位置。为了兼容旧数据,需要先把现有分组转换成顶层文件夹,再处理重复归属、收藏、标签、分享链接和回收站里的路径引用。

正式迁移前做了完整备份,并在 500 个文件夹、多层目录的场景下测试:

  • 进入子目录后首屏保持 120 项;
  • 切换目录不整页重载;
  • 面包屑和返回上级正常;
  • 控制台无报错。

这次改造让我更清楚地认识到:文件管理器的难点不是树形结构,而是路径、引用和迁移。

七、几个看起来很小、实际很难的坑

iOS 安全区把底部导航挤坏

加入 viewport-fit=cover 之后,env(safe-area-inset-bottom) 开始生效。悬浮 tab 栏已经通过 bottom: calc(8px + env(...)) 浮在安全区上方,却又继承了另一套样式里的底部 padding,导致 60px 高的栏被 34px 的安全区吃掉,按钮只露出半截。

修复只有一行,但前提是先理解:悬浮导航的 bottom 定位已经处理了安全区,内部就不能再重复计算一次。

视频进度条悬停预览不能每次都 seek

鼠标在进度条上移动时会触发大量事件。如果每次移动都让隐藏的 <video> seek 到对应时间再画 canvas,播放器会直接被拖垮。

最后的做法是对 seek 做节流,只保留最后一次有效位置,并复用隐藏的视频元素和 canvas。看起来只是一个小浮层,背后其实是一套独立的帧调度。

自定义播放器不一定比原生好

我曾经认为自定义播放器可以统一桌面和手机体验。实际结果是手机上的加载、全屏和手势都更差。

原生控件不漂亮,但它稳定、符合系统预期,也自动处理了很多边界情况。网页应用不应该为了“统一”而牺牲平台本身的可用性。

八、隐私不是一句口号

这个项目最让我安心的地方,是它不需要把照片交给别人。

本地模式下:

  • 索引、缩略图、标签、分组存在浏览器 IndexedDB;
  • 设置存在 LocalStorage;
  • 密码锁只保存 SHA-256 哈希;
  • 不发起外部网络请求;
  • 媒体文件仍然在原来的磁盘目录里。

远程模式下,数据也只在自己的服务器上。媒体目录独立于应用代码,删除或重建容器不会删除宿主机上的照片和视频。

唯一需要反复提醒自己的事情是:服务器上的媒体目录一定要定期备份。

代码可以重新部署,索引可以重建,缩略图可以重新生成,但原始照片不能。

九、现在做到哪了

从 2026 年 8 月底写下第一版,到现在大约两个月:

  • 网页端迭代到 v160;
  • macOS 客户端 2.22.0;
  • 前端约 8600 行 JavaScript;
  • 页面、样式与 Python 后端约 3900 行;
  • SwiftUI 客户端约 2500 行;
  • 媒体库已积累到几个 TB 的照片和视频;
  • 文件夹、回收站、重复检测、离线地图、图片批注、密码锁、远程上传和移动端访问都已落地。

版本号涨得很快。v153 修安全区,v154 修 tab 栏,v155 重做手机播放器,v156 改回原生控件,v157 修下滑动画,v158 开始做真实文件夹,v160 完成多层目录。

每一个小版本背后,都是一次真实使用中发现的“不顺手”。

十、写在最后

这个项目没有融资,没有用户增长,也没有云端。

它只是解决了一个很具体的问题:

我自己的照片和视频,应该由我自己来管。

做完之后,我对浏览器有了新的认识。文件系统、IndexedDB、Web Worker、Canvas、Range 流媒体、离线缓存,这些能力组合起来,足以支撑一个真正可用的媒体管理工具。

但更重要的不是技术栈,而是取舍:

  • 不追求所有平台都长一样;
  • 不为了功能数量牺牲加载速度;
  • 不把用户数据搬到自己的服务器之外;
  • 不在一个个人工具里引入不必要的复杂度。

有时候,最好的云,就是你自己那台电脑。


开源项目与第三方组件声明

Henry 媒体库的核心代码由 Henry 编写,但运行时、媒体处理和部署环境使用了以下开源软件或平台能力。相关名称、代码和商标归各自作者、维护者与项目所有。

Python

  • 项目:Python
  • 官网:python.org
  • 许可证:Python Software Foundation License 2.0(PSF-2.0)
  • 用途:实现本地 HTTP 服务、文件操作接口、Range 流媒体、元数据与缩略图调度。

Pillow

  • 项目:Pillow
  • 仓库:github.com/python-pillow/Pillow
  • 许可证:HPND / PIL License
  • 用途:读取图片、生成缩略图和处理基础 EXIF 信息。没有安装 Pillow 时,项目会退化为原始文件或 FFmpeg 路径。

FFmpeg / FFprobe

  • 项目:FFmpeg
  • 官网:ffmpeg.org
  • 许可证:LGPL-2.1-or-later;具体二进制取决于构建选项,也可能包含 GPL 组件
  • 用途:生成视频封面、提取媒体信息和处理部分浏览器不能直接读取的格式。项目没有修改 FFmpeg 源码。

Docker Engine

  • 项目:Docker Engine
  • 官网:docker.com
  • 许可证:Apache-2.0
  • 用途:服务器端容器的构建与运行。

Nginx / OpenResty

  • 项目:Nginx
  • 官网:nginx.org
  • 许可证:BSD-2-Clause
  • 用途:反向代理、HTTPS 入口以及视频 Range 流的转发。

如果部署时使用 1Panel 提供的 OpenResty:

  • 项目:OpenResty
  • 官网:openresty.org
  • 许可证:BSD 风格的 OpenResty 许可证,详见其 COPYRIGHT
  • 用途:作为 1Panel 网站与 HTTPS 的运行环境。

1Panel

  • 项目:1Panel
  • 仓库:github.com/1Panel-dev/1Panel
  • 许可证:GPL-3.0
  • 用途:个人服务器管理、容器编排和反向代理配置。

Let’s Encrypt

  • 服务:Let’s Encrypt
  • 官网:letsencrypt.org
  • 性质:免费的公共证书颁发机构,不属于项目内置代码。
  • 用途:为远程访问地址签发 TLS 证书。

Apple 系统框架

macOS 客户端没有引入第三方 Swift Package。项目使用的主要能力来自 Apple 系统框架:

  • SwiftUI
  • AppKit
  • AVKit / AVFoundation
  • WebKit
  • Combine
  • CryptoKit

这些框架随 macOS 和 Xcode 提供,不是本项目的开源依赖,也不随项目源码分发。SwiftUI、macOS、iOS、iPhone 和 Apple 是其各自权利人的商标。

Web 平台能力

项目还使用了 IndexedDB、LocalStorage、Web Worker、Canvas、File System Access API、HTTP Range 和 Service Worker 等浏览器标准能力。

这些属于 Web 平台 API,不是第三方 JavaScript 框架。当前前端没有使用 React、Vue、Svelte、jQuery 或外部 UI 组件库,也没有依赖外部 CDN。

项目自身

Henry 媒体库由 Henry 独立开发,源码目前未公开,项目没有额外声明开源许可证。

本文中的代码结构、功能描述和项目版本信息仅用于开发记录,不构成对源码发布、二次分发或商业授权的承诺。文中不包含真实访问令牌、服务器地址或其他敏感凭据。


项目信息: Henry 媒体库 / Henry Media Library。项目作者与本文作者均为 Henry。当前版本 Web v160、macOS 2.22.0,源码目前未公开。

H
Henry

记录技术实践、设计观察与生活里的慢思考。

关于作者
DISCUSSION

评论 0

留下想法或补充。评论经审核后会显示在这里。

正在读取评论…