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 +3 -3
- package/README.zh-CN.md +3 -3
- package/lib/client.js +236 -100
- package/lib/index.mjs +179 -7
- package/package.json +1 -1
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
|
-
- **
|
|
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 **
|
|
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
|
-
-
|
|
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, () => ({
|
|
33303
|
-
|
|
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:
|
|
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
|
-
|
|
35194
|
-
|
|
35195
|
-
|
|
35196
|
-
|
|
35197
|
-
|
|
35198
|
-
|
|
35199
|
-
|
|
35200
|
-
|
|
35201
|
-
|
|
35202
|
-
|
|
35203
|
-
|
|
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, () =>
|
|
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:
|
|
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: [
|
|
35804
|
-
|
|
35805
|
-
|
|
35806
|
-
|
|
35807
|
-
|
|
35808
|
-
|
|
35809
|
-
|
|
35810
|
-
|
|
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;
|
|
36840
|
-
html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top
|
|
36841
|
-
|
|
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
|
-
/**
|
|
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 "
|
|
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
|
-
|
|
206
|
+
const lib = readTerms(library).map((t) => ({
|
|
193
207
|
...t,
|
|
194
|
-
origin:
|
|
208
|
+
origin: "user"
|
|
195
209
|
}));
|
|
196
|
-
|
|
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: "
|
|
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
|
+
"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",
|