dsh-vidi 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,16 +8,16 @@ A video tool for DeepSeek Harness: watch live streams, videos and podcasts with
8
8
 
9
9
  - **Opens anything**: YouTube, Bilibili and other sites yt-dlp supports, Xiaoyuzhou podcast episodes, m3u8 / rtmp / srt streams, local files (drop them in), or a stream pushed from OBS.
10
10
  - **Subtitles start by themselves** when a video opens; pause and resume any time. One or two languages (a main line and a second line); the site's own subtitles can be shown too, or used as a reference.
11
- - **Two paces**: real-time shows lines as they are heard; polished arrives a little later with misheard words fixed from context.
11
+ - **Two paces**: real-time shows lines as they are heard; polished arrives a little later with misheard words fixed from context. In the subtitle list, polished lines carry a small tick; lines not polished yet are lighter.
12
12
  - **The subtitle model looks at the video**: polishing uses the page's title, description and comments, and — when the chosen model reads images — a frame of that stretch. It also keeps a glossary of names and terms for the video.
13
13
  - **Chat with DeepSeek beside the video**: grab this moment, cut this part, summarise, download the original — all in one conversation, with files saved in the video's folder.
14
14
  - **Restream**: push the video with its subtitles drawn into the picture to Bilibili, Douyin, Kuaishou, WeChat Channels, Douyu, Huya, YouTube, Twitch or any RTMP/SRT address — several at once; each platform connects and reconnects on its own.
15
- - **Export**: SRT / VTT subtitles, transcripts by line or paragraph, clips with subtitle tracks.
15
+ - **Download and export**: the original or a video with subtitle tracks, just the sound (M4A), SRT / VTT subtitles, transcripts by line or paragraph.
16
16
  - English and Chinese interface; works on phones.
17
17
 
18
18
  ## Install
19
19
 
20
- In DSH, open **Plugins → Install third-party plugin** and enter `dsh-vidi`. A **Video** button appears in the sidebar; pasting a video link in a chat also offers to open it.
20
+ In DSH, open **Plugins → Install third-party plugin** and enter `dsh-vidi`. A **Vidi video** button appears in the sidebar (the video page takes the main area like a chat, and the sidebar opens and closes as usual); pasting a video link in a chat also offers to open it.
21
21
 
22
22
  Needs DSH 0.2.2 or later.
23
23
 
package/README.zh-CN.md CHANGED
@@ -8,16 +8,16 @@ DeepSeek Harness 里的视频工具:看直播、视频和播客,同时出任
8
8
 
9
9
  - **打开任何来源**:YouTube、B 站等 yt-dlp 支持的网站,小宇宙节目,m3u8 / rtmp / srt 直播流,本地文件(直接拖进来),也可以用 OBS 推流过来。
10
10
  - **自动出字幕**:视频一打开就开始,可以随时暂停、继续。单语、双语都行,主字幕和副字幕各选一种语言;网站自带的字幕也能直接显示,或者作为参考。
11
- - **两种节奏**:实时模式边播边出;精修模式晚一点出,但会结合上下文把听错的词改对。
11
+ - **两种节奏**:实时模式边播边出;精修模式晚一点出,但会结合上下文把听错的词改对。字幕列表里精修过的句子带一个小勾,还没精修的颜色浅一些。
12
12
  - **字幕模型会看画面**:精修时结合视频页面的标题、简介、评论,所选模型能看图时还会带上那一段的画面,顺便整理这期视频的词表(人名、术语)。
13
13
  - **在视频旁边和 DeepSeek 对话**:截取此刻、剪下这段、总结要点、下载原片,都在同一个会话里完成,文件存在视频目录下。
14
14
  - **转播**:把视频连同画面里的字幕推到 B站、抖音、快手、视频号、斗鱼、虎牙、YouTube、Twitch 或任意 RTMP/SRT 地址,可以同时推多个平台;每个平台各自连接、断了各自重连。
15
- - **导出**:SRT / VTT 字幕,逐句或按段落的文字稿,带字幕轨的片段。
15
+ - **下载和导出**:原片或带字幕轨的视频、只要声音的音频(M4A)、SRT / VTT 字幕,逐句或按段落的文字稿。
16
16
  - 中英文界面,手机上也能用。
17
17
 
18
18
  ## 安装
19
19
 
20
- 在 DSH 里打开 **插件 → 安装第三方插件**,填 `dsh-vidi`。装好后侧栏会出现 **视频** 按钮;在对话里贴视频链接,输入框上方也会出现「在视频里打开」。
20
+ 在 DSH 里打开 **插件 → 安装第三方插件**,填 `dsh-vidi`。装好后侧栏会出现 **Vidi 视频** 按钮,视频页和对话一样占主区域,侧栏照常收起、展开;在对话里贴视频链接,输入框上方也会出现「在视频里打开」。
21
21
 
22
22
  需要 DSH 0.2.2 或更新的版本。
23
23
 
package/lib/client.js CHANGED
@@ -247,6 +247,14 @@ const Icon = {
247
247
  ...P,
248
248
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" })
249
249
  }),
250
+ check: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
251
+ viewBox: "0 0 24 24",
252
+ width: "12",
253
+ height: "12",
254
+ ...P,
255
+ strokeWidth: 2.6,
256
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
257
+ }),
250
258
  plus: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
251
259
  viewBox: "0 0 24 24",
252
260
  width: "14",
@@ -33299,9 +33307,12 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33299
33307
  const idleTimer = (0, react.useRef)();
33300
33308
  /** Where playback was, so a new source (quality switch) continues from there. */
33301
33309
  const lastTime = (0, react.useRef)(0);
33302
- (0, react.useImperativeHandle)(ref, () => ({ seek: (time) => {
33303
- if (video.current) video.current.currentTime = Math.max(0, time);
33304
- } }), []);
33310
+ (0, react.useImperativeHandle)(ref, () => ({
33311
+ seek: (time) => {
33312
+ if (video.current) video.current.currentTime = Math.max(0, time);
33313
+ },
33314
+ closeMenu: () => setMenu(null)
33315
+ }), []);
33305
33316
  (0, react.useEffect)(() => {
33306
33317
  const el = video.current;
33307
33318
  if (!src || !el) return;
@@ -33836,6 +33847,11 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33836
33847
  className: "txt",
33837
33848
  children: m.label
33838
33849
  }),
33850
+ m.id === "cc" && ccOn && current?.polished && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33851
+ className: "dshl-okmark",
33852
+ title: t("player.polished"),
33853
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.check, {})
33854
+ }),
33839
33855
  m.label && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "caret" })
33840
33856
  ]
