Conversation
The media route read the whole file into memory and always answered 200 without Accept-Ranges, and it capped every binary at the image-oriented mediaLimit (20 MB). That is what forces video preview into an ecosystem plugin with its own route: without 206 a <video> element cannot scrub, and a clip is routinely larger than the cap. - extract the route's pure helpers into src/media-route.ts (content types + the video extension set + Range/If-Range resolution), unit-tested in tests/media-route.spec.ts (RFC 9110: satisfiable -> 206, unsatisfiable -> 416, malformed/non-bytes -> ignored, multi-range -> first satisfiable); - stream with createReadStream instead of readFile, so a large file never lands in memory, and answer 200 / 206 / 416 with content-length, content-range, accept-ranges, etag and last-modified; - give video its own videoLimit deployment limit (default 2 GiB); - mediaTypeForPath stays re-exported from the plugin entry.
DSH 0.1.7's ui-sidebar-documentpreview owns the read-only previews, so this plugin yields image / pdf / binary-download and registers only the surfaces where it is not equivalent (markdown / html / the editable code catch-all). VIDEO is the missing cell on the host side — the reason the catalog points readers at the third-party dsh-video-preview plugin, which needs its own /video route exactly because this media route had no ranges. Per the same "keep what the host does not render" rule, video now stays here: - src/client/VideoView.tsx: a native <video controls> streaming through /sidebar/file, with a decode-failure fallback to the download affordance binary-download uses (containers like .mkv/.avi are frequently undecodable, and a dead black rectangle with no way out is the one outcome we refuse). The pane carries data-dsh-video-view="player" | "fallback"; - src/client/media-failure.ts: that fallback asks the route WHY it refused the file (the JSON envelope's message, else `HTTP <status>`, else the generic headline) instead of leaving the browser's own bare failure; - builtins/viewers.tsx gains the `video` descriptor (mediaUrl strategy, the same container table as the host's VIDEO_EXTENSIONS) with a glyph in icons.tsx (currentColor, skin contract intact) and a shared centering wrapper in sidebar.module.css; - viewerVideo / videoUnsupported / mediaLoadFailed added to all 20 dictionaries (zh + en in locales.ts, 19 third-language files); - tests/builtins.spec pins 3 -> 4 viewers plus the video extension table, and the mount lane seeds a 4 KiB .mp4 and proves the real route answers Range: bytes=0-3 with 206 + content-range (and the whole-file 200 + content-length the download affordance needs) — decode is deliberately not asserted, headless Chromium ships no proprietary codecs, so either pane state is accepted.
A session-scoped file address may spell the file RELATIVE to that session's workspace root — that is how the chat opens the files a turn produced — while every host route hands its path to `requireAbsolute` (ensureWorkspacePath's resolveSessionPath only projects Windows/WSL paths, it never joins a cwd). A relative path therefore dies with `"emote_verify/final/x.png" is not an absolute path`, and the browser's own fallback for a refused request is its `alt` text — the file name and nothing else. Video hits this the moment it is opened from the chat, and the pane's own diagnosis (media-failure.ts) is what made the cause visible. - new src/client/native/file-tab.ts `fileTabTarget()`: address -> tab target, resolving a session-relative path against that session's cwd (absolute paths, the absolute scope and an unknown cwd all pass through), wired into the native editor tab's params/session lookups so the tab, its title and every URL built from it agree; - src/client/api.ts `absolutePath()`: one choke point applied to mediaUrl, downloadUrl, fsTree, fsRead, fsWrite, fsRename and fsRemove, so an external caller handing in a relative path still reaches the host absolutely; - tests/media-path-resolution.spec.ts pins all four path states on both the media URLs and the tab target.
The built-in `video` viewer replaces it, and the two cannot coexist: both register the viewer id `video`, and `service.registerFileViewer` throws on a duplicate id — so leaving the catalog entry in place would hand readers an install that fails its registration the moment it mounts. The two locale keys the entry owned (pluginVideoPreviewName / Desc) go with it. Reverting this commit means restoring the entry + keys and giving one of the two viewers a different id.
- docs/external-plugin-guide.md: the built-in viewer inventory is 4 now
(video / markdown / html / code), the mediaUrl fetch-strategy row states
the byte-range contract, and the yield note names video as the exception
the host does not cover;
- README(.md/_EN.md): the viewer counts, the inline-preview cell ("video is
the one exception"), and the ecosystem table's dsh-video-preview row warns
that the capability is built in now and installing it alongside fails on
the duplicate viewer id;
- AGENTS.md: the yield section and the test-map counts;
- new design plan for the feature (why video, the range contract, the
relative-path fix, the retirement decision and its revert path).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景:宿主没有的那一格预览
DSH 0.1.7 的
ui-sidebar-documentpreview已经自带 code / excel / office / pdf / image / html / markdown / text 预览,本插件因此按既有判据(宿主不等价才自己留)让出了 image / pdf / binary-download,只保留 markdown / html / 可编辑的 code。视频是宿主唯一没有的只读预览格:推荐目录里的
dsh-video-preview(#126 引入)需要自己的/video路由,正是因为本插件的媒体路由没有字节范围——不支持206浏览器就不给拖进度条,而 20MB 的mediaLimit又会直接拒掉剪辑。按同一条判据,视频应该留在插件里。顺带修掉一个真机上暴露的路径缺陷(见下)。
改动(5 个提交)
feat(media):/sidebar/file支持 HTTP 字节范围src/media-route.ts(内容类型表 + 视频扩展名 + Range/If-Range 解析),tests/media-route.spec.ts覆盖 RFC 9110 边界:可满足 → 206、越界 → 416、非法/非 bytes 单位 → 忽略(整份)、多段 → 取第一个可满足段、后缀段、钳制、空资源;createReadStream流式(大文件不进内存),返回 200 / 206 / 416 并带content-length/content-range/accept-ranges/etag/last-modified;?download=1语义不变;videoLimit(默认 2 GiB),视频走它、其余仍走mediaLimit。feat(viewer):内置videoviewer(第 4 个内置 viewer)src/client/VideoView.tsx:原生<video controls>走媒体路由;解码失败回退到binary-download同款下载入口(.mkv/.avi在 Chromium 下经常解不了,不留"黑框死路"),面板带data-dsh-video-view="player" | "fallback";src/client/media-failure.ts:失败时再探一次同一 URL,把宿主的真实拒绝原因(工作区栅栏 / 超限 / 会话不存在)写进面板,而不是只给浏览器的兜底;viewerVideo/videoUnsupported/mediaLoadFailed三个词条进全部 20 份词典(zh + en + 19 份第三语言),键集相等由tests/locales.spec.ts守护。fix(paths):相对路径在到达宿主前解析(真机调试中发现的既有缺陷)dsh-resource://file/session/<id>/emote_verify/final/x.png,聊天打开产物文件就是这种),而宿主所有路由只接受绝对路径(requireAbsolute;ensureWorkspacePath只做 Windows/WSL 投影、不做 cwd 拼接)→ 400 拒绝,浏览器对失败的请求只画自己的兜底(<img>的alt=文件名);src/client/native/file-tab.ts的fileTabTarget()在 tab 层按会话 cwd 解析,src/client/api.ts的absolutePath()在mediaUrl/downloadUrl/fs.*兜一层;tests/media-path-resolution.spec.ts钉住相对 / 绝对 / 无 cwd / 非地址四态。chore(plugins):退役dsh-video-preview推荐条目(可以单独丢弃这一个提交)video,service.registerFileViewer对重复 id 抛错——内置之后继续推荐,等于给读者一条必然失败的安装路径;docs:指南 §5 的内置清单(3 → 4 viewer)与mediaUrl行的字节范围契约、README 中英文的计数与"视频是唯一例外"说明、AGENTS 的让出条目与测试地图、以及一份设计文档docs/plans/2026-09-24-builtin-video-preview-design.md。验证
pnpm typecheck/pnpm lint干净;pnpm test116 文件 / 1166 通过 / 9 跳过。pnpm test:mount,宿主钉 DSH 0.1.7-rc.1):7/7 通过,其中新增断言:seed 一个 4 KiB.mp4→ 经文件树打开 → 落在[data-dsh-video-view](player 或 fallback 皆可,无头 Chromium 无专有编解码器)→ 同一路由发Range: bytes=0-3断言 206 +content-range: bytes 0-3/4096+accept-ranges: bytes+video/mp4,再无 Range 请求断言 200 +content-length(下载入口依赖的那半边)。loadeddata、duration=2、320×240、响应206—— 即浏览器确实解码了路由流出的字节。这一条不在 CI 里(需要 ffmpeg 生成素材),仅作为人工验证记录。影响面 / 兼容性
/sidebar/file的响应多出Accept-Ranges/ETag/Last-Modified,旧的"整文件一次性写入"变成流式;?download=1行为不变(仍可 Range 续传)。video不参与那条让出清单(tests/builtins.spec.ts双向断言)。