dsh-vidi 0.9.1 → 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 +204 -27
  2. package/lib/index.mjs +106 -1
  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",
@@ -35416,9 +35423,15 @@ function History({ t, ui, onOpen }) {
35416
35423
  }),
35417
35424
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35418
35425
  className: "hl",
35419
- children: items.map((v) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
35426
+ children: items.map((v) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35427
+ role: "button",
35428
+ tabIndex: 0,
35420
35429
  className: "hi",
35421
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,
35422
35435
  children: [
35423
35436
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
35424
35437
  className: "th",
@@ -35450,6 +35463,14 @@ function History({ t, ui, onOpen }) {
35450
35463
  v.running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35451
35464
  className: "chip",
35452
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")
35453
35474
  })
35454
35475
  ]
35455
35476
  }, v.id))
@@ -35930,6 +35951,20 @@ function LiveView(props) {
35930
35951
  }).catch(() => job));
35931
35952
  };
35932
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
+ };
35933
35968
  const changeMode = (m) => {
35934
35969
  if (m === mode) return;
35935
35970
  setMode(m);
@@ -36209,8 +36244,13 @@ function LiveView(props) {
36209
36244
  return "camera";
36210
36245
  }
36211
36246
  };
36212
- const openPast = async (v) => {
36247
+ const openPast = async (v, replay = false) => {
36213
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
+ }
36214
36254
  try {
36215
36255
  if (v.conversationId !== sessionId && props.onOpenVideo && await props.onOpenVideo(v.conversationId, v.id)) return;
36216
36256
  const j = await post("reopen", {
@@ -36237,7 +36277,7 @@ function LiveView(props) {
36237
36277
  pageMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(History, {
36238
36278
  t,
36239
36279
  ui,
36240
- onOpen: (v) => void openPast(v)
36280
+ onOpen: (v, replay) => void openPast(v, replay)
36241
36281
  }),
36242
36282
  srcTab === "push" && armed?.rtmp && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36243
36283
  className: "dshl-pushinfo",
@@ -36313,6 +36353,34 @@ function LiveView(props) {
36313
36353
  openBench: setBench
36314
36354
  } : void 0;
36315
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
+ };
36316
36384
  const transcriptBox = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
36317
36385
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
36318
36386
  className: "dshl-sidetabs",
@@ -36377,9 +36445,45 @@ function LiveView(props) {
36377
36445
  children: [shown.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36378
36446
  className: "dshl-muted pad",
36379
36447
  children: emptyLines
36380
- }), 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,
36381
36482
  className: `dshl-line ${c.id === activeId ? "active" : ""} ${c.polished ? "ok" : "raw"}`,
36382
36483
  onClick: () => player.current?.seek(c.start),
36484
+ onKeyDown: (e) => {
36485
+ if (e.key === "Enter") player.current?.seek(c.start);
36486
+ },
36383
36487
  title: c.polished ? t("tr.polished") : t("tr.raw"),
36384
36488
  children: [
36385
36489
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -36400,6 +36504,27 @@ function LiveView(props) {
36400
36504
  className: "mark",
36401
36505
  "aria-hidden": "true",
36402
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
+ })()]
36403
36528
  })
36404
36529
  ]
36405
36530
  }, c.id))]
@@ -36739,30 +36864,52 @@ function LiveView(props) {
36739
36864
  t
36740
36865
  }) })
36741
36866
  },
36742
- ...!job.vod && job.status === "running" ? [{
36743
- id: "mode",
36867
+ {
36868
+ id: "how",
36744
36869
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.timer, {}),
36745
- title: t("set.mode"),
36746
- label: t(`mode.${mode}`),
36747
- 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", {
36748
36873
  className: "dshl-modemenu",
36749
36874
  children: [
36750
- "live",
36751
- "realtime",
36752
- "polished"
36753
- ].map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
36754
- className: `mm ${mode === m ? "on" : ""}`,
36755
- onClick: () => changeMode(m),
36756
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
36757
- className: "n",
36758
- children: [t(`mode.${m}`), mode === m && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.check, {})]
36759
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
36760
- className: "d",
36761
- children: t(`mode.${m}.tip`)
36762
- })]
36763
- }, 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
+ ]
36764
36911
  })
36765
- }] : [],
36912
+ },
36766
36913
  {
36767
36914
  id: "restream",
36768
36915
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.push, {}),
@@ -37599,6 +37746,13 @@ const en = {
37599
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.",
37600
37747
  "dev.denied": "DeepSeek Harness needs permission to use the {d}. Open System Settings, switch it on in the list, then retry.",
37601
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",
37602
37756
  "hist.title": "Recent videos",
37603
37757
  "hist.untitled": "Untitled video",
37604
37758
  "hist.live": "live",
@@ -38053,6 +38207,13 @@ const zh = {
38053
38207
  "dev.blocked": "设备没有传来画面和声音:DeepSeek Harness 需要系统授权。点「打开系统设置」,在列表里打开 DeepSeek Harness,再点重试。",
38054
38208
  "dev.denied": "DeepSeek Harness 需要{d}权限。点「打开系统设置」,在列表里打开它,再点重试。",
38055
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": "回放",
38056
38217
  "hist.title": "最近的视频",
38057
38218
  "hist.untitled": "未命名视频",
38058
38219
  "hist.live": "直播",
@@ -38743,6 +38904,21 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
38743
38904
  .dshl-history .mm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font-size: 12px; }
38744
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; }
38745
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; }
38746
38922
  .dshl-modemenu { display: grid; gap: 4px; width: 300px; max-width: 100%; }
38747
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; }
38748
38924
  .dshl-modemenu .mm:hover { background: rgba(255,255,255,.08); }
@@ -39187,9 +39363,9 @@ function videoHandlers(sessions, workspaces, sessionId, fallbackTitle) {
39187
39363
  }
39188
39364
  },
39189
39365
  /** Open a past video in its own conversation; false when that conversation is gone. */
39190
- onOpenVideo: async (conversationId, jobId) => {
39366
+ onOpenVideo: async (conversationId, jobId, pending) => {
39191
39367
  if (!conversationId || !sessions.has(conversationId)) return false;
39192
- await fetch("/live/api/reopen", {
39368
+ if (!pending) await fetch("/live/api/reopen", {
39193
39369
  method: "POST",
39194
39370
  headers: { "content-type": "application/json" },
39195
39371
  body: JSON.stringify({
@@ -39199,7 +39375,8 @@ function videoHandlers(sessions, workspaces, sessionId, fallbackTitle) {
39199
39375
  }).catch(() => {});
39200
39376
  await openPage(sessions, {
39201
39377
  sessionId: conversationId,
39202
- workspaces
39378
+ workspaces,
39379
+ pending
39203
39380
  });
39204
39381
  return true;
39205
39382
  },
package/lib/index.mjs CHANGED
@@ -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;
@@ -2860,6 +2886,76 @@ var LiveJob = class LiveJob extends EventEmitter {
2860
2886
  })).join("\n") + "\n");
2861
2887
  this.emit("cues", this.cues);
2862
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
+ }
2863
2959
  /** Append a finished cue (with its translation, if any) to cues.jsonl. */
2864
2960
  record(cue) {
2865
2961
  appendFileSync(join(this.deps.dir, "cues.jsonl"), JSON.stringify(cue) + "\n");
@@ -6774,6 +6870,8 @@ function apply(ctx, config) {
6774
6870
  if (!job) return send(res, 404, { error: "no such job" });
6775
6871
  if (b.action === "pause") await job.pauseSubs();
6776
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();
6777
6875
  const opts = {};
6778
6876
  if (Array.isArray(b.tgtLangs)) opts.tgtLangs = b.tgtLangs.map(String).slice(0, 4);
6779
6877
  if (typeof b.srcLang === "string") opts.srcLang = b.srcLang;
@@ -7050,6 +7148,13 @@ function apply(ctx, config) {
7050
7148
  createReadStream(file).pipe(res);
7051
7149
  return;
7052
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
+ }
7053
7158
  if (route === "reopen" && req.method === "POST") {
7054
7159
  const b = await readJson(req);
7055
7160
  const job = loadJob(String(b.jobId));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.9.1",
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",