dsh-vidi 0.9.0 → 0.9.2

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.
Files changed (3) hide show
  1. package/lib/client.js +351 -27
  2. package/lib/index.mjs +288 -2
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -358,6 +358,13 @@ const Icon = {
358
358
  rx: "2"
359
359
  })
360
360
  }),
361
+ merge: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
362
+ viewBox: "0 0 24 24",
363
+ width: "16",
364
+ height: "16",
365
+ ...P,
366
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M7 4v5a5 5 0 0 0 5 5 5 5 0 0 1 5 5v1M17 4v5a5 5 0 0 1-3 4.6M12 17l-3 3M12 17l3 3" })
367
+ }),
361
368
  timer: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
362
369
  viewBox: "0 0 24 24",
363
370
  width: "18",
@@ -35376,6 +35383,106 @@ function useDevices(enabled) {
35376
35383
  }, [enabled, devices]);
35377
35384
  return devices;
35378
35385
  }
35386
+ /** "3 分钟前", "5 小时前", "2 天前", then the date. */
35387
+ function ago(ts, ui) {
35388
+ if (!ts) return "";
35389
+ const s = Math.max(0, (Date.now() - ts) / 1e3);
35390
+ if (s < 60) return ui === "zh" ? "刚刚" : "just now";
35391
+ const [n, unit] = s < 3600 ? [Math.round(s / 60), ui === "zh" ? " 分钟前" : " min ago"] : s < 86400 ? [Math.round(s / 3600), ui === "zh" ? " 小时前" : " h ago"] : s < 604800 ? [Math.round(s / 86400), ui === "zh" ? " 天前" : " d ago"] : [0, ""];
35392
+ if (unit) return `${n}${unit}`;
35393
+ return new Date(ts).toLocaleDateString(ui === "zh" ? "zh-CN" : "en-US", {
35394
+ month: "short",
35395
+ day: "numeric"
35396
+ });
35397
+ }
35398
+ /** Past videos under the start box: the newest few, more on request. */
35399
+ function History({ t, ui, onOpen }) {
35400
+ const [items, setItems] = (0, react.useState)([]);
35401
+ const [total, setTotal] = (0, react.useState)(0);
35402
+ const load = (offset, limit) => fetch(`/live/api/history?offset=${offset}&limit=${limit}`).then((r) => r.json()).then((j) => {
35403
+ setItems((v) => offset ? [...v, ...j.items ?? []] : j.items ?? []);
35404
+ setTotal(j.total ?? 0);
35405
+ }).catch(() => {});
35406
+ (0, react.useEffect)(() => {
35407
+ load(0, 5);
35408
+ }, []);
35409
+ if (!items.length) return null;
35410
+ const from = (v) => v.source === "file" ? t("src.file") : v.source === "device" ? t("src.camera") : v.source === "push" ? t("src.push") : siteName(v.url ?? "") || (() => {
35411
+ try {
35412
+ return new URL(v.url ?? "").hostname.replace(/^www\./, "");
35413
+ } catch {
35414
+ return t("src.link");
35415
+ }
35416
+ })();
35417
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
35418
+ className: "dshl-history",
35419
+ children: [
35420
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35421
+ className: "hh",
35422
+ children: t("hist.title")
35423
+ }),
35424
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35425
+ className: "hl",
35426
+ children: items.map((v) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35427
+ role: "button",
35428
+ tabIndex: 0,
35429
+ className: "hi",
35430
+ onClick: () => onOpen(v),
35431
+ onKeyDown: (e) => {
35432
+ if (e.key === "Enter") onOpen(v);
35433
+ },
35434
+ title: v.live && v.source === "url" && !v.running ? t("hist.goLive") : void 0,
35435
+ children: [
35436
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
35437
+ className: "th",
35438
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
35439
+ src: `/live/api/thumb?jobId=${encodeURIComponent(v.id)}`,
35440
+ alt: "",
35441
+ onError: (e) => {
35442
+ e.currentTarget.style.visibility = "hidden";
35443
+ }
35444
+ }), v.duration ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35445
+ className: "dur",
35446
+ children: CLOCK(v.duration)
35447
+ }) : null]
35448
+ }),
35449
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
35450
+ className: "tx",
35451
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35452
+ className: "tt",
35453
+ children: v.title || (v.source === "push" ? from(v) : t("hist.untitled"))
35454
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35455
+ className: "mm",
35456
+ children: [
35457
+ v.title || v.source !== "push" ? from(v) : "",
35458
+ v.live ? t("hist.live") : "",
35459
+ ago(v.startedAt, ui)
35460
+ ].filter(Boolean).join(" · ")
35461
+ })]
35462
+ }),
35463
+ v.running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35464
+ className: "chip",
35465
+ children: t("hist.running")
35466
+ }),
35467
+ v.live && v.source === "url" && !v.running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35468
+ className: "dshl-link replay",
35469
+ onClick: (e) => {
35470
+ e.stopPropagation();
35471
+ onOpen(v, true);
35472
+ },
35473
+ children: t("hist.replay")
35474
+ })
35475
+ ]
35476
+ }, v.id))
35477
+ }),
35478
+ items.length < total && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35479
+ className: "dshl-link more",
35480
+ onClick: () => void load(items.length, 10),
35481
+ children: t("hist.more").replace("{n}", String(total - items.length))
35482
+ })
35483
+ ]
35484
+ });
35485
+ }
35379
35486
  /** The push stream key: copy it, or change it (typed, or a new random one). */