33841
33857
  }, m.id)),
@@ -33890,9 +33906,10 @@ function registry() {
33890
33906
  * itself stays in this package; companions only add.
33891
33907
  */
33892
33908
  const vidiUi = {
33893
- version: 1,
33909
+ version: 2,
33894
33910
  panels: registry(),
33895
- playerMenus: registry()
33911
+ playerMenus: registry(),
33912
+ benches: registry()
33896
33913
  };
33897
33914
 
33898
33915
  // src/client/RestreamPanel.tsx
@@ -34589,7 +34606,7 @@ const siteName = (url) => /youtu\.?be/.test(url) ? "YouTube" : /bilibili|b23\.tv
34589
34606
  const media = (dirId, file) => `/live/media/${encodeURIComponent(dirId)}/${encodeURIComponent(file)}`;
34590
34607
  function LiveView(props) {
34591
34608
  const { sessionId, t, chatFace, chatSource, onClose, pending, onPendingDone } = props;
34592
- const pageMode = !!onClose;
34609
+ const pageMode = !!onClose || !!props.page;
34593
34610
  const [input, setInput] = (0, react.useState)("");
34594
34611
  const [lines, setLinesRaw] = (0, react.useState)(() => store.get("lines", [browserLang(), "src"]));
34595
34612
  const setLines = (l) => {
@@ -34632,6 +34649,8 @@ function LiveView(props) {
34632
34649
  const [tab, setTab] = (0, react.useState)("transcript");
34633
34650
  const [query, setQuery] = (0, react.useState)("");
34634
34651
  const [trMode, setTrMode] = (0, react.useState)("lines");
34652
+ /** Video page: the workbench shown in place of the chat (companion plugins), if any. */
34653
+ const [bench, setBench] = (0, react.useState)(null);
34635
34654
  /** Reading view: which languages to show ('src' = original); empty = follow the subtitle lines. */
34636
34655
  const [readPick, setReadPick] = (0, react.useState)([]);
34637
34656
  const [now, setNow] = (0, react.useState)(0);
@@ -34846,19 +34865,6 @@ function LiveView(props) {
34846
34865
  const stop = async () => {
34847
34866
  if (job) await post("stop", { jobId: job.id }).catch(() => {});
34848
34867
  };
34849
- const downloadOnly = async () => {
34850
- const c = classify(input);
34851
- if (c?.source !== "url") return;
34852
- try {
34853
- await post("download", {
34854
- conversationId: sessionId,
34855
- url: c.url
34856
- });
34857
- refreshTasks();
34858
- } catch (e) {
34859
- setError(e.message);
34860
- }
34861
- };
34862
34868
  const onFile = async (file) => {
34863
34869
  setError("");
34864
34870
  setUploading(0);
@@ -35036,7 +35042,6 @@ function LiveView(props) {
35036
35042
  const statusDetail = job?.status === "running" && !job.message && !job.vod ? CLOCK(Math.max(0, (tick - job.startedAt) / 1e3)) : "";
35037
35043
  const missing = deps.filter((d) => !d.ok && (d.name === "ffmpeg" || d.name === "yt-dlp" || (engine || "local") === "local" && d.name.startsWith("whisper")));
35038
35044
  const needsModel = !hasTranslator && shownLines.some((l) => l !== "src");
35039
- const canDownloadLink = classify(input)?.source === "url" && !/^(rtmps?|srt|rtsp|udp):/i.test(classify(input).url);
35040
35045
  const hasMain = !!job && !(job.status === "error" && !job.files.length);
35041
35046
  const emptyBox = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35042
35047
  className: "dshl-drop",
@@ -35112,6 +35117,8 @@ function LiveView(props) {
35112
35117
  const inline = pageMode && tab === "transcript" && !!job;
35113
35118
  const extPanels = (0, react.useSyncExternalStore)(vidiUi.panels.subscribe, vidiUi.panels.get);
35114
35119
  const extMenus = (0, react.useSyncExternalStore)(vidiUi.playerMenus.subscribe, vidiUi.playerMenus.get);
35120
+ const extBenches = (0, react.useSyncExternalStore)(vidiUi.benches.subscribe, vidiUi.benches.get);
35121
+ const activeBench = pageMode && job ? extBenches.find((b) => b.id === bench) : void 0;
35115
35122
  const view = job ? {
35116
35123
  job,
35117
35124
  cues,
@@ -35120,7 +35127,9 @@ function LiveView(props) {
35120
35127
  seek: (s) => player.current?.seek(s),
35121
35128
  style: subStyle,
35122
35129
  lines: shownLines,
35123
- ui
35130
+ ui,
35131
+ bench: activeBench?.id ?? null,
35132
+ openBench: setBench
35124
35133
  } : void 0;
35125
35134
  /** Subtitle lines and glossary: under the player on the video page, beside it in a conversation. */
35126
35135
  const transcriptBox = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
@@ -35188,21 +35197,30 @@ function LiveView(props) {
35188
35197
  className: "dshl-muted pad",
35189
35198
  children: t("tr.empty")
35190
35199
  }), shown.map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
35191
- className: `dshl-line ${c.id === activeId ? "active" : ""} ${c.polished ? "" : "raw"}`,
35200
+ className: `dshl-line ${c.id === activeId ? "active" : ""} ${c.polished ? "ok" : "raw"}`,
35192
35201
  onClick: () => player.current?.seek(c.start),
35193
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35194
- className: "ts",
35195
- children: CLOCK(c.start)
35196
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35197
- className: "txt",
35198
- children: shownLines.map((k, i) => {
35199
- const v = lineText(c, k);
35200
- return v ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35201
- className: i === 0 ? "main" : "sub",
35202
- children: v
35203
- }, k) : null;
35202
+ title: c.polished ? t("tr.polished") : t("tr.raw"),
35203
+ children: [
35204
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35205
+ className: "ts",
35206
+ children: CLOCK(c.start)
35207
+ }),
35208
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35209
+ className: "txt",
35210
+ children: shownLines.map((k, i) => {
35211
+ const v = lineText(c, k);
35212
+ return v ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35213
+ className: i === 0 ? "main" : "sub",
35214
+ children: v
35215
+ }, k) : null;
35216
+ })
35217
+ }),
35218
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35219
+ className: "mark",
35220
+ "aria-hidden": "true",
35221
+ children: c.polished && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.check, {})
35204
35222
  })
35205
- })]
35223
+ ]
35206
35224
  }, c.id))]
35207
35225
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35208
35226
  className: "dshl-read",
@@ -35244,7 +35262,7 @@ function LiveView(props) {
35244
35262
  })
35245
35263
  ] });
