dsh-vidi 0.7.1 → 0.7.3

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 +164 -11
  2. package/lib/index.mjs +129 -23
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -255,6 +255,23 @@ const Icon = {
255
255
  strokeWidth: 2.6,
256
256
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
257
257
  }),
258
+ sidePanel: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
259
+ viewBox: "0 0 24 24",
260
+ width: "18",
261
+ height: "18",
262
+ fill: "none",
263
+ stroke: "currentColor",
264
+ strokeWidth: "1.8",
265
+ strokeLinecap: "round",
266
+ strokeLinejoin: "round",
267
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
268
+ x: "3",
269
+ y: "4",
270
+ width: "18",
271
+ height: "16",
272
+ rx: "2.5"
273
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 4v16" })]
274
+ }),
258
275
  plus: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
259
276
  viewBox: "0 0 24 24",
260
277
  width: "14",
@@ -844,7 +861,9 @@ function Pick(props) {
844
861
  if (!list.current?.contains(e.target) && !btn.current?.contains(e.target)) close(false);
845
862
  };
846
863
  const gone = (e) => {
847
- if (!list.current?.contains(e.target)) close(false);
864
+ const t = e.target;
865
+ if (list.current?.contains(t)) return;
866
+ if (e.type === "resize" || t === document || t === document.documentElement || t instanceof Node && btn.current && t.contains(btn.current)) close(false);
848
867
  };
849
868
  document.addEventListener("pointerdown", away, true);
850
869
  window.addEventListener("resize", gone);
@@ -35169,6 +35188,80 @@ function upload(file, onProgress) {
35169
35188
  });
35170
35189
  }
35171
35190
  /** A value to copy: plain text (not an input look-alike) and a text button. */