35380
35487
  function KeyRow({ value, t, onChanged }) {
35381
35488
  const [edit, setEdit] = (0, react.useState)(null);
@@ -35844,6 +35951,20 @@ function LiveView(props) {
35844
35951
  }).catch(() => job));
35845
35952
  };
35846
35953
  /** Switching modes while a video is open takes effect right away: no delay stops transcribing, the others (re)start it. */
35954
+ const switchEngine = async (id) => {
35955
+ setEngine(id);
35956
+ if (!job || subs.state === "off" || job.status === "error") return;
35957
+ try {
35958
+ setJob(await post("subs", {
35959
+ ...options(),
35960
+ provider: id,
35961
+ jobId: job.id,
35962
+ action: "start"
35963
+ }));
35964
+ } catch (e) {
35965
+ setError(e.message);
35966
+ }
35967
+ };
35847
35968
  const changeMode = (m) => {
35848
35969
  if (m === mode) return;
35849
35970
  setMode(m);
@@ -36123,6 +36244,27 @@ function LiveView(props) {
36123
36244
  return "camera";
36124
36245
  }
36125
36246
  };
36247
+ const openPast = async (v, replay = false) => {
36248
+ setError("");
36249
+ if (!replay && v.live && v.source === "url" && v.url && !v.running) {
36250
+ if (v.conversationId !== sessionId && props.onOpenVideo && await props.onOpenVideo(v.conversationId, v.id, v.url)) return;
36251
+ start(v.url, "url");
36252
+ return;
36253
+ }
36254
+ try {
36255
+ if (v.conversationId !== sessionId && props.onOpenVideo && await props.onOpenVideo(v.conversationId, v.id)) return;
36256
+ const j = await post("reopen", {
36257
+ jobId: v.id,
36258
+ conversationId: sessionId
36259
+ });
36260
+ setCues(j.cues ?? []);
36261
+ setTerms(j.terms ?? []);
36262
+ setJob(j);
36263
+ setArmed(null);
36264
+ } catch (e) {
36265
+ setError(e.message);
36266
+ }
36267
+ };
36126
36268
  const emptyBox = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
36127
36269
  className: "dshl-drop",
36128
36270
  children: [
@@ -36132,6 +36274,11 @@ function LiveView(props) {
36132
36274
  children: sourceBar
36133
36275
  }),
36134
36276
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: srcTab === "camera" && devices && !mics.length ? t("src.cam.none") : t(srcTab === "file" ? "src.file.body" : srcTab === "camera" ? "src.cam.body" : srcTab === "push" ? "src.push.body" : "empty.body") }),
36277
+ pageMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(History, {
36278
+ t,
36279
+ ui,
36280
+ onOpen: (v, replay) => void openPast(v, replay)
36281
+ }),
36135
36282
  srcTab === "push" && armed?.rtmp && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36136
36283
  className: "dshl-pushinfo",
36137
36284
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyRow, {
@@ -36206,6 +36353,34 @@ function LiveView(props) {
36206
36353
  openBench: setBench
36207
36354
  } : void 0;
36208
36355
  /** Subtitle lines and glossary: under the player on the video page, beside it in a conversation. */
36356
+ const [splitting, setSplitting] = (0, react.useState)(null);
36357
+ const lineKey = (k) => k === "src" || k === spokenBase ? "src" : k;
36358
+ const mergeLine = async (id) => {
36359
+ try {
36360
+ await post("lines", {
36361
+ jobId: job.id,
36362
+ action: "merge",
36363
+ id
36364
+ });
36365
+ } catch (e) {
36366
+ setError(e.message);
36367
+ }
36368
+ };
36369
+ const splitLine = async (id, key, at) => {
36370
+ setSplitting(null);
36371
+ if (at <= 0) return;
36372
+ try {
36373
+ await post("lines", {
36374
+ jobId: job.id,
36375
+ action: "split",
36376
+ id,
36377
+ key,
36378
+ at
36379
+ });
36380
+ } catch (e) {
36381
+ setError(e.message);
36382
+ }
36383
+ };
36209
36384
  const transcriptBox = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
36210
36385
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
36211
36386
  className: "dshl-sidetabs",
@@ -36270,9 +36445,45 @@ function LiveView(props) {
36270
36445
  children: [shown.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36271
36446
  className: "dshl-muted pad",
36272
36447
  children: emptyLines
36273
- }), shown.map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
36448
+ }), shown.map((c, idx) => splitting?.id === c.id ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
36449
+ className: "dshl-line splitting",
36450
+ children: [
36451
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
36452
+ className: "ts",
36453
+ children: CLOCK(c.start)
36454
+ }),
36455
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
36456
+ className: "txt",
36457
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
36458
+ className: "main cutline",
36459
+ children: Array.from((splitting.key === "src" ? c.src : c.tr?.[splitting.key]) ?? "").map((ch, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
36460
+ className: "ch",
36461
+ onClick: () => void splitLine(c.id, splitting.key, i),
36462
+ children: ch
36463
+ }, i))
36464
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
36465
+ className: "hint",
36466
+ children: [
36467
+ t("lines.splitHint"),
36468
+ " ",
36469
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
36470
+ className: "dshl-link",
36471
+ onClick: () => setSplitting(null),
36472
+ children: t("player.cancel")
36473
+ })
36474
+ ]
36475
+ })]
36476
+ }),
36477
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {})
36478
+ ]
36479
+ }, c.id) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
36480
+ role: "button",
36481
+ tabIndex: 0,
36274
36482
  className: `dshl-line ${c.id === activeId ? "active" : ""} ${c.polished ? "ok" : "raw"}`,