35246
35264
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35247
- className: `dshl ${theater ? "theater" : ""} ${pageMode ? "page" : ""}`,
35265
+ className: `dshl ${theater && !activeBench ? "theater" : ""} ${pageMode ? "page" : ""} ${activeBench ? "bench" : ""}`,
35248
35266
  "data-dsh-own-drop": "dsh-vidi",
35249
35267
  onDragEnter: (e) => {
35250
35268
  if (e.dataTransfer.types.includes("Files")) e.stopPropagation();
@@ -35315,12 +35333,6 @@ function LiveView(props) {
35315
35333
  })
35316
35334
  ]
35317
35335
  }),
35318
- canDownloadLink && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35319
- className: "dshl-btn icon",
35320
- onClick: downloadOnly,
35321
- title: t("in.download.tip"),
35322
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.download, {})
35323
- }),
35324
35336
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35325
35337
  className: "dshl-btn primary",
35326
35338
  onClick: () => start(),
@@ -35376,13 +35388,6 @@ function LiveView(props) {
35376
35388
  children: t("st.retry")
35377
35389
  })]
35378
35390
  }),
35379
- tasks.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35380
- className: "dshl-tasks",
35381
- children: tasks.filter((x) => x.kind === "original").slice(0, 3).map((x) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskRow, {
35382
- task: x,
35383
- t
35384
- }, x.id))
35385
- }),
35386
35391
  !hasMain && !pageMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
35387
35392
  className: "dshl-empty",
35388
35393
  children: emptyBox
@@ -35474,7 +35479,7 @@ function LiveView(props) {
35474
35479
  id: `ext:${m.id}`,
35475
35480
  icon: m.icon,
35476
35481
  title: m.title(ui),
35477
- content: view ? m.render(view, () => {}) : null
35482
+ content: view ? m.render(view, () => player.current?.closeMenu()) : null
35478
35483
  })),
35479
35484
  {
35480
35485
  id: "download",
@@ -35528,7 +35533,10 @@ function LiveView(props) {
35528
35533
  ] })
35529
35534
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
35530
35535
  className: "dshl-side",
35531
- children: pageMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChatPanel, {
35536
+ children: activeBench && view ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35537
+ className: "dshl-benchbox",
35538
+ children: activeBench.render(view)
35539
+ }) : pageMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChatPanel, {
35532
35540
  face: chatFace,
35533
35541
  source: chatSource,
35534
35542
  now,
@@ -35729,36 +35737,6 @@ function UsagePill({ usage, t, ui }) {
35729
35737
  })]
35730
35738
  });
35731
35739
  }
35732
- function TaskRow({ task, t }) {
35733
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35734
- className: `dshl-task ${task.status}`,
35735
- children: [
35736
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.download, {}),
35737
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35738
- className: "name",
35739
- children: task.title ?? task.file
35740
- }),
35741
- task.status === "running" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35742
- className: "bar",
35743
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { style: { width: `${task.pct}%` } })
35744
- }),
35745
- task.status === "running" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35746
- className: "pct",
35747
- children: t("dl.pct").replace("{p}", String(task.pct))
35748
- }),
35749
- task.status === "done" && task.file && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
35750
- href: media(task.dirId, task.file),
35751
- download: true,
35752
- children: task.file
35753
- }),
35754
- task.status === "error" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35755
- className: "bad",
35756
- title: task.error,
35757
- children: t("dl.failed")
35758
- })
35759
- ]
35760
- });
35761
- }
35762
35740
  /** Download: choose what (video or subtitles), then one button that says exactly what you get. */
35763
35741
  function DownloadPanel(props) {
35764
35742
  const { job, lines, tasks, sessionId, onTask, langName, siteChips, t } = props;
@@ -35781,6 +35759,13 @@ function DownloadPanel(props) {
35781
35759
  ].filter(Boolean).join(" · ");
35782
35760
  const saveAs = `${title}(${[qLabel, withSubs ? subsLabel : ""].filter(Boolean).join(",")}).mp4`;
35783
35761
  const task = tasks.find((x) => x.kind === "video" && x.saveAs === saveAs);
35762
+ const audioAs = `${title}(${t("dl.tab.audio")})`;
35763
+ const audioTask = tasks.find((x) => x.kind === "audio" && x.saveAs === audioAs);
35764
+ const goAudio = () => post("audio", {
35765
+ conversationId: sessionId,
35766
+ jobId: job.id,
35767
+ saveAs: audioAs
35768
+ }).then(onTask).catch(() => onTask());
35784
35769
  const go = () => post("video", {
35785
35770
  conversationId: sessionId,
35786
35771
  jobId: job.id,
@@ -35800,15 +35785,23 @@ function DownloadPanel(props) {
35800
35785
  className: "dshl-dl",
35801
35786
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35802
35787
  className: "dshl-seg wide",
35803
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35804
- className: tab === "video" ? "on" : "",
35805
- onClick: () => setTab("video"),
35806
- children: t("dl.tab.video")
35807
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35808
- className: tab === "subs" ? "on" : "",
35809
- onClick: () => setTab("subs"),
35810
- children: t("dl.tab.subs")
35811
- })]
35788
+ children: [
35789
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35790
+ className: tab === "video" ? "on" : "",
35791
+ onClick: () => setTab("video"),
35792
+ children: t("dl.tab.video")
35793
+ }),
35794
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35795
+ className: tab === "audio" ? "on" : "",
35796
+ onClick: () => setTab("audio"),
35797
+ children: t("dl.tab.audio")
35798
+ }),
35799
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35800
+ className: tab === "subs" ? "on" : "",
35801
+ onClick: () => setTab("subs"),
35802
+ children: t("dl.tab.subs")
35803
+ })
35804
+ ]
35812
35805
  }), tab === "video" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
35813
35806
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35814
35807
  className: "grp",
@@ -35856,6 +35849,37 @@ function DownloadPanel(props) {
35856
35849
  className: "err",
35857
35850
  children: task.error
35858
35851
  })
