Skip to content

feat: 内置视频预览(媒体路由字节范围 + video viewer) - #760

Open
StvLi wants to merge 5 commits into
omdsh-dev:mainfrom
StvLi:feat/builtin-video-preview
Open

StvLi wants to merge 5 commits into
omdsh-dev:mainfrom
StvLi:feat/builtin-video-preview

Conversation

@StvLi

@StvLi StvLi commented Sep 24, 2026

Copy link
Copy Markdown

背景:宿主没有的那一格预览

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 个提交)

  1. 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。
  2. feat(viewer):内置 video viewer(第 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 守护。
  3. 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 / 非地址四态。
  4. chore(plugins):退役 dsh-video-preview 推荐条目(可以单独丢弃这一个提交)
    • 两者 viewer id 都是 video,service.registerFileViewer 对重复 id 抛错——内置之后继续推荐,等于给读者一条必然失败的安装路径;
    • 若更希望视频继续留在生态:丢掉这个提交即可,其余四个提交仍自洽(README/指南里"已内置、勿同时安装"的提示我也一并写了,可以按立场保留或改写)。
  5. docs:指南 §5 的内置清单(3 → 4 viewer)与 mediaUrl 行的字节范围契约、README 中英文的计数与"视频是唯一例外"说明、AGENTS 的让出条目与测试地图、以及一份设计文档 docs/plans/2026-09-24-builtin-video-preview-design.md。

验证

  • pnpm typecheck / pnpm lint 干净;pnpm test 116 文件 / 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(下载入口依赖的那半边)。
  • 解码过半单独验证(本地 DSH 0.1.5 线 + 真实 VP9/WebM):loadeddata、duration=2、320×240、响应 206 —— 即浏览器确实解码了路由流出的字节。这一条不在 CI 里(需要 ffmpeg 生成素材),仅作为人工验证记录。

影响面 / 兼容性

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant