noctis91 downloadsTurn the photos and videos scattered across your notes into an Instagram-style feed: single-column posts or a masonry grid, carousels, a full-screen lightbox, and a publish flow. Local-only; never copies files or edits your notes.
中文 | English
把你散落在 Obsidian 里的照片重新组织起来,变成一个能一直往下滑的照片流。
一个 Obsidian 插件:扫描你指定的一个或多个文件夹(日记、社交平台归档、随手记、剪藏……),把里面出现过的图片和视频抽出来,重新排成 Instagram 风格的时间流 —— 单列大图,或者多列网格瀑布流,左下角一点就切。
纯本地运行。不复制原文件、不生成缩略图、不改动你的任何一条笔记。
用久了 Obsidian 之后,照片是散落的:今天的记录在 日记/2026/0901.md,一条动态在 社交归档/2026-09-01.md,随手记又在另一个文件夹里。想回看「我以前拍过什么」,只能在文件树里翻。
视界不改你的目录结构,也不要求你把照片搬到统一的地方。它只是按你给的范围建一份索引,然后把照片按时间倒序铺出来 —— 于是你的笔记结构完全不用动,但你多了一个专门用来看照片的地方。
左下角按钮一点就切,选择会被记住:
单列 Feed —— Instagram 风格。照片大、带正文与来源,一条一条往下看。
网格瀑布流 —— 多列自动铺满,瓦片固定正方形、裁切填满,一屏能扫很多张。瓦片模式下正文和来源整块都不渲染(不是藏起来),照片再多也不卡。默认每张照片一格:一条记录里的照片全部摊开,各占一格;不想打散就切成每条记录一格(多图在格子里左右滑)。格子上不叠任何东西 —— 没有张数角标、没有图标,只有照片本身。
两种布局共用同一套分批渲染:首屏只渲染一批,滚动到哨兵才追加下一批,提前 1200px 预载。上千张照片也不会一次性塞进 DOM。
照片的原始比例差异极大(实测 45% 是 1:1,但也有 1:10 的手机长截图和 15:1 的全景),直接铺出来会参差不齐。三档策略:
| 模式 | 行为 |
|---|---|
| 限制范围(默认 4:5 ~ 16:9) | 区间内的照片完全不动,只有越界的极端比例裁到边界值。实测只有约 22% 的记录会受影响 |
| 统一比例 | 全部裁成同一个比例(9:16 / 3:4 / 4:5 / 1:1 / 4:3 / 16:9),整列最齐 |
| 原始比例 | 完全不裁,完全按原图 |
另外「图片最大高度」限制照片区域占屏幕的高度;超过时照片框会整体缩窄而不是左右留白。
1/N 角标),滑动带 scroll-snap← → Esc 操作,触屏原生手势右下角 +(或命令「发布照片或视频」):
然后插件会写进 {目标文件夹}/YYYY/MM/MMDD.md,媒体用 ![[纯文件名]] 嵌入,附件落在你指定的附件文件夹(留空则跟随 Obsidian 的附件设置)。
几个刻意做对的地方:
HH:MM 升序插进同一个文件,而不是各写一个文件。### HH:MM 小标题或 - HH:MM 列表项都是 Post 的开头,一直管到下一条时间戳为止。## Memos / ## 随记 / ## 日记 / ## Journal 这类标题只是普通标题,叫什么、有没有都不影响解析。### HH:MM 小标题就插小标题,本来是 - HH:MM 列表就插列表项;两种混在同一份文件里也会按出现顺序正常解析。HH:MM 都没有时,整篇算一条记录(文件级回退),不会因为缺时间戳就把含图的笔记跳过。适合「一天一篇、每篇几张图」的写法 —— 那些图会合并成一张卡片的多图轮播。date → 路径里的年月日 → 文件修改时间;时分取 date 里的时间 → 文件名里的 HH-MM。两者都取不到就只显示日期,不硬造一个时间出来。-1,换行符保持原样。<video controls preload="metadata" playsinline>,绝不自动播放;点视频本体不会误触发 Lightbox。左下角筛选按钮:按来源 + 年份 + 起止日期。几个维度是与的关系,同时生效。来源可以多选:点一个来源加进来、再点一下移出去,多个来源取并集(全部 永远排第一,点它等于清空已选;一个都不选本来就是全部)。来源列表不是写死的,而是按索引里实际存在的来源动态生成,标签就是你在设置里填的显示名;只有一个来源时整排自动隐藏 —— 「全部 / 唯一来源」两个选项没有意义。面板默认收起,点外面或 Esc 自动关;有筛选生效时按钮上会有个小圆点。筛选组合会被记住 —— 存在本机的插件本地存储里(不写插件配置),重启 Obsidian、重载插件、关掉标签页再打开都还在;点「重置筛选」即清空。面板底部一行是「当前条数 + ⚙ 设置」,来源文件夹、显示项想改就点这儿一步跳过去。
main.js、manifest.json、styles.css<你的库>/.obsidian/plugins/visual-feed/装了 BRAT 的话,直接把本仓库地址加进去即可。
版本要求:Obsidian 1.13.0 或更新。 设置页用的是 1.13.0 引入的声明式设置 API (设置项因此也能被 Obsidian 自带的设置搜索搜到)。还在更旧的版本上,可以继续用 1.6.4(最后一个支持 1.5.0+)。
之后 Obsidian 里新建 / 修改 / 删除 / 重命名文件都会自动增量更新索引,不用手动重建。
| 设置 | 说明 |
|---|---|
| 来源文件夹 | 可增删改的扫描范围列表。改路径或启用状态会重建索引;只改说明则原地打补丁,不重建 |
| 分组方式 | 每条记录一个 Post(一段带时间戳的记录各自成帖)/每个文件一个 Post |
| 默认布局 | 单列 Feed / 网格瀑布流。首页左下角按钮可随手切换,这里设的是打开时的默认值 |
| 瀑布流瓦片尺寸 | 小 / 中 / 大(窄屏会自动缩一档,保证能排下多列) |
| 瀑布流一格 | 每张照片一格(记录摊开,更像照片墙)/每条记录一格(多图在格子里左右滑;网格里不显示张数角标) |
| 每批渲染条数 | 滚动时每批追加多少条(5–40),越小越省内存 |
| 图片最大高度 | 照片区域占屏幕高度上限(40–90vh) |
| 照片框比例 | 限制范围 / 统一比例 / 原始比例,见上文 |
| 显示记录文字 | 是否在照片下方显示记录正文(限 10 行) |
| 正文显示字数上限 | 索引时截断正文字数 |
| 显示来源 | 关掉后首页与大图里都不出现来源(来源名与来源说明一起隐藏);打开则两者都显示 |
| 卡片样式 | 关掉后是无边框圆角的贴边无缝效果,更像相册 |
| 发布文件夹 | 发布功能的默认目标文件夹 |
| 附件存放文件夹 | 留空 = 跟随 Obsidian 附件设置;./ = 笔记同目录;/ = 库根目录 |
| 发布文件夹自动加为来源 | 否则刚发布的照片不会出现在 Feed 里 |
| 启动时打开照片流 | Obsidian 启动后自动打开视界 |
设置页用的是 Obsidian 1.13.0 的声明式设置 API,所以上面这些项也能在 Obsidian 自带的设置搜索里搜到。
![[...]]、、HTML 标签),按 record 或 file 分组记录成 Post。索引持久化在插件的 data.json 里。mtime + size,只有变过的才重新解析;没变化时零读文件。INDEX_VERSION,升级后自动重建一次,不用手动点。app://local/ 资源地址直接引用库内文件,远程图片(https://)也能显示。prefers-reduced-motion。npm install
npm run dev # 监听模式(带 sourcemap)
npm run build # 生产构建,产出 main.js
npm run check # tsc --noEmit
npm test # 打包测试 + 跑端到端测试
两套测试,加起来 449 项断言(run-test 244 + run-dom-test 205):
test/run-test.ts —— 真实 Vault 端到端。对着一个真实的 Obsidian 库跑全量索引,断言 Post/媒体数量、日期合法性、增量读取次数(没变化时应该是 0 次读文件)、增删改后的正确性、发布写入端到端,以及一批纯 CSS 回归断言(jsdom 量不了布局,所以直接对 styles.css 源码做文本断言)。test/run-dom-test.ts —— jsdom UI 冒烟。轮播、懒加载、分批渲染、Lightbox、筛选面板、发布弹窗、布局切换、设置页(声明式定义的渲染 / 显隐谓词 / 读写与副作用)。两套测试里的写操作全部走内存 overlay,任何情况下都不会改到你磁盘上的真实笔记。
端到端测试需要指向一个真实的 Obsidian 库,路径不写进代码:
# 方式一:环境变量 VISUAL_FEED_VAULT="/path/to/your/vault" npm test # 方式二:把库路径写进 test/vault.local(该文件已 gitignore)来源文件夹也不用配:测试会在运行时从那个库里就地挑几个 md 最多的目录当来源 (并顺带探测文件里用的时间戳格式),所以仓库里不含任何真实目录名。
Release 附件(main.js / manifest.json / styles.css)由 GitHub Actions 构建并附带
构件来源证明(artifact attestation,Sigstore 签名)—— 这是社区市场审核的硬性建议,
任何人都可以验证附件确实来自某个 tag 的源码:
gh attestation verify main.js --owner noctisvexx
发布步骤:
manifest.json / package.json / versions.json 里的版本号(补丁位递推)git commit && git pushgit tag 1.6.9 && git push origin 1.6.9 —— tag 必须与 manifest.json 的 version 一字不差,且不带 v 前缀(社区市场硬校验;.github/workflows/release.yml 会先做一次自检,对不上直接失败)gh release edit 1.6.9 --notes "..."本地 npm run build 只用于测试和塞进 Vault,不再是分发来源。
https://)依赖网络,离线时不显示。中文 | English
Regroup the photos scattered across your Obsidian notes into a feed you can keep scrolling.
An Obsidian plugin: point it at one or more folders (journals, social-media archives, quick notes, web clippings…), and it pulls out every image and video referenced there and lays them out as an Instagram-style timeline — single-column large photos, or a multi-column masonry grid you flip between from the bottom-left corner.
Runs entirely locally. It does not copy your files, does not generate thumbnails, and does not modify a single one of your notes.
After a while with Obsidian, your photos end up scattered: today's entry is in Journal/2026/0901.md, a post is in Social/2026-09-01.md, a quick note is in yet another folder. To look back at "what did I shoot before?", you have to dig through the file tree.
Visual Feed does not change your folder structure, and it does not ask you to move photos into one place. It simply builds an index over the scope you give it, then lays the photos out newest-first — so your note structure stays exactly as it is, and you gain a dedicated place to look at your photos.
The bottom-left button flips between them, and the choice is remembered:
Single-column feed — Instagram style. Big photos, with the note text and source underneath, one record after another.
Masonry grid — multiple columns that fill the width, tiles locked to squares and cropped to fill, so a whole screenful is scannable at once. In grid mode captions and sources are not rendered at all (not merely hidden), so it stays smooth no matter how many photos you have. The default is one tile per photo — every photo in a record gets its own tile; switch to one tile per record to keep them together (swipe between images inside the tile). Nothing is overlaid on the tiles — no count badge, no icon, just the photo itself.
Both layouts share the same batched rendering: only one batch on first paint, the next appended once a sentinel scrolls into view, prefetched 1200px ahead. Thousands of photos never all land in the DOM at once.
Source ratios vary wildly (measured: 45% are 1:1, but there are also 1:10 phone screenshots and 15:1 panoramas), so a raw layout looks ragged. Three strategies:
| Mode | Behaviour |
|---|---|
| Clamp range (default 4:5 – 16:9) | Photos inside the range are left untouched; only extreme ratios outside it are cropped to the bound. Measured: only around 22% of records are affected |
| Fixed ratio | Everything cropped to a single ratio (9:16 / 3:4 / 4:5 / 1:1 / 4:3 / 16:9) — the tidiest column |
| Original | No cropping at all, exactly as shot |
"Max media height" additionally caps how much screen height the photo area may take; when a photo would exceed it, the frame narrows as a whole instead of leaving side gaps.
1/N badge), with scroll-snap← → Esc on the keyboard, native gestures on touchThe + in the bottom-right corner (or the "Publish media" command):
The plugin then writes to {target folder}/YYYY/MM/MMDD.md, embeds media as ![[bare-filename]], and puts attachments in the attachment folder you configured (leave it empty to follow Obsidian's own attachment setting).
A few things done deliberately right:
HH:MM order, instead of each getting its own file.### HH:MM heading or a - HH:MM list item starts a post, and it runs until the next timestamp. Headings like ## Memos / ## Journal are just ordinary headings — name them whatever you like, or leave them out entirely.### HH:MM file gets a heading, a - HH:MM list file gets a list item; mixing the two in the same file still parses in file order.HH:MM whatsoever, the whole note counts as one record (a file-level fallback), so a note with media is never skipped just because it lacks timestamps. Ideal for "one note a day, a few photos each" — those photos become one card with a carousel.date → date components in the path → file mtime; the time comes from date → HH-MM in the filename. When neither yields one, only the date is shown — no time is invented.-1 suffix, and line breaks are preserved as-is.<video controls preload="metadata" playsinline> and never autoplay; tapping the video itself will not accidentally trigger the lightbox.The bottom-left filter button: by source + year + date range, combined with AND. Sources are multi-select: click a source to add it, click again to drop it, and several sources are unioned (全部 is always first and clears the selection; selecting none already means all). The source list is not hard-coded — it is generated dynamically from the sources actually present in the index, labelled with the display name you set in settings; when there is only one source the whole row hides itself, since "all / the only source" means nothing. The panel is collapsed by default and closes when you click outside or press Esc; a small dot appears on the button whenever a filter is active. Your filter selection is remembered — it lives in the plugin's on-device local storage (not in the plugin config), so it survives Obsidian restarts, plugin reloads and closing the tab; hit Reset to clear it. The bottom row of the panel shows the current counts plus a ⚙ Settings button that jumps straight to the plugin's settings page.
main.js, manifest.json and styles.css from the latest Releases<your vault>/.obsidian/plugins/visual-feed/If you have BRAT installed, simply add this repository's URL.
Requires Obsidian 1.13.0 or newer. The settings tab uses the declarative settings API introduced in 1.13.0 (which is also what makes the settings searchable from Obsidian's own settings search). On older versions, keep using 1.6.4 — the last release supporting 1.5.0+.
After that, creating / modifying / deleting / renaming files inside Obsidian updates the index incrementally — no manual rebuild needed. Images that arrive later from another device are picked up automatically too — even if the image lands after the note, or arrives while Obsidian was closed.
| Setting | Description |
|---|---|
| Source folders | The list of scanned folders; addable, editable, removable. Changing a path or the enabled state rebuilds the index; changing only the description patches it in place |
| Grouping | One post per record (a new post at every - HH:MM or ### HH:MM timestamp) / one post per file |
| Default layout | Single-column feed / masonry grid. The home-view button flips it live; this is what it opens with |
| Grid tile size | Small / medium / large (narrow screens step down one size so several columns still fit) |
| Grid unit | One tile per photo (records spread out, more like a photo wall) / one tile per record (swipe between images inside the tile; no count badge in grid) |
| Posts per batch | How many posts each scroll batch appends (5–40); smaller uses less memory |
| Max media height | Upper bound on the photo area's share of the screen (40–90vh) |
| Frame aspect ratio | Clamp range / fixed ratio / original — see above |
| Show note text | Whether to show the note body under the photo (up to 10 lines) |
| Caption character limit | Truncate the body at index time |
| Show source | Whether to show the source name (and its description) in the feed and in the lightbox |
| Card style | Turn it off for a borderless, flush, rounded look — more like a photo album |
| Publish folder | Default target folder for the publish flow |
| Attachment folder | Empty = follow Obsidian's attachment setting; ./ = next to the note; / = vault root |
| Auto-add publish folder as a source | Otherwise photos you just published will not show up in the feed |
| Open feed on startup | Automatically open Visual Feed when Obsidian starts |
The settings tab uses Obsidian 1.13.0's declarative settings API, so every setting above is also reachable from Obsidian's own settings search.
![[...]], , HTML tags), and groups them into posts by record or file. The index is persisted in the plugin's data.json.- HH:MM / ### HH:MM timestamp counts (25:80 does not), and a stray "12:56" mentioned mid-sentence does not either — it has to be a timestamp structure at the start of a line. The whole file is cut into blocks in order of appearance, so list items and headings mixed together are handled in file order; a file with no timestamp at all becomes a single file-level post (so a file that is only Frontmatter, with no fixed headings, is never dropped).mtime + size is compared and only changed files are re-parsed; when nothing changed, zero files are read.INDEX_VERSION and rebuilds once, automatically, right after the upgrade.app://local/ resource URLs to reference vault files directly; remote images (https://) work too.prefers-reduced-motion.npm install
npm run dev # watch mode (with sourcemaps)
npm run build # production build → main.js
npm run check # tsc --noEmit
npm test # bundle the tests + run the end-to-end suite
Two suites, 449 assertions in total (run-test 244 + run-dom-test 205):
test/run-test.ts — real-vault end to end. Runs a full index pass against a real Obsidian vault and asserts post/media counts, date validity, incremental read counts (0 file reads when nothing changed), correctness after create/modify/delete, the publish write path end to end, plus a set of pure CSS regression assertions (jsdom cannot measure layout, so styles.css is asserted as source text).test/run-dom-test.ts — jsdom UI smoke tests. Carousel, lazy loading, batched rendering, lightbox, filter panel, publish modal, layout switching, and the settings tab (declarative definitions, visible predicates, control read/write plus their side effects).Every write in both suites goes through an in-memory overlay — your real notes on disk are never touched.
The end-to-end suite needs a real Obsidian vault, and the path is never hard-coded:
# option 1: environment variable VISUAL_FEED_VAULT="/path/to/your/vault" npm test # option 2: put the vault path in test/vault.local (gitignored)No source folders to configure either: the suite picks a few of the vault's markdown-heaviest directories at runtime (detecting their record style on the way), so the repository contains no real folder names.
The release assets (main.js / manifest.json / styles.css) are built by GitHub Actions
together with a build provenance attestation (Sigstore-signed) — the community-directory
review recommends this, and anyone can verify that an asset really came from a tag's source:
gh attestation verify main.js --owner noctisvexx
To release:
manifest.json / package.json / versions.json (patch digit only)git commit && git pushgit tag 1.6.8 && git push origin 1.6.8 — the tag must match manifest.json's version exactly, with no v prefix (the directory enforces this; .github/workflows/release.yml checks it first and fails fast)gh release edit 1.6.8 --notes "..."A local npm run build is for testing and sideloading into your vault — it is no longer the distribution source.
https://) need a network connection and will not render offline.MIT © 2026 noctis