35191
+ /** The push stream key: copy it, or change it (typed, or a new random one). */
35192
+ function KeyRow({ value, t, onChanged }) {
35193
+ const [edit, setEdit] = (0, react.useState)(null);
35194
+ const [err, setErr] = (0, react.useState)("");
35195
+ const [done, setDone] = (0, react.useState)(false);
35196
+ const save = async (body) => {
35197
+ if (typeof body.key === "string" && !/^[A-Za-z0-9_-]{3,40}$/.test(body.key.trim())) {
35198
+ setErr(t("push.badKey"));
35199
+ return;
35200
+ }
35201
+ try {
35202
+ await post("pushkey", body);
35203
+ setEdit(null);
35204
+ setErr("");
35205
+ onChanged();
35206
+ } catch (e) {
35207
+ setErr(e.message);
35208
+ }
35209
+ };
35210
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("push.key") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dd", { children: [edit === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
35211
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35212
+ className: "val",
35213
+ children: value
35214
+ }),
35215
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35216
+ className: "dshl-link",
35217
+ onClick: () => {
35218
+ navigator.clipboard.writeText(value);
35219
+ setDone(true);
35220
+ setTimeout(() => setDone(false), 1500);
35221
+ },
35222
+ children: done ? t("push.copied") : t("push.copy")
35223
+ }),
35224
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35225
+ className: "dshl-link",
35226
+ onClick: () => setEdit(value),
35227
+ children: t("push.change")
35228
+ })
35229
+ ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
35230
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
35231
+ className: "dshl-keyedit",
35232
+ value: edit,
35233
+ spellCheck: false,
35234
+ autoFocus: true,
35235
+ onFocus: (e) => e.target.select(),
35236
+ onChange: (e) => setEdit(e.target.value),
35237
+ onKeyDown: (e) => {
35238
+ if (e.key === "Enter") save({ key: edit });
35239
+ if (e.key === "Escape") setEdit(null);
35240
+ }
35241
+ }),
35242
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35243
+ className: "dshl-link",
35244
+ onClick: () => void save({ key: edit }),
35245
+ children: t("push.save")
35246
+ }),
35247
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35248
+ className: "dshl-link",
35249
+ onClick: () => void save({ random: true }),
35250
+ children: t("push.random")
35251
+ }),
35252
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35253
+ className: "dshl-link",
35254
+ onClick: () => {
35255
+ setEdit(null);
35256
+ setErr("");
35257
+ },
35258
+ children: t("player.cancel")
35259
+ })
35260
+ ] }), err && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35261
+ className: "err",
35262
+ children: err
35263
+ })] })] });
35264
+ }
35172
35265
  function CopyRow({ label, value, copy, copied }) {
35173
35266
  const [done, setDone] = (0, react.useState)(false);
35174
35267
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dd", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -35239,6 +35332,29 @@ function LiveView(props) {
35239
35332
  const [subStyle, setSubStyle] = (0, react.useState)(() => loadStyle(lines));
35240
35333
  const [delay, setDelay] = (0, react.useState)(8);
35241
35334
  const [theater, setTheater] = (0, react.useState)(false);
35335
+ const [sideOpen, setSideOpenRaw] = (0, react.useState)(() => store.get("sideOpen", true));
35336
+ const setSideOpen = (v) => {
35337
+ setSideOpenRaw(v);
35338
+ store.set("sideOpen", v);
35339
+ };
35340
+ const [sideW, setSideWRaw] = (0, react.useState)(() => store.get("sideW", 380));
35341
+ const mainRef = (0, react.useRef)(null);
35342
+ const dragSide = (e) => {
35343
+ e.preventDefault();
35344
+ const box = mainRef.current?.getBoundingClientRect();
35345
+ if (!box) return;
35346
+ const move = (ev) => setSideWRaw(Math.round(Math.min(box.width * .6, Math.max(280, box.right - ev.clientX - 8))));
35347
+ const up = () => {
35348
+ window.removeEventListener("pointermove", move);
35349
+ window.removeEventListener("pointerup", up);
35350
+ setSideWRaw((w) => {
35351
+ store.set("sideW", w);
35352
+ return w;
35353
+ });
35354
+ };
35355
+ window.addEventListener("pointermove", move);
35356
+ window.addEventListener("pointerup", up);
35357
+ };
35242
35358
  const [tab, setTab] = (0, react.useState)("transcript");
35243
35359
  const [query, setQuery] = (0, react.useState)("");
35244
35360
  const [trMode, setTrMode] = (0, react.useState)("lines");
@@ -35739,11 +35855,10 @@ function LiveView(props) {
35739
35855
  value: armed.rtmp,
35740
35856
  copy: t("push.copy"),
35741
35857
  copied: t("push.copied")
35742
- }), armed.key && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyRow, {
35743
- label: t("push.key"),
35858
+ }), armed.key && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyRow, {
35744
35859
  value: armed.key,
35745
- copy: t("push.copy"),
35746
- copied: t("push.copied")
35860
+ t,
35861
+ onChanged: () => void post("arm", options()).then((a) => setArmed(a.busy ? null : a)).catch(() => {})
35747
35862
  })] })]
35748
35863
  })
35749
35864
  ]
@@ -35951,7 +36066,8 @@ function LiveView(props) {
35951
36066
  })]
35952
36067
  }) : null;
35953
36068
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35954
- className: `dshl ${theater && !activeBench ? "theater" : ""} ${pageMode ? "page" : ""} ${activeBench ? "bench" : ""}`,
36069
+ className: `dshl ${theater && !activeBench ? "theater" : ""} ${pageMode ? "page" : ""} ${activeBench ? "bench" : ""} ${pageMode && !sideOpen && !activeBench ? "noside" : ""}`,
36070
+ style: pageMode ? { ["--side-w"]: `${sideW}px` } : void 0,
35955
36071
  "data-dsh-own-drop": "dsh-vidi",