36275
36483
  onClick: () => player.current?.seek(c.start),
36484
+ onKeyDown: (e) => {
36485
+ if (e.key === "Enter") player.current?.seek(c.start);
36486
+ },
36276
36487
  title: c.polished ? t("tr.polished") : t("tr.raw"),
36277
36488
  children: [
36278
36489
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -36293,6 +36504,27 @@ function LiveView(props) {
36293
36504
  className: "mark",
36294
36505
  "aria-hidden": "true",
36295
36506
  children: c.polished && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.check, {})
36507
+ }),
36508
+ c.final && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
36509
+ className: "tools",
36510
+ onClick: (e) => e.stopPropagation(),
36511
+ children: [idx < shown.length - 1 && shown[idx + 1]?.final && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
36512
+ className: "ghost",
36513
+ title: t("lines.merge"),
36514
+ onClick: () => void mergeLine(c.id),
36515
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.merge, {})
36516
+ }), (() => {
36517
+ const k = shownLines.find((x) => (lineText(c, x) ?? "").length > 1) ?? "src";
36518
+ return Array.from(lineText(c, k) ?? c.src).length > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
36519
+ className: "ghost",
36520
+ title: t("lines.split"),
36521
+ onClick: () => setSplitting({
36522
+ id: c.id,
36523
+ key: lineKey(k)
36524
+ }),
36525
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.scissors, {})
36526
+ });
36527
+ })()]
36296
36528
  })
36297
36529
  ]
36298
36530
  }, c.id))]
@@ -36632,30 +36864,52 @@ function LiveView(props) {
36632
36864
  t
36633
36865
  }) })
36634
36866
  },
36635
- ...!job.vod && job.status === "running" ? [{
36636
- id: "mode",
36867
+ {
36868
+ id: "how",
36637
36869
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.timer, {}),
36638
- title: t("set.mode"),
36639
- label: t(`mode.${mode}`),
36640
- content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36870
+ title: t("how.title"),
36871
+ label: [!job.vod ? t(`mode.${mode}`) : "", engineName(usableEngine())].filter(Boolean).join(" · "),
36872
+ content: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
36641
36873
  className: "dshl-modemenu",
36642
36874
  children: [
36643
- "live",
36644
- "realtime",
36645
- "polished"
36646
- ].map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
36647
- className: `mm ${mode === m ? "on" : ""}`,
36648
- onClick: () => changeMode(m),
36649
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
36650
- className: "n",
36651
- children: [t(`mode.${m}`), mode === m && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.check, {})]
36652
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
36653
- className: "d",
36654
- children: t(`mode.${m}.tip`)
36655
- })]
36656
- }, m))
36875
+ !job.vod && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36876
+ className: "mh",
36877
+ children: t("set.mode")
36878
+ }), [
36879
+ "live",
36880
+ "realtime",
36881
+ "polished"
36882
+ ].map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
36883
+ className: `mm ${mode === m ? "on" : ""}`,
36884
+ onClick: () => changeMode(m),
36885
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
36886
+ className: "n",
36887
+ children: [t(`mode.${m}`), mode === m && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.check, {})]
36888
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
36889
+ className: "d",
36890
+ children: t(`mode.${m}.tip`)
36891
+ })]
36892
+ }, m))] }),
36893
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36894
+ className: "mh",
36895
+ children: t("set.engine")
36896
+ }),
36897
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
36898
+ value: usableEngine(),
36899
+ onChange: (id) => void switchEngine(id),
36900
+ options: providers.filter((p) => p.ready).map((p) => ({
36901
+ value: p.id,
36902
+ label: engineName(p.id),
36903
+ hint: p.model
36904
+ }))
36905
+ }),
36906
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
36907
+ className: "mt",
36908
+ children: t("how.more")
36909
+ })
36910
+ ]
36657
36911
  })