35852
+ ] }) : tab === "audio" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
35853
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
35854
+ className: "dshl-dl-note",
35855
+ children: t("dl.audio.d")
35856
+ }),
35857
+ audioTask?.status === "running" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35858
+ className: "dshl-btn block progress",
35859
+ style: { ["--p"]: `${audioTask.pct}%` },
35860
+ children: audioTask.stage === "mux" ? t("dl.stage.audio") : t("dl.stage.audioDl").replace("{p}", String(audioTask.pct))
35861
+ }) : audioTask?.status === "done" && audioTask.file ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
35862
+ className: "dshl-btn block primary",
35863
+ href: media(audioTask.dirId, audioTask.file),
35864
+ download: `${audioAs}${audioTask.file.slice(audioTask.file.lastIndexOf("."))}`,
35865
+ children: [
35866
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.download, {}),
35867
+ " ",
35868
+ t("dl.save")
35869
+ ]
35870
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
35871
+ className: "dshl-btn block primary",
35872
+ onClick: () => void goAudio(),
35873
+ children: [
35874
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.download, {}),
35875
+ " ",
35876
+ t("dl.go").replace("{what}", t("dl.audio.what"))
35877
+ ]
35878
+ }),
35879
+ audioTask?.status === "error" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
35880
+ className: "err",
35881
+ children: audioTask.error
35882
+ })
35859
35883
  ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
35860
35884
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35861
35885
  className: "grp",