35956
36072
  onDragEnter: (e) => {
35957
36073
  if (e.dataTransfer.types.includes("Files")) e.stopPropagation();
@@ -36053,6 +36169,13 @@ function LiveView(props) {
36053
36169
  open: settingsOpen,
36054
36170
  onOpen: setSettingsOpen,
36055
36171
  t
36172
+ }),
36173
+ pageMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
36174
+ className: `dshl-ctl icon ${sideOpen ? "on" : ""}`,
36175
+ title: t(sideOpen ? "side.hide" : "side.show"),
36176
+ "aria-pressed": sideOpen,
36177
+ onClick: () => setSideOpen(!sideOpen),
36178
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.sidePanel, {})
36056
36179
  })
36057
36180
  ]
36058
36181
  })
@@ -36082,6 +36205,7 @@ function LiveView(props) {
36082
36205
  children: emptyBox
36083
36206
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
36084
36207
  className: "dshl-main",
36208
+ ref: mainRef,
36085
36209
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36086
36210
  className: "dshl-col",
36087
36211
  children: !hasMain || !job ? emptyBox : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
@@ -36233,9 +36357,13 @@ function LiveView(props) {
36233
36357
  children: transcriptBox
36234
36358
  })
36235
36359
  ] })
36236
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
36360
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("aside", {
36237
36361
  className: "dshl-side",
36238
- children: activeBench && view ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36362
+ children: [pageMode && !activeBench && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36363
+ className: "dshl-resize",
36364
+ onPointerDown: dragSide,
36365
+ title: t("side.resize")
36366
+ }), activeBench && view ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36239
36367
  className: "dshl-benchbox",
36240
36368
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Guard, {
36241
36369
  name: `workbench ${activeBench.id}`,
@@ -36252,7 +36380,7 @@ function LiveView(props) {
36252
36380
  hasVideo: !!job,
36253
36381
  jobsDir,
36254
36382
  t
36255
- }) : transcriptBox
36383
+ }) : transcriptBox]
36256
36384
  })]
36257
36385
  }),
36258
36386
  dragging && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -36823,6 +36951,13 @@ const en = {
36823
36951
  "rs.overlay": "Subtitles for OBS (Browser Source)",
36824
36952
  "rs.overlay.d": "Add a Browser Source in OBS with this address (1920×1080): the newest subtitles on a transparent background, over your camera or any picture. Add ?lines=zh,src&size=4.5 to choose lines and size.",
36825
36953
  "rs.copy": "Copy",
36954
+ "push.change": "Change",
36955
+ "push.save": "Save",
36956
+ "push.random": "New random key",
36957
+ "push.badKey": "3–40 letters, digits, - or _",
36958
+ "side.hide": "Hide the chat",
36959
+ "side.show": "Show the chat",
36960
+ "side.resize": "Drag to change the width",
36826
36961
  "rs.openPage": "Open",
36827
36962
  "rs.screen": "Subtitle screen (16:9)",
36828
36963
  "rs.screen.d": "Only the subtitles, large, for a big screen or projector: open it in any browser on this network and go full screen (on this computer: {local}). Add ?theme=light for a light screen; it can also be an OBS source of its own.",
@@ -37204,6 +37339,13 @@ const zh = {
37204
37339
  "rs.overlay": "OBS 字幕叠加(浏览器源)",
37205
37340
  "rs.overlay.d": "在 OBS 里加一个「浏览器源」,填这个地址(1920×1080):透明背景上只有最新的字幕,叠在摄像头或任何画面上。地址后加 ?lines=zh,src&size=4.5 可以选行和字号。",
37206
37341
  "rs.copy": "复制",
37342
+ "push.change": "修改",
37343
+ "push.save": "保存",
37344
+ "push.random": "换一个",
37345
+ "push.badKey": "用 3–40 位字母、数字、- 或 _",
37346
+ "side.hide": "收起对话",
37347
+ "side.show": "展开对话",
37348
+ "side.resize": "拖动调整宽度",
37207
37349
  "rs.openPage": "打开",
37208
37350
  "rs.screen": "字幕大屏(16:9)",
37209
37351
  "rs.screen.d": "只有字幕、字很大,适合大屏和投影:在同一网络里任何浏览器打开、全屏即可(本机用 {local})。地址后加 ?theme=light 换浅色;也可以作为 OBS 的一个独立画面来源。",
@@ -37700,6 +37842,10 @@ const CSS = `
37700
37842
  .dshl-pushinfo dl { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 6px 16px; margin: 0; }
37701
37843
  .dshl-pushinfo dt { color: var(--faint); font-size: 12px; }
37702
37844
  .dshl-pushinfo dd { display: flex; align-items: center; gap: 14px; min-width: 0; margin: 0; }
37845
+ .dshl-keyedit { width: 140px; height: 26px; padding: 0 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
37846
+ .dshl-keyedit:focus { outline: none; border-color: var(--accent); }
37847
+ .dshl-pushinfo dd:has(.err) { flex-wrap: wrap; row-gap: 4px; }
37848
+ .dshl-pushinfo dd .err { flex-basis: 100%; color: #d1434b; font-size: 12px; }
37703
37849
  .dshl-pushinfo .val { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; user-select: all; }
37704
37850
  .dshl-pushinfo .dshl-link { flex: none; font-size: 12.5px; }
37705
37851
 
@@ -37736,12 +37882,18 @@ html[data-platform='darwin'] .dshl-panelwrap .dshl.page .dshl-top { padding-top:
37736
37882
  html[data-platform='darwin'] .dshl.page .dshl-top { -webkit-app-region: drag; app-region: drag; }
37737
37883
  html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .dshl-select, .dshl-pick) { -webkit-app-region: no-drag; app-region: no-drag; }
37738
37884
  .dshl.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
37739
- .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; }
37885
+ .dshl.page .dshl-main { flex: 1; min-height: 0; grid-template-columns: minmax(0, 1fr) var(--side-w, clamp(320px, 30vw, 420px)); align-items: stretch; padding-bottom: 16px; }
37740
37886
  /* Video page: player on top, then the status row, then subtitles/glossary filling what is left; chat on the right. */
37741
37887
  .dshl.page .dshl-col { display: flex; flex-direction: column; min-height: 0; container-type: size; }
37742
37888
  .dshl.page .dshl-col > .dshl-drop { flex: 1; aspect-ratio: auto; min-height: 0; }
37743
37889
  .dshl.page .dshl-stage { flex: none; width: 100%; max-height: max(240px, calc(100cqh - 270px)); }
37744
- .dshl.page .dshl-side { position: static; height: 100%; max-height: none; }
37890
+ .dshl.page .dshl-side { position: relative; top: auto; height: 100%; max-height: none; }
37891
+ .dshl.page.noside .dshl-main { grid-template-columns: minmax(0, 1fr); }
37892
+ .dshl.page.noside .dshl-side { display: none; }
37893
+ /* Drag the chat panel's left edge to change its width; the player and subtitles take the rest. */
37894
+ .dshl-resize { position: absolute; top: 0; bottom: 0; left: 0; z-index: 3; width: 8px; cursor: col-resize; }
37895
+ .dshl-resize::after { content: ''; position: absolute; top: 50%; left: 2px; width: 3px; height: 36px; border-radius: 2px; background: var(--line); transform: translateY(-50%); opacity: 0; transition: opacity .15s; }
37896
+ .dshl-resize:hover::after { opacity: 1; }
37745
37897
  /* Workbench open: the video and its subtitles keep a column on the left; the bench takes the rest. */
37746
37898
  .dshl.page.bench .dshl-main { grid-template-columns: clamp(340px, 32vw, 520px) minmax(0, 1fr); }
37747
37899
  .dshl-lag { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: -2px 0 8px; padding: 7px 12px; border-radius: 8px; background: var(--l1, rgba(0,0,0,.04)); color: var(--muted); font-size: 12.5px; }
@@ -37762,6 +37914,7 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37762
37914
  .dshl.page.theater .dshl-stage { max-height: 100cqh; }
37763
37915
  @media (max-width: 1000px) {
37764
37916
  .dshl.page { display: block; overflow: auto; }
37917
+ .dshl-resize { display: none; }
37765
37918
  .dshl.page.bench .dshl-main { grid-template-columns: minmax(0, 1fr); }
37766
37919
  .dshl.page.bench .dshl-side { height: auto; min-height: 0; }
37767
37920
  .dshl-benchbox { overflow: visible; }
package/lib/index.mjs CHANGED
@@ -2289,11 +2289,28 @@ var LiveJob = class LiveJob extends EventEmitter {
2289
2289
  }
2290
2290
  }, 400);
2291
2291
  }
2292
- ff.stderr.on("data", (d) => appendFileSync(join(this.deps.dir, "ffmpeg.log"), d));
2292
+ let rejected = false;
2293
+ ff.stderr.on("data", (d) => {
2294
+ appendFileSync(join(this.deps.dir, "ffmpeg.log"), d);
2295
+ if (this.opts.source === "push" && !rejected && /Unexpected stream/.test(d.toString())) {
2296
+ rejected = true;
2297
+ ff.kill("SIGKILL");
2298
+ }
2299
+ });
2293
2300
  ff.on("exit", (code) => {
2294
2301
  if (this.ffmpeg !== ff) return;
2295
2302
  this.ffmpeg = void 0;
2296
2303
  if (this.status !== "running") return;
2304
+ if (this.opts.source === "push" && this.message && (rejected || !this.segments().length)) {
2305
+ if (rejected) rmSync(hls, {
2306
+ recursive: true,
2307
+ force: true
2308
+ });
2309
+ setTimeout(() => {
2310
+ if (this.status === "running" && !this.ffmpeg) this.spawnFfmpeg(input);
2311
+ }, 500);
2312
+ return;
2313
+ }
2297
2314
  this.finishCapture(code === 0 ? "ended" : this.ffmpegError(code), code === 0 || this.segments().length > 0);
2298
2315
  });
2299
2316
  }
@@ -4725,6 +4742,8 @@ var Stream = class {
4725
4742
  cjk;
4726
4743
  open = [];
4727
4744
  done = [];
4745
+ /** When the last piece arrived (wall clock, ms). */
4746
+ lastWall = 0;
4728
4747
  constructor(cjk) {
4729
4748
  this.cjk = cjk;
4730
4749
  }
@@ -4754,18 +4773,51 @@ var Stream = class {
4754
4773
  this.push(p);
4755
4774
  }
4756
4775
  push(p) {
4776
+ this.lastWall = Date.now();
4757
4777
  this.open.push(p);
4758
4778
  const text = this.text.trim();
4759
4779
  const len = this.cjk ? Array.from(text).length : text.length;
4760
4780
  const soft = this.cjk ? SOFT_CJK : SOFT_LATIN, hard = this.cjk ? HARD_CJK : HARD_LATIN;
4761
4781
  if (END.test(text) || len >= soft && /[,,、;;::]\s*$/.test(text) || len >= hard) this.close();
4762
4782
  }
4783
+ /** End the sentence at its last comma or full stop (the rest starts the next one); false when it has none. */
4784
+ closeAtPause() {
4785
+ const text = this.text;
4786
+ let cut = -1;
4787
+ for (let i = text.length - 2; i > 0; i--) if (/[,。、;:!?,;:!?]/.test(text[i])) {
4788
+ cut = i + 1;
4789
+ break;
4790
+ }
4791
+ if (cut <= 0) return false;
4792
+ const head = [], tail = [];
4793
+ let n = 0;
4794
+ for (const p of this.open) {
4795
+ if (n + p.text.length <= cut) head.push(p);
4796
+ else if (n >= cut) tail.push(p);
4797
+ else {
4798
+ head.push({
4799
+ t: p.t,
4800
+ text: p.text.slice(0, cut - n)
4801
+ });
4802
+ tail.push({
4803
+ t: p.t,
4804
+ text: p.text.slice(cut - n)
4805
+ });
4806
+ }
4807
+ n += p.text.length;
4808
+ }
4809
+ this.open = head;
4810
+ this.close();
4811
+ this.open = tail;
4812
+ return true;
4813
+ }
4763
4814
  close() {
4764
4815
  const text = this.text.trim();
4765
4816
  if (text) this.done.push({
4766
4817
  text,
4767
4818
  start: this.open[0].t,
4768
- end: this.open.at(-1).t
4819
+ end: this.open.at(-1).t,
4820
+ wall: Date.now()
4769
4821
  });
4770
4822
  this.open = [];
4771
4823
  }
@@ -4791,6 +4843,11 @@ var OpenAITranslateProvider = class {
4791
4843
  reconnects = 0;
4792
4844
  /** Audio sent so far (s); a session's positions count from `base`. */
4793
4845
  sent = 0;
4846
+ /** Audio waiting to be sent: the service listens in real time, so bursts (a recording read from its
4847
+ * start) go up at most a little faster than that. */
4848
+ queue = [];
4849
+ queued = 0;
4850
+ drain;
4794
4851
  base = 0;
4795
4852
  src = new Stream(false);
4796
4853
  tr;
@@ -4801,6 +4858,8 @@ var OpenAITranslateProvider = class {
4801
4858
  seq = 0;
4802
4859
  /** End of the last finished line (s): everything before it is on screen. */
4803
4860
  readyAt = 0;
4861
+ /** Where the last line ends on screen (lines never overlap). */
4862
+ lastEnd = 0;
4804
4863
  /** Arrival delay of text after its audio position, for the page (s). */
4805
4864
  lag = 0;
4806
4865
  connectedAt = 0;
@@ -4821,6 +4880,7 @@ var OpenAITranslateProvider = class {
4821
4880
  this.onError = onError;
4822
4881
  await this.connect();
4823
4882
  this.quiet = setInterval(() => this.settle(), 400);
4883
+ this.drain = setInterval(() => this.sendDue(), 100);
4824
4884
  }
4825
4885
  connect() {
4826
4886
  return new Promise((resolve, reject) => {
@@ -4874,35 +4934,52 @@ var OpenAITranslateProvider = class {
4874
4934
  }
4875
4935
  push(pcm) {
4876
4936
  if (this.stopped) return;
4877
- this.send({
4878
- type: "session.input_audio_buffer.append",
4879
- audio: this.up.push(Buffer.from(pcm)).toString("base64")
4880
- });
4881
- this.sent += pcm.length / BYTES_PER_SEC;
4937
+ this.queue.push(Buffer.from(pcm));
4938
+ this.queued += pcm.length / BYTES_PER_SEC;
4939
+ this.sendDue();
4940
+ }
4941
+ /** Send queued audio, everything including silence, no faster than 1.5× real time since the session began. */
4942
+ sendDue() {
4943
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
4944
+ const allowed = this.base + (Date.now() - this.connectedAt) / 1e3 * 1.5 + 2;
4945
+ while (this.queue.length && this.sent < allowed) {
4946
+ const pcm = this.queue.shift();
4947
+ this.queued -= pcm.length / BYTES_PER_SEC;
4948
+ this.send({
4949
+ type: "session.input_audio_buffer.append",
4950
+ audio: this.up.push(pcm).toString("base64")
4951
+ });
4952
+ this.sent += pcm.length / BYTES_PER_SEC;
4953
+ }
4882
4954
  }
4883
- /** Audio sent but not yet in a finished line. */
4955
+ /** Audio received but not yet in a finished line (queued, or sent and still being worked on). */
4884
4956
  backlog() {
4885
- return Math.max(0, this.sent - this.readyAt);
4957
+ return this.queued + Math.max(0, this.sent - this.readyAt);
4886
4958
  }
4887
4959
  onEvent(ev) {
4888
4960
  if (ev.type === "session.input_transcript.delta" || ev.type === "session.output_transcript.delta") {
4889
4961
  const t = this.base + (ev.elapsed_ms ?? 0) / 1e3;
4890
- const arrived = this.base + (Date.now() - this.connectedAt) / 1e3;
4891
- this.lag = this.lag ? this.lag * .9 + (arrived - t) * .1 : arrived - t;
4962
+ const behind = Math.max(0, this.sent - t);
4963
+ this.lag = this.lag ? this.lag * .9 + behind * .1 : behind;
4892
4964
  const piece = {
4893
4965
  t,
4894
4966
  text: String(ev.delta ?? "")
4895
4967
  };
4896
4968
  if (ev.type === "session.input_transcript.delta") this.src.add(piece);
4897
- else this.tr.add(piece);
4969
+ else {
4970
+ const next = this.src.done[this.srcUsed];
4971
+ if (next && this.tr.text.trim() && t - next.end >= 1.2) {
4972
+ if (!this.tr.closeAtPause() && t - next.end >= 3) this.tr.close();
4973
+ }
4974
+ this.tr.add(piece);
4975
+ }
4898
4976
  this.pair(false);
4899
4977
  this.draft();
4900
4978
  } else if (ev.type === "error") this.onError?.(/* @__PURE__ */ new Error(`OpenAI: ${ev.error?.message ?? "error"}`));
4901
4979
  }
4902
4980
  /** Close streams that went quiet (a pause without punctuation), then pair what is complete. */
4903
4981
  settle() {
4904
- const now = this.sent;
4905
- for (const s of [this.src, this.tr]) if (s.last !== void 0 && now - s.last > QUIET_SEC + this.lag) s.close();
4982
+ for (const s of [this.src, this.tr]) if (s.last !== void 0 && Date.now() - s.lastWall > QUIET_SEC * 1e3) s.close();
4906
4983
  this.pair(false);
4907
4984
  this.draft();
4908
4985
  }
@@ -4913,7 +4990,10 @@ var OpenAITranslateProvider = class {
4913
4990
  */
4914
4991
  pair(all) {
4915
4992
  while (this.tr.done.length) {
4916
- const t = this.tr.done.shift();
4993
+ const t = this.tr.done[0];
4994
+ const srcReady = this.srcUsed < this.src.done.length && this.src.done[this.srcUsed].end <= t.end + .5;
4995
+ if (!all && !srcReady && this.src.text.trim() && Date.now() - t.wall < 1500) break;
4996
+ this.tr.done.shift();
4917
4997
  const want = Array.from(t.text).length * this.ratio;
4918
4998
  const src = [];
4919
4999
  let len = 0;
@@ -4936,28 +5016,37 @@ var OpenAITranslateProvider = class {
4936
5016
  }
4937
5017
  line(t, src) {
4938
5018
  const srcText = src.map((s) => s.text).join(" ").trim();
4939
- const start = Math.max(0, Math.min(t?.start ?? Infinity, src[0]?.start ?? Infinity) - .4);
4940
- const end = Math.max(t?.end ?? 0, src.at(-1)?.end ?? 0);
5019
+ const spoken = src.length ? {
5020
+ start: src[0].start - .6,
5021
+ end: src.at(-1).end - .2
5022
+ } : {
5023
+ start: (t?.start ?? 0) - 1.4,
5024
+ end: (t?.end ?? 0) - .8
5025
+ };
5026
+ const start = Math.max(0, this.lastEnd, spoken.start);
5027
+ const end = Math.max(start + 1, spoken.end);
5028
+ this.lastEnd = end;
4941
5029
  this.emit?.({
4942
5030
  key: `ot:${this.seq++}`,
4943
5031
  start,
4944
- end: Math.max(end, start + .8),
5032
+ end,
4945
5033
  src: srcText,
4946
5034
  tr: t ? { [this.opts.target]: t.text } : void 0,
4947
5035
  final: true
4948
5036
  });
4949
- this.readyAt = Math.max(this.readyAt, end);
5037
+ this.readyAt = Math.max(this.readyAt, t?.end ?? end, end);
4950
5038
  }
4951
5039
  /** The sentence being translated, as a draft line. */
4952
5040
  draft() {
4953
5041
  const tr = this.tr.text.trim();
4954
5042
  if (!tr) return;
4955
5043
  const srcText = [...this.src.done.slice(this.srcUsed).map((s) => s.text), this.src.text.trim()].filter(Boolean).join(" ");
4956
- const start = Math.max(0, Math.min(this.tr.start ?? Infinity, this.src.done[this.srcUsed]?.start ?? this.src.start ?? Infinity) - .4);
5044
+ const from = this.src.done[this.srcUsed]?.start ?? this.src.start;
5045
+ const start = Math.max(0, this.lastEnd, from !== void 0 ? from - .6 : (this.tr.start ?? 0) - 1.4);
4957
5046
  this.emit?.({
4958
5047
  key: `ot:${this.seq}`,
4959
5048
  start,
4960
- end: Math.max(this.tr.last ?? start, start + .8),
5049
+ end: Math.max(start + 1, (this.src.last ?? this.tr.last ?? start) - .2),
4961
5050
  src: srcText,
4962
5051
  tr: { [this.opts.target]: tr },
4963
5052
  final: false
@@ -4970,6 +5059,7 @@ var OpenAITranslateProvider = class {
4970
5059
  async stop() {
4971
5060
  await new Promise((r) => setTimeout(r, 2500));
4972
5061
  if (this.quiet) clearInterval(this.quiet);
5062
+ if (this.drain) clearInterval(this.drain);
4973
5063
  this.src.close();
4974
5064
  this.tr.close();
4975
5065
  this.pair(true);
@@ -5807,7 +5897,14 @@ function apply(ctx, config) {
5807
5897
  * page owns it.
5808
5898
  */
5809
5899
  let armed;
5810
- const PUSH_KEY = "dsh";
5900
+ const hadSettings = existsSync(settingsFile);
5901
+ const pushKey = () => {
5902
+ const saved = prefs().pushKey;
5903
+ if (saved) return saved;
5904
+ const key = hadSettings ? "dsh" : Math.random().toString(36).slice(2, 10);
5905
+ savePrefs({ pushKey: key });
5906
+ return key;
5907
+ };
5811
5908
  async function disarm() {
5812
5909
  const a = armed;
5813
5910
  armed = void 0;
@@ -5879,7 +5976,7 @@ function apply(ctx, config) {
5879
5976
  host: lanAddress(),
5880
5977
  rtmpPort: config.rtmpPort,
5881
5978
  srtPort: config.srtPort,
5882
- key: waiting ? PUSH_KEY : void 0
5979
+ key: waiting ? pushKey() : void 0
5883
5980
  },
5884
5981
  cookies: () => prefs().cookies
5885
5982
  };
@@ -6109,6 +6206,15 @@ function apply(ctx, config) {
6109
6206
  }
6110
6207
  return send(res, 200, job.summary());
6111
6208
  }
6209
+ if (route === "pushkey" && req.method === "POST") {
6210
+ if ([...jobs.values()].some((j) => j.opts.source === "push" && j.status === "running" && !j.message)) return send(res, 409, { error: "a stream is coming in; change the key after it ends" });
6211
+ const b = await readJson(req);
6212
+ const key = b.random ? Math.random().toString(36).slice(2, 10) : String(b.key ?? "").trim();
6213
+ if (!/^[A-Za-z0-9_-]{3,40}$/.test(key)) return send(res, 400, { error: "use 3–40 letters, digits, - or _" });
6214
+ savePrefs({ pushKey: key });
6215
+ await disarm();
6216
+ return send(res, 200, { key });
6217
+ }
6112
6218
  if (route === "arm" && req.method === "POST") {
6113
6219
  const b = await readJson(req);
6114
6220
  return send(res, 200, await arm({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",