36658
- }] : [],
36912
+ },
36659
36913
  {
36660
36914
  id: "restream",
36661
36915
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.push, {}),
@@ -37492,6 +37746,18 @@ const en = {
37492
37746
  "dev.blocked": "No picture or sound from the device: DeepSeek Harness needs the system’s permission. Open System Settings, switch DeepSeek Harness on, then retry.",
37493
37747
  "dev.denied": "DeepSeek Harness needs permission to use the {d}. Open System Settings, switch it on in the list, then retry.",
37494
37748
  "dev.and": " and ",
37749
+ "how.title": "How subtitles are made",
37750
+ "how.more": "More services and keys are in Settings.",
37751
+ "lines.merge": "Join with the next line",
37752
+ "lines.split": "Split this line",
37753
+ "lines.splitHint": "Click where the line should break.",
37754
+ "hist.goLive": "Watch live again",
37755
+ "hist.replay": "Replay",
37756
+ "hist.title": "Recent videos",
37757
+ "hist.untitled": "Untitled video",
37758
+ "hist.live": "live",
37759
+ "hist.running": "Live now",
37760
+ "hist.more": "More ({n})",
37495
37761
  "set.spoken.tip": "The language people speak in this video; Auto detects it.",
37496
37762
  "rs.mode": "Subtitle mode of the push-out",
37497
37763
  "rs.mode.live": "Pushed out about {s} s behind; subtitles come a little after the words.",
@@ -37941,6 +38207,18 @@ const zh = {
37941
38207
  "dev.blocked": "设备没有传来画面和声音:DeepSeek Harness 需要系统授权。点「打开系统设置」,在列表里打开 DeepSeek Harness,再点重试。",
37942
38208
  "dev.denied": "DeepSeek Harness 需要{d}权限。点「打开系统设置」,在列表里打开它,再点重试。",
37943
38209
  "dev.and": "和",
38210
+ "how.title": "字幕怎么做",
38211
+ "how.more": "更多服务和 key 在设置里。",
38212
+ "lines.merge": "和下一句合并",
38213
+ "lines.split": "拆成两句",
38214
+ "lines.splitHint": "点一下要断开的位置。",
38215
+ "hist.goLive": "回到直播",
38216
+ "hist.replay": "回放",
38217
+ "hist.title": "最近的视频",
38218
+ "hist.untitled": "未命名视频",
38219
+ "hist.live": "直播",
38220
+ "hist.running": "直播中",
38221
+ "hist.more": "更多(还有 {n} 个)",
37944
38222
  "set.spoken.tip": "视频里说的是哪种语言;自动 = 自己识别。",
37945
38223
  "rs.mode": "推流用的字幕模式",
37946
38224
  "rs.mode.live": "推出去比实时晚约 {s} 秒,字幕会比说话稍晚出现。",
@@ -38613,6 +38891,34 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
38613
38891
  .dshl-menu .dshl-seg button.on { background: rgba(255,255,255,.92); color: #111; box-shadow: none; }
38614
38892
  .dshl-menu .dshl-switch { background: rgba(255,255,255,.22); }
38615
38893
  .dshl-menu .dshl-switch.on { background: var(--accent); }
38894
+ .dshl-history { width: 100%; max-width: 680px; margin-top: 40px; text-align: left; }
38895
+ .dshl-history .hh { margin: 0 0 8px 4px; color: var(--faint); font-size: 12.5px; }
38896
+ .dshl-history .hl { display: grid; gap: 2px; }
38897
+ .dshl-history .hi { display: flex; align-items: center; gap: 12px; width: 100%; padding: 6px; border: 0; border-radius: 10px; background: none; color: var(--ink); text-align: left; cursor: pointer; }
38898
+ .dshl-history .hi:hover { background: var(--hover); }
38899
+ .dshl-history .th { position: relative; flex: none; width: 96px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: var(--l2); }
38900
+ .dshl-history .th img { display: block; width: 100%; height: 100%; object-fit: cover; }
38901
+ .dshl-history .dur { position: absolute; right: 4px; bottom: 3px; padding: 0 4px; border-radius: 3px; background: rgba(0,0,0,.65); color: #fff; font-size: 10.5px; line-height: 16px; font-variant-numeric: tabular-nums; }
38902
+ .dshl-history .tx { display: grid; gap: 3px; min-width: 0; flex: 1; }
38903
+ .dshl-history .tt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 550; }
38904
+ .dshl-history .mm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font-size: 12px; }
38905
+ .dshl-history .chip { flex: none; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--live) 14%, transparent); color: var(--live); font-size: 11.5px; font-weight: 600; }
38906
+ .dshl-history .more { margin: 8px 0 0 6px; font-size: 12.5px; }
38907
+ .dshl-modemenu .mh { margin: 6px 4px 2px; color: rgba(255,255,255,.55); font-size: 11.5px; }
38908
+ .dshl-modemenu .mh:first-child { margin-top: 0; }
38909
+ .dshl-modemenu .dshl-select { width: 100%; }
38910
+ .dshl-modemenu .mt { margin: 2px 4px 0; color: rgba(255,255,255,.45); font-size: 11.5px; }
38911
+ .dshl-line { position: relative; }
38912
+ .dshl-line .tools { position: absolute; top: 4px; right: 4px; display: none; gap: 2px; padding: 2px; border-radius: 7px; background: var(--bg); box-shadow: 0 1px 4px rgba(0,0,0,.12); }
38913
+ .dshl-line:hover .tools, .dshl-line:focus-within .tools { display: flex; }
38914
+ .dshl-line .tools .ghost { display: grid; place-items: center; width: 26px; height: 24px; border: 0; border-radius: 5px; background: none; color: var(--muted); cursor: pointer; }
38915
+ .dshl-line .tools svg { width: 15px; height: 15px; }
38916
+ .dshl-line .tools .ghost:hover { background: var(--hover); color: var(--ink); }
38917
+ .dshl-line.splitting { cursor: default; background: color-mix(in srgb, var(--accent) 8%, transparent); }
38918
+ .dshl-line .cutline .ch { cursor: col-resize; border-left: 1px solid transparent; }
38919
+ .dshl-line .cutline .ch:hover { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
38920
+ .dshl-line .hint { color: var(--faint); font-size: 12px; }
38921
+ .dshl-history .replay { flex: none; font-size: 12.5px; }
38616
38922
  .dshl-modemenu { display: grid; gap: 4px; width: 300px; max-width: 100%; }
38617
38923
  .dshl-modemenu .mm { display: grid; gap: 3px; padding: 9px 10px; border: 0; border-radius: 8px; background: none; color: inherit; text-align: left; cursor: pointer; }
38618
38924
  .dshl-modemenu .mm:hover { background: rgba(255,255,255,.08); }
@@ -38987,7 +39293,11 @@ async function openPage(sessions, opts = {}) {
38987
39293
  }
38988
39294
  async function showPage(sessions, opts) {
38989
39295
  const current = sessions.current();
38990
- const fresh = () => pageSession(sessions, opts.workspaces, opts.title ?? "Video");
39296
+ const fresh = async () => {
39297
+ let id = await pageSession(sessions, opts.workspaces, opts.title ?? "Video");
39298
+ if (await fetch(`/live/api/job?conversationId=${encodeURIComponent(id)}`).then((r) => r.json()).catch(() => null)) id = await pageSession(sessions, opts.workspaces, opts.title ?? "Video", true);
39299
+ return id;
39300
+ };
38991
39301
  let sid = opts.sessionId && sessions.has(opts.sessionId) ? opts.sessionId : await fresh();
38992
39302
  if (sid !== current) try {
38993
39303
  sessions.open(sid);
@@ -39052,6 +39362,24 @@ function videoHandlers(sessions, workspaces, sessionId, fallbackTitle) {
39052
39362
  return false;
39053
39363
  }
39054
39364
  },
39365
+ /** Open a past video in its own conversation; false when that conversation is gone. */
39366
+ onOpenVideo: async (conversationId, jobId, pending) => {
39367
+ if (!conversationId || !sessions.has(conversationId)) return false;
39368
+ if (!pending) await fetch("/live/api/reopen", {
39369
+ method: "POST",
39370
+ headers: { "content-type": "application/json" },
39371
+ body: JSON.stringify({
39372
+ jobId,
39373
+ conversationId
39374
+ })
39375
+ }).catch(() => {});
39376
+ await openPage(sessions, {
39377
+ sessionId: conversationId,
39378
+ workspaces,
39379
+ pending
39380
+ });
39381
+ return true;
39382
+ },
39055
39383
  onTitle: (title) => {
39056
39384
  own().then((yes) => {
39057
39385
  const now = sessions.info?.(sessionId)?.title;
@@ -39137,11 +39465,7 @@ function SidebarButton(props) {
39137
39465
  const pct = item?.subs === "running" && item.duration ? Math.min(99, Math.round(item.at / item.duration * 100)) : void 0;
39138
39466
  const meta = !busy ? "" : item?.subs === "running" ? pct !== void 0 ? t("side.subs").replace("{p}", String(pct)) : t("side.subsLive") : item?.recording ? t("side.recording") : item?.loading ? t("side.loading") : t("side.downloading");
39139
39467
  const thumb = busy && frame && (!item || frame.jobId === item.jobId) ? frame.dataUrl : void 0;
39140
- const open = () => void openPage(sessions, item ? {
39141
- sessionId: item.conversationId,
39142
- workspaces,
39143
- title: t("view.live")
39144
- } : {
39468
+ const open = () => void openPage(sessions, {
39145
39469
  workspaces,
39146
39470
  title: t("view.live")
39147
39471
  });
package/lib/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
+ import { execFile, spawn } from "node:child_process";
1
2
  import { appendFileSync, chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
3
  import { homedir, networkInterfaces } from "node:os";
3
4
  import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
4
5
  import z from "@deepseek-ai/schemastery";
5
- import { execFile, spawn } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { EventEmitter } from "node:events";
8
8
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -1990,6 +1990,32 @@ const AHEAD_MAX_LINES = 10;
1990
1990
  const seekAfter = (skip) => skip > .01 ? ["-ss", skip.toFixed(3)] : [];
1991
1991
  const SOI = Buffer.from([255, 216]);
1992
1992
  const EOI = Buffer.from([255, 217]);
1993
+ const CJK_CHAR = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uff00-\uffef\u3000-\u303f]/;
1994
+ /** Two pieces of one language as one line: no space between CJK text, a space between words otherwise. */
1995
+ function joinText(a, b) {
1996
+ const x = a.trim(), y = b.trim();
1997
+ if (!x || !y) return x || y;
1998
+ return CJK_CHAR.test(x.at(-1)) || CJK_CHAR.test(y[0]) ? x + y : `${x} ${y}`;
1999
+ }
2000
+ /** Where to cut a text near `ratio` of its length: after a pause mark if one is close, else between words. */
2001
+ function cutNear(cs, ratio) {
2002
+ const n = cs.length;
2003
+ if (n < 2) return n;
2004
+ const target = Math.round(n * ratio);
2005
+ let best = -1, bestScore = Infinity;
2006
+ for (let i = 1; i < n; i++) {
2007
+ const prev = cs[i - 1], here = cs[i];
2008
+ const pause = /[,。、;:!?,.;:!?]/.test(prev);
2009
+ const word = here === " " || CJK_CHAR.test(prev) || CJK_CHAR.test(here);
2010
+ if (!pause && !word) continue;
2011
+ const score = Math.abs(i - target) - (pause ? n * .12 : 0);
2012
+ if (score < bestScore) {
2013
+ bestScore = score;
2014
+ best = i;
2015
+ }
2016
+ }
2017
+ return best > 0 ? best : Math.max(1, Math.min(n - 1, target));
2018
+ }
1993
2019
  var LiveJob = class LiveJob extends EventEmitter {
1994
2020
  opts;
1995
2021
  deps;
@@ -2052,7 +2078,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2052
2078
  targets: () => this.targets(),
2053
2079
  still: (at) => this.still(at),
2054
2080
  onLine: (cue, src, tr, speaker) => {
2055
- if (!this.cues.includes(cue)) return;
2081
+ if (!this.cues.includes(cue) || cue.edited) return;
2056
2082
  this.deps.glossary.notice(src);
2057
2083
  cue.src = src;
2058
2084
  if (speaker) cue.speaker = speaker;
@@ -2111,6 +2137,12 @@ var LiveJob = class LiveJob extends EventEmitter {
2111
2137
  this.persist();
2112
2138
  this.emit("state", this.summary());
2113
2139
  }
2140
+ /** Show this video in another conversation (opened again from the history): saved, so it stays there. */
2141
+ rebind(conversationId) {
2142
+ this.opts.conversationId = conversationId;
2143
+ this.closed = false;
2144
+ this.persist();
2145
+ }
2114
2146
  /** job.json: enough to bring the job back after a restart (finished, not recording). */
2115
2147
  persist() {
2116
2148
  if (this.opts.source === "push" && this.message) return;
@@ -2854,6 +2886,76 @@ var LiveJob = class LiveJob extends EventEmitter {
2854
2886
  })).join("\n") + "\n");
2855
2887
  this.emit("cues", this.cues);
2856
2888
  }
2889
+ /** Join a finished line with the next one (the user's own line breaks). */
2890
+ mergeCue(id) {
2891
+ const i = this.cues.findIndex((c) => c.id === id);
2892
+ const a = this.cues[i], b = this.cues[i + 1];
2893
+ if (!a || !b || !a.final || !b.final) return false;
2894
+ a.src = joinText(a.src, b.src);
2895
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a.tr ?? {}), ...Object.keys(b.tr ?? {})]);
2896
+ if (keys.size) a.tr = Object.fromEntries([...keys].map((k) => [k, joinText(a.tr?.[k] ?? "", b.tr?.[k] ?? "")]));
2897
+ a.end = Math.max(a.end, b.end);
2898
+ a.polished = !!a.polished && !!b.polished;
2899
+ a.edited = true;
2900
+ this.cues.splice(i + 1, 1);
2901
+ appendFileSync(join(this.deps.dir, "cues.jsonl"), JSON.stringify({
2902
+ id: b.id,
2903
+ dropped: true
2904
+ }) + "\n");
2905
+ this.record(a);
2906
+ this.emit("cues", this.cues);
2907
+ return true;
2908
+ }
2909
+ /**
2910
+ * Split a finished line in two where the user pointed: `key` is the text they pointed in ('src' or a
2911
+ * language) and `at` the character position. The other texts are cut at the matching place (at a
2912
+ * pause or a word nearby), and the time in proportion.
2913
+ */
2914
+ splitCue(id, key, at) {
2915
+ const i = this.cues.findIndex((c) => c.id === id);
2916
+ const a = this.cues[i];
2917
+ if (!a || !a.final) return false;
2918
+ const text = key === "src" ? a.src : a.tr?.[key];
2919
+ if (!text) return false;
2920
+ const chars = Array.from(text);
2921
+ const cut = Math.max(1, Math.min(chars.length - 1, Math.round(at)));
2922
+ const ratio = cut / chars.length;
2923
+ const pieces = (t, own) => {
2924
+ const cs = Array.from(t);
2925
+ const k = own ? cut : cutNear(cs, ratio);
2926
+ return [cs.slice(0, k).join("").trim(), cs.slice(k).join("").trim()];
2927
+ };
2928
+ const [srcA, srcB] = pieces(a.src, key === "src");
2929
+ if (!srcA || !srcB) return false;
2930
+ const trA = {}, trB = {};
2931
+ for (const [k, v] of Object.entries(a.tr ?? {})) {
2932
+ const [x, y] = pieces(v, k === key);
2933
+ trA[k] = x;
2934
+ trB[k] = y;
2935
+ }
2936
+ const mid = a.start + (a.end - a.start) * ratio;
2937
+ const b = {
2938
+ id: this.nextId++,
2939
+ start: mid,
2940
+ end: a.end,
2941
+ src: srcB,
2942
+ tr: Object.keys(trB).length ? trB : void 0,
2943
+ lang: a.lang,
2944
+ final: true,
2945
+ polished: a.polished,
2946
+ speaker: a.speaker,
2947
+ edited: true
2948
+ };
2949
+ a.src = srcA;
2950
+ if (Object.keys(trA).length) a.tr = trA;
2951
+ a.end = mid;
2952
+ a.edited = true;
2953
+ this.cues.splice(i + 1, 0, b);
2954
+ this.record(a);
2955
+ this.record(b);
2956
+ this.emit("cues", this.cues);
2957
+ return true;
2958
+ }
2857
2959
  /** Append a finished cue (with its translation, if any) to cues.jsonl. */
2858
2960
  record(cue) {
2859
2961
  appendFileSync(join(this.deps.dir, "cues.jsonl"), JSON.stringify(cue) + "\n");
@@ -6430,6 +6532,137 @@ function apply(ctx, config) {
6430
6532
  };
6431
6533
  }
6432
6534
  /** The conversation's video: in memory, or the latest one on disk (after a restart). */
6535
+ /** A video by id: the running one, or brought back from its folder. */
6536
+ function loadJob(id) {
6537
+ if (jobs.has(id)) return jobs.get(id);
6538
+ if (!/^[\w-]+$/.test(id)) return void 0;
6539
+ const dir = join(config.dataDir, "jobs", id);
6540
+ const file = join(dir, "job.json");
6541
+ if (!existsSync(file)) return void 0;
6542
+ try {
6543
+ const raw = JSON.parse(readFileSync(file, "utf8"));
6544
+ let job;
6545
+ job = LiveJob.restore(dir, jobDeps(dir, raw.opts, () => job));
6546
+ if (job) jobs.set(job.id, job);
6547
+ return job;
6548
+ } catch {
6549
+ return;
6550
+ }
6551
+ }
6552
+ /** Past videos for the start page, newest first: those with something to watch. */
6553
+ const historyCache = /* @__PURE__ */ new Map();
6554
+ function history() {
6555
+ const root = join(config.dataDir, "jobs");
6556
+ if (!existsSync(root)) return [];
6557
+ const out = [];
6558
+ for (const name of readdirSync(root).filter((n) => /^[\w-]+$/.test(n)).sort().reverse()) {
6559
+ const dir = join(root, name), file = join(dir, "job.json");
6560
+ const live = jobs.get(name);
6561
+ if (live && live.summary().status === "running" && !live.summary().message) {
6562
+ const s = live.summary();
6563
+ out.push({
6564
+ id: s.id,
6565
+ title: s.title || (s.source === "url" ? s.url : ""),
6566
+ source: s.source,
6567
+ url: s.source === "url" ? s.url : void 0,
6568
+ startedAt: s.startedAt,
6569
+ duration: s.vod ? s.duration : s.recorded,
6570
+ live: !s.vod,
6571
+ running: true,
6572
+ conversationId: s.conversationId
6573
+ });
6574
+ continue;
6575
+ }
6576
+ let mtime = 0;
6577
+ try {
6578
+ mtime = statSync(file).mtimeMs;
6579
+ } catch {
6580
+ continue;
6581
+ }
6582
+ let hit = historyCache.get(name);
6583
+ if (!hit || hit.mtime !== mtime) {
6584
+ let item = null;
6585
+ try {
6586
+ const j = JSON.parse(readFileSync(file, "utf8"));
6587
+ if ((existsSync(join(dir, "recording.mp4")) || existsSync(join(dir, "hls", "index.m3u8"))) && j.status !== "error") {
6588
+ const device = j.opts.source === "device" ? (() => {
6589
+ try {
6590
+ return JSON.parse(j.opts.url).title;
6591
+ } catch {
6592
+ return "";
6593
+ }
6594
+ })() : "";
6595
+ let duration = j.duration;
6596
+ if (!duration) try {
6597
+ duration = [...readFileSync(join(dir, "hls", "index.m3u8"), "utf8").matchAll(/#EXTINF:([\d.]+)/g)].reduce((n, m) => n + Number(m[1]), 0) || void 0;
6598
+ } catch {}
6599
+ item = {
6600
+ id: j.id,
6601
+ title: j.title || device || (j.opts.source === "url" ? j.opts.url : ""),
6602
+ source: j.opts.source,
6603
+ url: j.opts.source === "url" ? j.opts.url : void 0,
6604
+ startedAt: j.startedAt,
6605
+ duration,
6606
+ live: !j.vod,
6607
+ running: false,
6608
+ conversationId: j.opts.conversationId
6609
+ };
6610
+ }
6611
+ } catch {}
6612
+ hit = {
6613
+ mtime,
6614
+ item
6615
+ };
6616
+ historyCache.set(name, hit);
6617
+ }
6618
+ if (hit.item) out.push(hit.item);
6619
+ }
6620
+ return out;
6621
+ }
6622
+ /** A small picture of a video for its history row (made once, kept in its folder). */
6623
+ async function thumbnail(id) {
6624
+ if (!/^[\w-]+$/.test(id)) return void 0;
6625
+ const dir = join(config.dataDir, "jobs", id);
6626
+ const out = join(dir, "thumb.jpg");
6627
+ if (existsSync(out)) return out;
6628
+ for (const cover of ["cover.jpg", "cover.png"]) if (existsSync(join(dir, cover))) return join(dir, cover);
6629
+ const src = existsSync(join(dir, "recording.mp4")) ? join(dir, "recording.mp4") : existsSync(join(dir, "hls", "index.m3u8")) ? join(dir, "hls", "index.m3u8") : void 0;
6630
+ if (!src) return void 0;
6631
+ if (await new Promise((resolve) => {
6632
+ execFile("ffmpeg", [
6633
+ "-v",
6634
+ "error",
6635
+ "-y",
6636
+ "-i",
6637
+ src,
6638
+ "-ss",
6639
+ "3",
6640
+ "-frames:v",
6641
+ "1",
6642
+ "-vf",
6643
+ "scale=320:-2",
6644
+ "-q:v",
6645
+ "5",
6646
+ out
6647
+ ], { timeout: 2e4 }, (err) => resolve(!err && existsSync(out)));
6648
+ })) return out;
6649
+ return await new Promise((resolve) => {
6650
+ execFile("ffmpeg", [
6651
+ "-v",
6652
+ "error",
6653
+ "-y",
6654
+ "-i",
6655
+ src,
6656
+ "-frames:v",
6657
+ "1",
6658
+ "-vf",
6659
+ "scale=320:-2",
6660
+ "-q:v",
6661
+ "5",
6662
+ out
6663
+ ], { timeout: 2e4 }, (err) => resolve(!err && existsSync(out)));
6664
+ }) ? out : void 0;
6665
+ }
6433
6666
  function jobForConversation(conversationId) {
6434
6667
  const id = byConversation.get(conversationId);
6435
6668
  if (id === "") return void 0;
@@ -6637,6 +6870,8 @@ function apply(ctx, config) {
6637
6870
  if (!job) return send(res, 404, { error: "no such job" });
6638
6871
  if (b.action === "pause") await job.pauseSubs();
6639
6872
  else {
6873
+ const cur = job.summary();
6874
+ if ((typeof b.provider === "string" && b.provider !== cur.provider || typeof b.mode === "string" && modeOf(b.mode) !== cur.mode) && cur.subs?.state === "running") await job.pauseSubs();
6640
6875
  const opts = {};
6641
6876
  if (Array.isArray(b.tgtLangs)) opts.tgtLangs = b.tgtLangs.map(String).slice(0, 4);
6642
6877
  if (typeof b.srcLang === "string") opts.srcLang = b.srcLang;
@@ -6890,6 +7125,57 @@ function apply(ctx, config) {
6890
7125
  saveAs: typeof b.saveAs === "string" ? b.saveAs.replace(/[\\/:*?"<>|]+/g, " ").slice(0, 150) : "audio"
6891
7126
  }));
6892
7127
  }
7128
+ if (route === "history") {
7129
+ const all = history();
7130
+ const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
7131
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit")) || 5));
7132
+ return send(res, 200, {
7133
+ items: all.slice(offset, offset + limit),
7134
+ total: all.length
7135
+ });
7136
+ }
7137
+ if (route === "thumb") {
7138
+ const file = await thumbnail(url.searchParams.get("jobId") ?? "");
7139
+ if (!file) {
7140
+ res.writeHead(404);
7141
+ res.end();
7142
+ return;
7143
+ }
7144
+ res.writeHead(200, {
7145
+ "content-type": file.endsWith(".png") ? "image/png" : "image/jpeg",
7146
+ "cache-control": "max-age=3600"
7147
+ });
7148
+ createReadStream(file).pipe(res);
7149
+ return;
7150
+ }
7151
+ if (route === "lines" && req.method === "POST") {
7152
+ const b = await readJson(req);
7153
+ const job = jobs.get(String(b.jobId));
7154
+ if (!job) return send(res, 404, { error: "no such job" });
7155
+ const ok = b.action === "merge" ? job.mergeCue(Number(b.id)) : b.action === "split" ? job.splitCue(Number(b.id), String(b.key ?? "src"), Number(b.at)) : false;
7156
+ return send(res, ok ? 200 : 400, ok ? { ok: true } : { error: "cannot change that line" });
7157
+ }
7158
+ if (route === "reopen" && req.method === "POST") {
7159
+ const b = await readJson(req);
7160
+ const job = loadJob(String(b.jobId));
7161
+ const conv = String(b.conversationId ?? "");
7162
+ if (!job || !conv) return send(res, 404, { error: "no such video" });
7163
+ try {
7164
+ rmSync(join(job.summary().dir, "closed"), { force: true });
7165
+ } catch {}
7166
+ const prev = byConversation.get(conv);
7167
+ if (prev && prev !== job.id) {
7168
+ const other = jobs.get(prev);
7169
+ if (other && other.summary().status !== "running") byConversation.delete(conv);
7170
+ }
7171
+ job.rebind(conv);
7172
+ byConversation.set(conv, job.id);
7173
+ return send(res, 200, {
7174
+ ...job.summary(),
7175
+ cues: job.cues,
7176
+ terms: job.terms
7177
+ });
7178
+ }
6893
7179
  if (route === "preview") {
6894
7180
  const job = jobs.get(url.searchParams.get("jobId") ?? "");
6895
7181
  if (!job) return send(res, 404, { error: "no such job" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",