@@ -36137,8 +36161,6 @@ const en = {
36137
36161
  "st.ready": "Ready",
36138
36162
  "in.start": "Start",
36139
36163
  "in.stop": "Stop",
36140
- "in.download": "Download",
36141
- "in.download.tip": "Download the video only",
36142
36164
  "in.file": "File",
36143
36165
  "in.file.tip": "Open a video or audio file",
36144
36166
  "in.push": "Push",
@@ -36211,6 +36233,11 @@ const en = {
36211
36233
  "subs.progressLive": "Generating subtitles · {t}",
36212
36234
  "dl.tab.video": "Video",
36213
36235
  "dl.tab.subs": "Subtitles",
36236
+ "dl.tab.audio": "Audio",
36237
+ "dl.audio.d": "Just the sound, for podcasts, listening on the go or other transcription tools.",
36238
+ "dl.audio.what": "Audio",
36239
+ "dl.stage.audioDl": "Downloading audio {p}%",
36240
+ "dl.stage.audio": "Taking out the sound…",
36214
36241
  "dl.quality": "Picture",
36215
36242
  "dl.q.original": "Original",
36216
36243
  "dl.q.original.d": "Best quality from the site",
@@ -36260,6 +36287,9 @@ const en = {
36260
36287
  "chat.p.summary": "Summarise the key points of this video so far from the subtitles.",
36261
36288
  "chat.p.download": "Download the original of this video.",
36262
36289
  "page.back": "Back to chats",
36290
+ "tr.polished": "Polished",
36291
+ "tr.raw": "First pass; polishing comes a little later",
36292
+ "player.polished": "This line is polished",
36263
36293
  "page.open": "Vidi video",
36264
36294
  "page.failed": "The video page could not open: {e}",
36265
36295
  "side.subs": "Subtitles {p}%",
@@ -36453,8 +36483,6 @@ const zh = {
36453
36483
  "st.ready": "已就绪",
36454
36484
  "in.start": "开始",
36455
36485
  "in.stop": "停止",
36456
- "in.download": "下载",
36457
- "in.download.tip": "只下载视频",
36458
36486
  "in.file": "文件",
36459
36487
  "in.file.tip": "打开视频或音频文件",
36460
36488
  "in.push": "推流",
@@ -36527,6 +36555,11 @@ const zh = {
36527
36555
  "subs.progressLive": "正在生成字幕 · 已到 {t}",
36528
36556
  "dl.tab.video": "视频",
36529
36557
  "dl.tab.subs": "字幕",
36558
+ "dl.tab.audio": "音频",
36559
+ "dl.audio.d": "只要声音,适合当播客听,或交给别的转写工具。",
36560
+ "dl.audio.what": "音频",
36561
+ "dl.stage.audioDl": "正在下载音频 {p}%",
36562
+ "dl.stage.audio": "正在提取声音…",
36530
36563
  "dl.quality": "画质",
36531
36564
  "dl.q.original": "原片",
36532
36565
  "dl.q.original.d": "网站最高画质",
@@ -36576,6 +36609,9 @@ const zh = {
36576
36609
  "chat.p.summary": "根据字幕,总结这个视频到目前为止的要点。",
36577
36610
  "chat.p.download": "下载这个视频的原片。",
36578
36611
  "page.back": "返回对话",
36612
+ "tr.polished": "已精修",
36613
+ "tr.raw": "第一轮识别,稍后精修",
36614
+ "player.polished": "这句字幕已精修",
36579
36615
  "page.open": "Vidi 视频",
36580
36616
  "page.failed": "视频页打不开:{e}",
36581
36617
  "side.subs": "字幕 {p}%",
@@ -36836,9 +36872,22 @@ const CSS = `
36836
36872
  with them, and its empty space moves the window like the app's own top strip. Full screen and
36837
36873
  the web page need none of this. */
36838
36874
  .dshl-pagewrap { top: var(--dsh-frame-chrome-top, 0px); }
36839
- html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top { padding: 4px 16px 8px 84px; -webkit-app-region: drag; app-region: drag; }
36840
- html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(button, input, label, a, select, .dshl-pick) { -webkit-app-region: no-drag; app-region: no-drag; }
36841
- @media (max-width: 600px) { html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top { padding-left: 80px; } }
36875
+ html[data-platform='darwin']:not([data-fullscreen]) .dshl-pagewrap .dshl.page .dshl-top { padding: 4px 16px 8px 84px; }
36876
+ @media (max-width: 600px) { html[data-platform='darwin']:not([data-fullscreen]) .dshl-pagewrap .dshl.page .dshl-top { padding-left: 80px; } }
36877
+ /* Newer DS-Harness: the page is a main panel beside the app's sidebar. When the sidebar is closed
36878
+ the app puts its own controls (traffic lights, sidebar toggle, new chat) at the top-left and
36879
+ publishes how much room they take: the top bar starts after them. */
36880
+ .dshl-panelwrap { height: 100%; min-height: 0; background: var(--dsw-alias-bg-base, #fff); }
36881
+ .dshl-panelwrap .dshl.page .dshl-top { padding-left: max(20px, var(--dsh-frame-leading-clearance, 0px)); }
36882
+ /* Web page in a narrow window (sidebar as a drawer): the app gives its opener a row of its own above
36883
+ the page, so the bar starts at the edge. The desktop app keeps the opener on the bar's row instead
36884
+ (beside the traffic lights on macOS), and the clearance above leaves room for it. */
36885
+ html:not([data-platform]) [data-sidebar-drawer] .dshl-panelwrap .dshl.page .dshl-top { padding-left: 12px; }
36886
+ /* macOS: the app's window controls (traffic lights, sidebar toggle, new chat) are centred at y 25 in
36887
+ every state, full screen included; the bar's 40px row is centred on the same line. */
36888
+ html[data-platform='darwin'] .dshl-panelwrap .dshl.page .dshl-top { padding-top: 5px; padding-bottom: 8px; }
36889
+ html[data-platform='darwin'] .dshl.page .dshl-top { -webkit-app-region: drag; app-region: drag; }
36890
+ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, select, .dshl-pick) { -webkit-app-region: no-drag; app-region: no-drag; }
36842
36891
  .dshl.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
36843
36892
  .dshl.page .dshl-main { flex: 1; min-height: 0; grid-template-columns: minmax(0, 1fr) clamp(320px, 30vw, 420px); align-items: stretch; padding-bottom: 16px; }
36844
36893
  /* Video page: player on top, then the status row, then subtitles/glossary filling what is left; chat on the right. */
@@ -36846,6 +36895,9 @@ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(but
36846
36895
  .dshl.page .dshl-col > .dshl-drop { flex: 1; aspect-ratio: auto; min-height: 0; }
36847
36896
  .dshl.page .dshl-stage { flex: none; width: 100%; max-height: max(240px, calc(100cqh - 270px)); }
36848
36897
  .dshl.page .dshl-side { position: static; height: 100%; max-height: none; }
36898
+ /* Workbench open: the video and its subtitles keep a column on the left; the bench takes the rest. */
36899
+ .dshl.page.bench .dshl-main { grid-template-columns: clamp(340px, 32vw, 520px) minmax(0, 1fr); }
36900
+ .dshl-benchbox { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; }
36849
36901
  .dshl-under { display: flex; flex-direction: column; flex: 1; min-height: 210px; overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: var(--l1); }
36850
36902
  .dshl-under .dshl-sidetabs { align-items: center; padding: 0 12px 0 10px; }
36851
36903
  .dshl-under .dshl-sidetabs > button { flex: none; padding: 0 14px; }
@@ -36857,6 +36909,9 @@ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(but
36857
36909
  .dshl.page.theater .dshl-stage { max-height: 100cqh; }
36858
36910
  @media (max-width: 1000px) {
36859
36911
  .dshl.page { display: block; overflow: auto; }
36912
+ .dshl.page.bench .dshl-main { grid-template-columns: minmax(0, 1fr); }
36913
+ .dshl.page.bench .dshl-side { height: auto; min-height: 0; }
36914
+ .dshl-benchbox { overflow: visible; }
36860
36915
  .dshl.page .dshl-main { grid-template-columns: minmax(0, 1fr); align-items: start; }
36861
36916
  .dshl.page .dshl-col { container-type: normal; }
36862
36917
  .dshl.page .dshl-col > .dshl-drop { min-height: 320px; aspect-ratio: 16 / 9; }
@@ -36865,6 +36920,10 @@ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(but
36865
36920
  .dshl.page .dshl-side { height: 70vh; min-height: 420px; }
36866
36921
  }
36867
36922
  @media (max-width: 600px) {
36923
+ /* Tabs keep their row; the search, line/reading switch and copy move to one of their own. */
36924
+ .dshl-under .dshl-sidetabs { flex-wrap: wrap; row-gap: 0; }
36925
+ .dshl-tools.inline { flex: 1 0 100%; justify-content: flex-start; padding: 6px 0 8px; }
36926
+ .dshl-tools.inline .dshl-field { flex: 1 1 auto; }
36868
36927
  .dshl-top { padding: 10px 12px; }
36869
36928
  .dshl-bar { flex-wrap: nowrap; gap: 6px; }
36870
36929
  .dshl-bar .dshl-field { flex: 1 1 0; height: 38px; padding: 0 4px 0 10px; gap: 6px; }
@@ -37056,6 +37115,7 @@ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(but
37056
37115
  .dshl-dl { display: grid; gap: 14px; padding: 0 6px; }
37057
37116
  .dshl-dl .grp { display: grid; gap: 8px; }
37058
37117
  .dshl-dl .cap { color: var(--muted); font-size: 12px; font-weight: 550; }
37118
+ .dshl-dl-note { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.6; }
37059
37119
  .dshl-dl .cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
37060
37120
  .dshl-card-opt { display: grid; align-content: start; gap: 2px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--l1); color: var(--ink); text-align: left; cursor: pointer; }
37061
37121
  .dshl-card-opt b { font-size: 13px; font-weight: 600; }
@@ -37112,7 +37172,7 @@ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(but
37112
37172
  .dshl-lines { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; margin: 0 -6px; overflow: auto; }
37113
37173
  .dshl:not(.page) .dshl-lines { max-height: calc(100vh - 330px); }
37114
37174
  @media (max-width: 1000px) { .dshl:not(.page) .dshl-lines { max-height: 60vh; } }
37115
- .dshl-line { display: grid; grid-template-columns: 44px 1fr; gap: 6px; padding: 7px 6px; border: 0; border-radius: 8px; background: none; color: var(--ink); text-align: left; cursor: pointer; }
37175
+ .dshl-line { display: grid; grid-template-columns: 44px 1fr 14px; gap: 6px; padding: 7px 6px; border: 0; border-radius: 8px; background: none; color: var(--ink); text-align: left; cursor: pointer; }
37116
37176
  .dshl-lines > .pad { padding: 16px 6px; }
37117
37177
  .dshl-line:hover { background: var(--hover); }
37118
37178
  .dshl-line.active { background: color-mix(in srgb, var(--accent) 10%, transparent); }
@@ -37122,6 +37182,12 @@ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(but
37122
37182
  .dshl-line .main { font-weight: 550; }
37123
37183
  .dshl-line .sub { color: var(--muted); font-size: 13px; }
37124
37184
  .dshl-line.raw .main, .dshl-line.raw .sub { opacity: .62; }
37185
+ /* Polished lines: a quiet tick at the end; first-pass lines stay lighter until polished. */
37186
+ .dshl-line .mark { display: flex; padding-top: 4px; color: var(--faint); opacity: .7; }
37187
+ .dshl-line.active .mark { color: var(--accent); }
37188
+ /* Player: a faint tick on the subtitle button while the line on screen is polished (explained on hover). */
37189
+ .dshl-okmark { display: flex; margin: 0 -2px 0 1px; color: rgba(255,255,255,.45); }
37190
+ .dshl-okmark svg { width: 9px; height: 9px; }
37125
37191
 
37126
37192
  /* Reading (paragraphs by speaker) */
37127
37193
  .dshl-read { display: grid; align-content: start; gap: 16px; flex: 1; min-height: 0; overflow: auto; padding: 2px 2px 12px; }
@@ -37270,6 +37336,7 @@ let done = false;
37270
37336
  function ensureStyles() {
37271
37337
  if (done || typeof document === "undefined") return;
37272
37338
  done = true;
37339
+ for (const old of document.querySelectorAll("style[data-dsh-live]")) old.remove();
37273
37340
  const el = document.createElement("style");
37274
37341
  el.dataset.dshLive = "";
37275
37342
  el.textContent = CSS;
@@ -37278,6 +37345,26 @@ function ensureStyles() {
37278
37345
 
37279
37346
  // src/client/VideoPage.tsx
37280
37347
  const PAGE_SESSION = "dsh-live.pageSession";
37348
+ /** The id of the video page among DS-Harness's main panels (beside the conversation, Plugins…). */
37349
+ const PANEL_ID = "vidi";
37350
+ let panelHost;
37351
+ function setPanelHost(host) {
37352
+ panelHost = host;
37353
+ if (!host) return () => {};
37354
+ const sync = () => {
37355
+ const here = host.info.getSnapshot().activePanelId === PANEL_ID;
37356
+ if (pageStore.get().open !== here) pageStore.set({
37357
+ open: here,
37358
+ ...here ? {} : { pending: void 0 }
37359
+ });
37360
+ };
37361
+ const off = host.info.subscribe(sync);
37362
+ return () => {
37363
+ off();
37364
+ if (panelHost === host) panelHost = void 0;
37365
+ };
37366
+ }
37367
+ const inPanel = () => !!panelHost;
37281
37368
  const samePath = (a, b) => !!a && !!b && a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
37282
37369
  /**
37283
37370
  * The video page's own conversation: reused while it exists, created when not.
@@ -37327,6 +37414,16 @@ async function showPage(sessions, opts) {
37327
37414
  const current = sessions.current();
37328
37415
  const sid = opts.sessionId ?? await pageSession(sessions, opts.workspaces, opts.title ?? "Video");
37329
37416
  if (sid !== current) sessions.open(sid);
37417
+ if (panelHost) {
37418
+ pageStore.set({
37419
+ open: true,
37420
+ sessionId: sid,
37421
+ returnTo: void 0,
37422
+ pending: opts.pending
37423
+ });
37424
+ panelHost.select(PANEL_ID);
37425
+ return;
37426
+ }
37330
37427
  const prev = pageStore.get();
37331
37428
  pageStore.set({
37332
37429
  open: true,
@@ -37343,7 +37440,27 @@ function closePage(sessions) {
37343
37440
  });
37344
37441
  if (returnTo && sessions.has(returnTo)) sessions.open(returnTo);
37345
37442
  }
37346
- /** Full-window video page (shell.overlay): player, subtitles, terms and the chat with DeepSeek. */
37443
+ /** The video page as a main panel (newer DS-Harness): the app's sidebar and its toggle stay. */
37444
+ function VideoPanel(props) {
37445
+ const { sessions, t } = props;
37446
+ const page = (0, react.useSyncExternalStore)(pageStore.subscribe, pageStore.get);
37447
+ const face = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.face(page.sessionId) : void 0);
37448
+ const chat = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.chat(page.sessionId) : void 0);
37449
+ if (!page.sessionId) return null;
37450
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
37451
+ className: "dshl-panelwrap",
37452
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveView, {
37453
+ sessionId: page.sessionId,
37454
+ t,
37455
+ page: true,
37456
+ chatFace: face,
37457
+ chatSource: chat,
37458
+ pending: page.pending,
37459
+ onPendingDone: () => pageStore.set({ pending: void 0 })
37460
+ }, page.sessionId)
37461
+ });
37462
+ }
37463
+ /** Full-window video page (shell.overlay, older DS-Harness): player, subtitles, terms and the chat with DeepSeek. */
37347
37464
  function VideoPage(props) {
37348
37465
  const { sessions, t } = props;
37349
37466
  const page = (0, react.useSyncExternalStore)(pageStore.subscribe, pageStore.get);
@@ -37354,7 +37471,7 @@ function VideoPage(props) {
37354
37471
  role: "alert",
37355
37472
  children: t("page.failed").replace("{e}", page.error)
37356
37473
  });
37357
- if (!page.open || !page.sessionId) return null;
37474
+ if (inPanel() || !page.open || !page.sessionId) return null;
37358
37475
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
37359
37476
  className: "dshl-pagewrap",
37360
37477
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveView, {
@@ -37459,7 +37576,8 @@ function PasteChip(props) {
37459
37576
  /**
37460
37577
  * dsh-vidi browser half:
37461
37578
  * - a "Video" tab beside Chat in every conversation (conversation.view);
37462
- * - a "Watch video" button in the sidebar footer that opens the full video page (shell.overlay);
37579
+ * - a "Vidi video" button in the sidebar footer that opens the video page: one of the app's
37580
+ * main panels where DS-Harness has them (ctx.layout), else a full-window page (shell.overlay);
37463
37581
  * - a prompt above the composer when a video link is pasted (conversation.input.dock);
37464
37582
  * - "show this video" requests from DeepSeek's live_open tool and the /video command.
37465
37583
  */
@@ -37555,6 +37673,24 @@ function apply(ctx) {
37555
37673
  t
37556
37674
  })
37557
37675
  }, VideoPage));
37676
+ ctx.inject(["layout"], (c) => {
37677
+ c.effect(() => setPanelHost({
37678
+ select: (id) => c.layout.selectPanel(id),
37679
+ info: {
37680
+ getSnapshot: () => c.layout.panelInfo.getSnapshot(),
37681
+ subscribe: (fn) => c.layout.panelInfo.subscribe(fn)
37682
+ }
37683
+ }), "dsh-vidi: main panel host");
37684
+ c.slots.inject("main", () => c.slots.register({
37685
+ name: "main",
37686
+ key: PANEL_ID,
37687
+ locale: NS,
37688
+ inject: () => ({
37689
+ sessions: sessions(),
37690
+ t
37691
+ })
37692
+ }, VideoPanel));
37693
+ });
37558
37694
  ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
37559
37695
  name: "conversation.input.dock",
37560
37696
  id: "dsh-vidi.paste",
package/lib/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync, chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir, networkInterfaces } from "node:os";
3
- import { basename, extname, join, normalize, resolve, sep } from "node:path";
3
+ import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { execFile, spawn } from "node:child_process";
6
6
  import { promisify } from "node:util";
@@ -161,6 +161,16 @@ function defaultExports(targets) {
161
161
  }
162
162
  // src/glossary.ts
163
163
  const MAX = 80;
164
+ /** Does the text mention the term (its correct form or a known wrong one)? Latin words match whole words only. */
165
+ function mentions(text, t) {
166
+ return [t.src, ...t.aliases ?? []].some((w) => {
167
+ const k = w.trim().toLowerCase();
168
+ if (!k) return false;
169
+ if (!/^[\x00-\x7f]+$/.test(k)) return text.includes(k);
170
+ const esc = k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
171
+ return new RegExp(`(^|[^a-z0-9])${esc}($|[^a-z0-9])`).test(text);
172
+ });
173
+ }
164
174
  function readTerms(file) {
165
175
  if (!file || !existsSync(file)) return [];
166
176
  try {
@@ -174,7 +184,9 @@ function readTerms(file) {
174
184
  * holds terms confirmed by polished text, and gets corrected as polishing
175
185
  * goes on). Used by recognition (correct spellings as hints), translation
176
186
  * and the next polishing round. Users can correct or remove entries; their
177
- * entries win and are kept in a shared library for later videos.
187
+ * entries win and are kept in a shared library for later videos. A library
188
+ * entry joins a video's glossary only once that video mentions it (title,
189
+ * description or subtitles), so one video's names never show up in another.
178
190
  */
179
191
  var Glossary = class {
180
192
  file;
@@ -183,20 +195,24 @@ var Glossary = class {
183
195
  log;
184
196
  library;
185
197
  terms = [];
198
+ /** Library entries this video has not mentioned yet: not shown, not in any prompt. */
199
+ shelf = [];
186
200
  constructor(file, targets, onChange, log, seed, library) {
187
201
  this.file = file;
188
202
  this.targets = targets;
189
203
  this.onChange = onChange;
190
204
  this.log = log;
191
205
  this.library = library;
192
- this.terms = readTerms(file).map((t) => ({
206
+ const lib = readTerms(library).map((t) => ({
193
207
  ...t,
194
- origin: t.origin ?? "auto"
208
+ origin: "user"
195
209
  }));
196
- for (const t of readTerms(library)) if (!this.find(t.src)) this.terms.push({
210
+ const inLib = (t) => lib.some((x) => x.src.toLowerCase() === t.src.toLowerCase());
211
+ this.terms = readTerms(file).map((t) => ({
197
212
  ...t,
198
- origin: "user"
199
- });
213
+ origin: t.origin ?? "auto"
214
+ })).filter((t) => t.origin !== "user" || !inLib(t));
215
+ this.shelf = lib.filter((t) => !this.find(t.src));
200
216
  for (const line of seed.split("\n").map((l) => l.trim()).filter(Boolean)) {
201
217
  const [src, rest] = line.split(/\s*=\s*/);
202
218
  if (src && !this.find(src)) this.terms.push({
@@ -206,6 +222,17 @@ var Glossary = class {
206
222
  });
207
223
  }
208
224
  }
225
+ /** New text from this video (title, description, subtitle lines): bring in the library entries it mentions. */
226
+ notice(text) {
227
+ if (!this.shelf.length || !text) return;
228
+ const low = text.toLowerCase();
229
+ const hit = this.shelf.filter((t) => mentions(low, t) && !this.find(t.src));
230
+ if (!hit.length) return;
231
+ this.shelf = this.shelf.filter((t) => !hit.includes(t));
232
+ this.terms.push(...hit);
233
+ this.log(`glossary: ${hit.map((t) => t.src).join(", ")} from the library`);
234
+ this.save();
235
+ }
209
236
  setTargets(targets) {
210
237
  this.targets = targets;
211
238
  }
@@ -304,6 +331,7 @@ var Glossary = class {
304
331
  });
305
332
  this.save();
306
333
  const t = this.find(src);
334
+ this.shelf = this.shelf.filter((x) => x.src.toLowerCase() !== src.toLowerCase());
307
335
  this.saveLibrary((lib) => [...lib.filter((x) => x.src.toLowerCase() !== src.toLowerCase()), {
308
336
  ...t,
309
337
  origin: "user"
@@ -313,6 +341,7 @@ var Glossary = class {
313
341
  const t = this.find(src);
314
342
  if (!t) return;
315
343
  this.terms = this.terms.filter((x) => x !== t);
344
+ this.shelf = this.shelf.filter((x) => x.src.toLowerCase() !== t.src.toLowerCase());
316
345
  this.save();
317
346
  this.saveLibrary((lib) => lib.filter((x) => x.src.toLowerCase() !== t.src.toLowerCase()));
318
347
  }
@@ -1082,6 +1111,46 @@ function downloadOriginal(url, dir, browser, onProgress, signal) {
1082
1111
  });
1083
1112
  });
1084
1113
  }
1114
+ /** Download only the sound of a page URL (best audio stream), next to the job files. Resolves to the saved file path. */
1115
+ function downloadAudio(url, dir, browser, onProgress) {
1116
+ return new Promise((resolve, reject) => {
1117
+ const args = [
1118
+ "--no-warnings",
1119
+ "--no-playlist",
1120
+ ...cookieArgs(browser),
1121
+ "-f",
1122
+ "bestaudio[ext=m4a]/bestaudio/best",
1123
+ "--newline",
1124
+ "--progress-template",
1125
+ "download:%(progress._percent_str)s",
1126
+ "-o",
1127
+ join(dir, "audio-src.%(ext)s"),
1128
+ "--print",
1129
+ "after_move:filepath",
1130
+ url
1131
+ ];
1132
+ const p = spawn("yt-dlp", args, { stdio: [
1133
+ "ignore",
1134
+ "pipe",
1135
+ "pipe"
1136
+ ] });
1137
+ let out = "", err = "";
1138
+ p.stdout.on("data", (d) => {
1139
+ out += d.toString();
1140
+ const m = d.toString().match(/([\d.]+)%\s*$/m);
1141
+ if (m) onProgress(Math.min(99, Math.round(Number(m[1]))));
1142
+ });
1143
+ p.stderr.on("data", (d) => {
1144
+ err += d.toString();
1145
+ });
1146
+ p.on("error", reject);
1147
+ p.on("exit", (code) => {
1148
+ const file = out.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("/")).at(-1);
1149
+ if (code === 0 && file && existsSync(file)) resolve(file);
1150
+ else reject(new Error(ytdlpError({ stderr: err })));
1151
+ });
1152
+ });
1153
+ }
1085
1154
  /** Keep the most recent downloaded original of a job dir, if any. */
1086
1155
  function findOriginal(dir) {
1087
1156
  if (!existsSync(dir)) return void 0;
@@ -1682,6 +1751,7 @@ var LiveJob = class LiveJob extends EventEmitter {
1682
1751
  still: (at) => this.still(at),
1683
1752
  onLine: (cue, src, tr, speaker) => {
1684
1753
  if (!this.cues.includes(cue)) return;
1754
+ this.deps.glossary.notice(src);
1685
1755
  cue.src = src;
1686
1756
  if (speaker) cue.speaker = speaker;
1687
1757
  if (Object.keys(tr).length) cue.tr = {
@@ -1776,6 +1846,11 @@ var LiveJob = class LiveJob extends EventEmitter {
1776
1846
  job.cues.push(...[...seen.values()].sort((a, b) => a.start - b.start));
1777
1847
  job.nextId = Math.max(0, ...job.cues.map((c) => c.id)) + 1;
1778
1848
  if (existsSync(join(dir, "brief.txt"))) job.brief = readFileSync(join(dir, "brief.txt"), "utf8");
1849
+ deps.glossary.notice([
1850
+ job.title ?? "",
1851
+ job.brief,
1852
+ ...job.cues.map((c) => c.src)
1853
+ ].join("\n"));
1779
1854
  job.siteLang = j.siteLang;
1780
1855
  const q = j;
1781
1856
  if (q.height) job.height = q.height;
@@ -2317,6 +2392,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2317
2392
  if (cue.final && key !== void 0) this.drafts.delete(key);
2318
2393
  this.emit("cue", cue);
2319
2394
  if (cue.final) {
2395
+ this.deps.glossary.notice(cue.src);
2320
2396
  if (this.opts.mode === "realtime" && !this.provider?.translates && cue.tr === void 0 && this.targets().length) this.translate(cue);
2321
2397
  else {
2322
2398
  this.record(cue);
@@ -2762,6 +2838,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2762
2838
  try {
2763
2839
  writeFileSync(join(this.deps.dir, "brief.txt"), this.brief);
2764
2840
  } catch {}
2841
+ this.deps.glossary.notice(`${this.title ?? ""}\n${this.brief}`);
2765
2842
  }
2766
2843
  /** The site's subtitle text around [from, to] (media seconds), per track — a reference, not ground truth. */
2767
2844
  siteText(from, to) {
@@ -3037,6 +3114,86 @@ var Downloads = class {
3037
3114
  });
3038
3115
  return task;
3039
3116
  }
3117
+ /**
3118
+ * Just the sound, as a file people can play anywhere (M4A, or the site's own
3119
+ * MP3/M4A as is). Uses what is already here (original, recording, source
3120
+ * file); otherwise fetches only the audio stream, never the whole video.
3121
+ */
3122
+ audio(opts) {
3123
+ const dir = join(this.jobsDir, opts.dirId);
3124
+ const task = this.add({
3125
+ kind: "audio",
3126
+ conversationId: opts.conversationId,
3127
+ url: opts.url,
3128
+ dirId: opts.dirId,
3129
+ title: opts.title,
3130
+ saveAs: opts.saveAs
3131
+ });
3132
+ (async () => {
3133
+ const recording = join(dir, "recording.mp4");
3134
+ let source = opts.localFile ?? findOriginal(dir) ?? (existsSync(recording) ? recording : void 0);
3135
+ if (!source) {
3136
+ if (!opts.url) throw new Error("nothing to take the sound from yet");
3137
+ task.stage = "download";
3138
+ source = await downloadAudio(opts.url, dir, opts.browser, (pct) => {
3139
+ task.pct = Math.round(pct * .95);
3140
+ });
3141
+ }
3142
+ if (dirname(source) === dir && /^\.(m4a|mp3)$/i.test(extname(source))) {
3143
+ task.file = basename(source);
3144
+ return;
3145
+ }
3146
+ task.stage = "mux";
3147
+ task.pct = Math.max(task.pct, 95);
3148
+ const out = join(dir, "audio.m4a");
3149
+ try {
3150
+ await run("ffmpeg", [
3151
+ "-v",
3152
+ "error",
3153
+ "-y",
3154
+ "-i",
3155
+ source,
3156
+ "-vn",
3157
+ "-map",
3158
+ "0:a:0",
3159
+ "-c:a",
3160
+ "copy",
3161
+ "-movflags",
3162
+ "+faststart",
3163
+ out
3164
+ ], { timeout: 9e5 });
3165
+ } catch {
3166
+ await run("ffmpeg", [
3167
+ "-v",
3168
+ "error",
3169
+ "-y",
3170
+ "-i",
3171
+ source,
3172
+ "-vn",
3173
+ "-map",
3174
+ "0:a:0",
3175
+ "-c:a",
3176
+ "aac",
3177
+ "-b:a",
3178
+ "192k",
3179
+ "-movflags",
3180
+ "+faststart",
3181
+ out
3182
+ ], { timeout: 18e5 });
3183
+ }
3184
+ task.file = basename(out);
3185
+ })().then(() => {
3186
+ task.status = "done";
3187
+ task.pct = 100;
3188
+ task.stage = void 0;
3189
+ }).catch((err) => {
3190
+ const msg = String(err.stderr || err.message);
3191
+ task.status = "error";
3192
+ task.error = /HTTP Error (412|429)/.test(msg) ? "The site is refusing requests for now (rate limit). Try again in a minute." : msg.slice(-300);
3193
+ this.log(`audio download failed: ${task.error}`);
3194
+ });
3195
+ return task;
3196
+ }
3040
3197
  };
3041
3198
  // src/keys.ts
3042
3199
  /** Online speech services; each reads its key from the names DSH and other tools already use. */
@@ -5125,6 +5282,21 @@ function apply(ctx, config) {
5125
5282
  saveAs: typeof b.saveAs === "string" ? b.saveAs.replace(/[\\/:*?"<>|]+/g, " ").slice(0, 150) : "video.mp4"
5126
5283
  }));
5127
5284
  }
5285
+ if (route === "audio" && req.method === "POST") {
5286
+ const b = await readJson(req);
5287
+ const job = jobs.get(String(b.jobId));
5288
+ if (!job) return send(res, 404, { error: "no such job" });
5289
+ const s = job.summary();
5290
+ return send(res, 200, downloads.audio({
5291
+ conversationId: String(b.conversationId ?? s.conversationId),
5292
+ dirId: s.id,
5293
+ title: s.title,
5294
+ url: s.source === "url" ? s.url : void 0,
5295
+ browser: s.loginBrowser,
5296
+ localFile: s.source === "file" ? s.url : void 0,
5297
+ saveAs: typeof b.saveAs === "string" ? b.saveAs.replace(/[\\/:*?"<>|]+/g, " ").slice(0, 150) : "audio"
5298
+ }));
5299
+ }
5128
5300
  if (route === "job") {
5129
5301
  const jid = url.searchParams.get("jobId");
5130
5302
  const job = jid ? jobs.get(jid) : jobForConversation(url.searchParams.get("conversationId") ?? "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",