dsh-vidi 0.7.0 → 0.7.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 +125 -28
  2. package/lib/index.mjs +164 -22
  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);
@@ -34066,10 +34085,21 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
34066
34085
  ]
34067
34086
  }),
34068
34087
  openMenu && (() => {
34088
+ const phone = narrow && window.innerWidth < 700 && !full && !pageFull;
34089
+ const floating = narrow && !phone && !full && !pageFull;
34090
+ const r = floating ? stage.current?.getBoundingClientRect() : void 0;
34091
+ const place = r ? {
34092
+ position: "fixed",
34093
+ right: Math.max(8, window.innerWidth - r.right + 8),
34094
+ bottom: Math.max(8, window.innerHeight - r.bottom + 56),
34095
+ width: Math.min(360, window.innerWidth - 16),
34096
+ maxHeight: Math.max(240, r.bottom - 56 - 12)
34097
+ } : void 0;
34069
34098
  const panel = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34070
- className: `dshl-menu ${narrow ? "sheet" : ""}`,
34099
+ className: `dshl-menu ${phone ? "sheet" : ""}`,
34071
34100
  role: "dialog",
34072
34101
  "aria-label": openMenu.title,
34102
+ style: place,
34073
34103
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34074
34104
  className: "head",
34075
34105
  children: [
@@ -34094,13 +34124,13 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
34094
34124
  children: openMenu.content
34095
34125
  })]
34096
34126
  });
34097
- return narrow && !full && !pageFull ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
34127
+ return phone ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
34098
34128
  className: "dshl-sheetwrap",
34099
34129
  onMouseDown: (e) => {
34100
34130
  if (e.target === e.currentTarget) setMenu(null);
34101
34131
  },
34102
34132
  children: panel
34103
- }), document.body) : panel;
34133
+ }), document.body) : floating ? (0, react_dom.createPortal)(panel, document.body) : panel;
34104
34134
  })(),
34105
34135
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34106
34136
  className: "dshl-controls",
@@ -34628,38 +34658,49 @@ function RestreamPanel(props) {
34628
34658
  ]
34629
34659
  });
34630
34660
  }
34631
- /** The subtitle page for OBS's Browser Source: lay the subtitles over your own picture without encoding anything. */
34661
+ /** Subtitle pages: the overlay for OBS's Browser Source, and the big screen of subtitles only. */
34632
34662
  function OverlayLink({ t }) {
34633
- const [url, setUrl] = (0, react.useState)("");
34634
- const [copied, setCopied] = (0, react.useState)(false);
34663
+ const [urls, setUrls] = (0, react.useState)({});
34664
+ const [copied, setCopied] = (0, react.useState)("");
34635
34665
  (0, react.useEffect)(() => {
34636
- fetch("/live/api/overlay").then((r) => r.json()).then((j) => setUrl(j.url ?? "")).catch(() => {});
34666
+ fetch("/live/api/overlay").then((r) => r.json()).then(setUrls).catch(() => {});
34637
34667
  }, []);
34638
- if (!url) return null;
34639
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34668
+ if (!urls.url) return null;
34669
+ const row = (label, url, note, open) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34640
34670
  className: "rs-overlay",
34641
34671
  children: [
34642
34672
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34643
34673
  className: "rs-sub",
34644
- children: t("rs.overlay")
34674
+ children: label
34645
34675
  }),
34646
34676
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34647
34677
  className: "rs-url",
34648
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: url }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34649
- className: "dshl-link",
34650
- onClick: () => void navigator.clipboard.writeText(url).then(() => {
34651
- setCopied(true);
34652
- setTimeout(() => setCopied(false), 1500);
34678
+ children: [
34679
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: url }),
34680
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34681
+ className: "dshl-link",
34682
+ onClick: () => void navigator.clipboard.writeText(url).then(() => {
34683
+ setCopied(url);
34684
+ setTimeout(() => setCopied(""), 1500);
34685
+ }),
34686
+ children: copied === url ? t("tr.copied") : t("rs.copy")
34653
34687
  }),
34654
- children: copied ? t("tr.copied") : t("rs.copy")
34655
- })]
34688
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
34689
+ className: "dshl-link",
34690
+ href: url,
34691
+ target: "_blank",
34692
+ rel: "noreferrer",
34693
+ children: t("rs.openPage")
34694
+ })
34695
+ ]
34656
34696
  }),
34657
34697
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34658
34698
  className: "rs-hint",
34659
- children: t("rs.overlay.d")
34699
+ children: note
34660
34700
  })
34661
34701
  ]
34662
34702
  });
34703
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [row(t("rs.overlay"), urls.url, t("rs.overlay.d")), urls.screen && row(t("rs.screen"), urls.screenLan ?? urls.screen, t("rs.screen.d").replace("{local}", urls.screen), true)] });
34663
34704
  }
34664
34705
 
34665
34706
  // src/client/Settings.tsx
@@ -35217,6 +35258,29 @@ function LiveView(props) {
35217
35258
  const [subStyle, setSubStyle] = (0, react.useState)(() => loadStyle(lines));
35218
35259
  const [delay, setDelay] = (0, react.useState)(8);
35219
35260
  const [theater, setTheater] = (0, react.useState)(false);
35261
+ const [sideOpen, setSideOpenRaw] = (0, react.useState)(() => store.get("sideOpen", true));
35262
+ const setSideOpen = (v) => {
35263
+ setSideOpenRaw(v);
35264
+ store.set("sideOpen", v);
35265
+ };
35266
+ const [sideW, setSideWRaw] = (0, react.useState)(() => store.get("sideW", 380));
35267
+ const mainRef = (0, react.useRef)(null);
35268
+ const dragSide = (e) => {
35269
+ e.preventDefault();
35270
+ const box = mainRef.current?.getBoundingClientRect();
35271
+ if (!box) return;
35272
+ const move = (ev) => setSideWRaw(Math.round(Math.min(box.width * .6, Math.max(280, box.right - ev.clientX - 8))));
35273
+ const up = () => {
35274
+ window.removeEventListener("pointermove", move);
35275
+ window.removeEventListener("pointerup", up);
35276
+ setSideWRaw((w) => {
35277
+ store.set("sideW", w);
35278
+ return w;
35279
+ });
35280
+ };
35281
+ window.addEventListener("pointermove", move);
35282
+ window.addEventListener("pointerup", up);
35283
+ };
35220
35284
  const [tab, setTab] = (0, react.useState)("transcript");
35221
35285
  const [query, setQuery] = (0, react.useState)("");
35222
35286
  const [trMode, setTrMode] = (0, react.useState)("lines");
@@ -35929,7 +35993,8 @@ function LiveView(props) {
35929
35993
  })]
35930
35994
  }) : null;
35931
35995
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35932
- className: `dshl ${theater && !activeBench ? "theater" : ""} ${pageMode ? "page" : ""} ${activeBench ? "bench" : ""}`,
35996
+ className: `dshl ${theater && !activeBench ? "theater" : ""} ${pageMode ? "page" : ""} ${activeBench ? "bench" : ""} ${pageMode && !sideOpen && !activeBench ? "noside" : ""}`,
35997
+ style: pageMode ? { ["--side-w"]: `${sideW}px` } : void 0,
35933
35998
  "data-dsh-own-drop": "dsh-vidi",
35934
35999
  onDragEnter: (e) => {
35935
36000
  if (e.dataTransfer.types.includes("Files")) e.stopPropagation();
@@ -36031,6 +36096,13 @@ function LiveView(props) {
36031
36096
  open: settingsOpen,
36032
36097
  onOpen: setSettingsOpen,
36033
36098
  t
36099
+ }),
36100
+ pageMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
36101
+ className: `dshl-ctl icon ${sideOpen ? "on" : ""}`,
36102
+ title: t(sideOpen ? "side.hide" : "side.show"),
36103
+ "aria-pressed": sideOpen,
36104
+ onClick: () => setSideOpen(!sideOpen),
36105
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.sidePanel, {})
36034
36106
  })
36035
36107
  ]
36036
36108
  })
@@ -36060,6 +36132,7 @@ function LiveView(props) {
36060
36132
  children: emptyBox
36061
36133
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
36062
36134
  className: "dshl-main",
36135
+ ref: mainRef,
36063
36136
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36064
36137
  className: "dshl-col",
36065
36138
  children: !hasMain || !job ? emptyBox : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
@@ -36211,9 +36284,13 @@ function LiveView(props) {
36211
36284
  children: transcriptBox
36212
36285
  })
36213
36286
  ] })
36214
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
36287
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("aside", {
36215
36288
  className: "dshl-side",
36216
- children: activeBench && view ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36289
+ children: [pageMode && !activeBench && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36290
+ className: "dshl-resize",
36291
+ onPointerDown: dragSide,
36292
+ title: t("side.resize")
36293
+ }), activeBench && view ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36217
36294
  className: "dshl-benchbox",
36218
36295
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Guard, {
36219
36296
  name: `workbench ${activeBench.id}`,
@@ -36230,7 +36307,7 @@ function LiveView(props) {
36230
36307
  hasVideo: !!job,
36231
36308
  jobsDir,
36232
36309
  t
36233
- }) : transcriptBox
36310
+ }) : transcriptBox]
36234
36311
  })]
36235
36312
  }),
36236
36313
  dragging && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -36299,7 +36376,7 @@ function SubsStarter(props) {
36299
36376
  const box = (0, react.useRef)(null);
36300
36377
  const pop = (0, react.useRef)(null);
36301
36378
  const toggle = () => {
36302
- setSheet((box.current?.closest(".dshl-stage")?.clientWidth ?? 999) < 560 && !document.fullscreenElement);
36379
+ setSheet(window.innerWidth < 700 && !document.fullscreenElement);
36303
36380
  setOpen((v) => !v);
36304
36381
  };
36305
36382
  (0, react.useEffect)(() => {
@@ -36801,6 +36878,12 @@ const en = {
36801
36878
  "rs.overlay": "Subtitles for OBS (Browser Source)",
36802
36879
  "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.",
36803
36880
  "rs.copy": "Copy",
36881
+ "side.hide": "Hide the chat",
36882
+ "side.show": "Show the chat",
36883
+ "side.resize": "Drag to change the width",
36884
+ "rs.openPage": "Open",
36885
+ "rs.screen": "Subtitle screen (16:9)",
36886
+ "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.",
36804
36887
  "rs.content": "Sends",
36805
36888
  "rs.c.burn": "Picture + subtitles",
36806
36889
  "rs.c.clean": "Picture only",
@@ -37179,6 +37262,12 @@ const zh = {
37179
37262
  "rs.overlay": "OBS 字幕叠加(浏览器源)",
37180
37263
  "rs.overlay.d": "在 OBS 里加一个「浏览器源」,填这个地址(1920×1080):透明背景上只有最新的字幕,叠在摄像头或任何画面上。地址后加 ?lines=zh,src&size=4.5 可以选行和字号。",
37181
37264
  "rs.copy": "复制",
37265
+ "side.hide": "收起对话",
37266
+ "side.show": "展开对话",
37267
+ "side.resize": "拖动调整宽度",
37268
+ "rs.openPage": "打开",
37269
+ "rs.screen": "字幕大屏(16:9)",
37270
+ "rs.screen.d": "只有字幕、字很大,适合大屏和投影:在同一网络里任何浏览器打开、全屏即可(本机用 {local})。地址后加 ?theme=light 换浅色;也可以作为 OBS 的一个独立画面来源。",
37182
37271
  "rs.content": "内容",
37183
37272
  "rs.c.burn": "画面 + 字幕",
37184
37273
  "rs.c.clean": "只要画面",
@@ -37708,12 +37797,18 @@ html[data-platform='darwin'] .dshl-panelwrap .dshl.page .dshl-top { padding-top:
37708
37797
  html[data-platform='darwin'] .dshl.page .dshl-top { -webkit-app-region: drag; app-region: drag; }
37709
37798
  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; }
37710
37799
  .dshl.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
37711
- .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; }
37800
+ .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; }
37712
37801
  /* Video page: player on top, then the status row, then subtitles/glossary filling what is left; chat on the right. */
37713
37802
  .dshl.page .dshl-col { display: flex; flex-direction: column; min-height: 0; container-type: size; }
37714
37803
  .dshl.page .dshl-col > .dshl-drop { flex: 1; aspect-ratio: auto; min-height: 0; }
37715
37804
  .dshl.page .dshl-stage { flex: none; width: 100%; max-height: max(240px, calc(100cqh - 270px)); }
37716
- .dshl.page .dshl-side { position: static; height: 100%; max-height: none; }
37805
+ .dshl.page .dshl-side { position: relative; top: auto; height: 100%; max-height: none; }
37806
+ .dshl.page.noside .dshl-main { grid-template-columns: minmax(0, 1fr); }
37807
+ .dshl.page.noside .dshl-side { display: none; }
37808
+ /* Drag the chat panel's left edge to change its width; the player and subtitles take the rest. */
37809
+ .dshl-resize { position: absolute; top: 0; bottom: 0; left: 0; z-index: 3; width: 8px; cursor: col-resize; }
37810
+ .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; }
37811
+ .dshl-resize:hover::after { opacity: 1; }
37717
37812
  /* Workbench open: the video and its subtitles keep a column on the left; the bench takes the rest. */
37718
37813
  .dshl.page.bench .dshl-main { grid-template-columns: clamp(340px, 32vw, 520px) minmax(0, 1fr); }
37719
37814
  .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; }
@@ -37734,6 +37829,7 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37734
37829
  .dshl.page.theater .dshl-stage { max-height: 100cqh; }
37735
37830
  @media (max-width: 1000px) {
37736
37831
  .dshl.page { display: block; overflow: auto; }
37832
+ .dshl-resize { display: none; }
37737
37833
  .dshl.page.bench .dshl-main { grid-template-columns: minmax(0, 1fr); }
37738
37834
  .dshl.page.bench .dshl-side { height: auto; min-height: 0; }
37739
37835
  .dshl-benchbox { overflow: visible; }
@@ -37966,8 +38062,9 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37966
38062
  .dshl-dl .dshl-chips .chip { background: transparent; }
37967
38063
 
37968
38064
  /* Menus as a bottom sheet on small players (phones) */
37969
- .dshl-sheetwrap { position: fixed; inset: 0; z-index: 2147483002; display: flex; align-items: flex-end; background: rgba(0,0,0,.4); }
37970
- .dshl-menu.sheet { position: relative; right: auto; bottom: auto; width: 100%; max-height: 78vh; padding-bottom: env(safe-area-inset-bottom); border: 0; border-radius: 16px 16px 0 0;
38065
+ .dshl-sheetwrap { position: fixed; inset: 0; z-index: 2147483002; display: flex; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.4); }
38066
+ body > .dshl-menu { z-index: 2147483002; }
38067
+ .dshl-menu.sheet { position: relative; right: auto; bottom: auto; width: 100%; max-width: 560px; max-height: 78vh; padding-bottom: env(safe-area-inset-bottom); border: 0; border-radius: 16px 16px 0 0;
37971
38068
  font-family: system-ui, -apple-system, "PingFang SC", sans-serif; font-size: 15px; }
37972
38069
  .dshl-menu.sheet .head { padding: 14px 12px 8px 18px; font-size: 16px; }
37973
38070
  .dshl-menu.sheet .body { padding: 4px 14px 16px; }
package/lib/index.mjs CHANGED
@@ -4725,6 +4725,8 @@ var Stream = class {
4725
4725
  cjk;
4726
4726
  open = [];
4727
4727
  done = [];
4728
+ /** When the last piece arrived (wall clock, ms). */
4729
+ lastWall = 0;
4728
4730
  constructor(cjk) {
4729
4731
  this.cjk = cjk;
4730
4732
  }
@@ -4754,18 +4756,51 @@ var Stream = class {
4754
4756
  this.push(p);
4755
4757
  }
4756
4758
  push(p) {
4759
+ this.lastWall = Date.now();
4757
4760
  this.open.push(p);
4758
4761
  const text = this.text.trim();
4759
4762
  const len = this.cjk ? Array.from(text).length : text.length;
4760
4763
  const soft = this.cjk ? SOFT_CJK : SOFT_LATIN, hard = this.cjk ? HARD_CJK : HARD_LATIN;
4761
4764
  if (END.test(text) || len >= soft && /[,,、;;::]\s*$/.test(text) || len >= hard) this.close();
4762
4765
  }
4766
+ /** End the sentence at its last comma or full stop (the rest starts the next one); false when it has none. */
4767
+ closeAtPause() {
4768
+ const text = this.text;
4769
+ let cut = -1;
4770
+ for (let i = text.length - 2; i > 0; i--) if (/[,。、;:!?,;:!?]/.test(text[i])) {
4771
+ cut = i + 1;
4772
+ break;
4773
+ }
4774
+ if (cut <= 0) return false;
4775
+ const head = [], tail = [];
4776
+ let n = 0;
4777
+ for (const p of this.open) {
4778
+ if (n + p.text.length <= cut) head.push(p);
4779
+ else if (n >= cut) tail.push(p);
4780
+ else {
4781
+ head.push({
4782
+ t: p.t,
4783
+ text: p.text.slice(0, cut - n)
4784
+ });
4785
+ tail.push({
4786
+ t: p.t,
4787
+ text: p.text.slice(cut - n)
4788
+ });
4789
+ }
4790
+ n += p.text.length;
4791
+ }
4792
+ this.open = head;
4793
+ this.close();
4794
+ this.open = tail;
4795
+ return true;
4796
+ }
4763
4797
  close() {
4764
4798
  const text = this.text.trim();
4765
4799
  if (text) this.done.push({
4766
4800
  text,
4767
4801
  start: this.open[0].t,
4768
- end: this.open.at(-1).t
4802
+ end: this.open.at(-1).t,
4803
+ wall: Date.now()
4769
4804
  });
4770
4805
  this.open = [];
4771
4806
  }
@@ -4791,6 +4826,11 @@ var OpenAITranslateProvider = class {
4791
4826
  reconnects = 0;
4792
4827
  /** Audio sent so far (s); a session's positions count from `base`. */
4793
4828
  sent = 0;
4829
+ /** Audio waiting to be sent: the service listens in real time, so bursts (a recording read from its
4830
+ * start) go up at most a little faster than that. */
4831
+ queue = [];
4832
+ queued = 0;
4833
+ drain;
4794
4834
  base = 0;
4795
4835
  src = new Stream(false);
4796
4836
  tr;
@@ -4801,6 +4841,8 @@ var OpenAITranslateProvider = class {
4801
4841
  seq = 0;
4802
4842
  /** End of the last finished line (s): everything before it is on screen. */
4803
4843
  readyAt = 0;
4844
+ /** Where the last line ends on screen (lines never overlap). */
4845
+ lastEnd = 0;
4804
4846
  /** Arrival delay of text after its audio position, for the page (s). */
4805
4847
  lag = 0;
4806
4848
  connectedAt = 0;
@@ -4821,6 +4863,7 @@ var OpenAITranslateProvider = class {
4821
4863
  this.onError = onError;
4822
4864
  await this.connect();
4823
4865
  this.quiet = setInterval(() => this.settle(), 400);
4866
+ this.drain = setInterval(() => this.sendDue(), 100);
4824
4867
  }
4825
4868
  connect() {
4826
4869
  return new Promise((resolve, reject) => {
@@ -4874,35 +4917,52 @@ var OpenAITranslateProvider = class {
4874
4917
  }
4875
4918
  push(pcm) {
4876
4919
  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;
4920
+ this.queue.push(Buffer.from(pcm));
4921
+ this.queued += pcm.length / BYTES_PER_SEC;
4922
+ this.sendDue();
4923
+ }
4924
+ /** Send queued audio, everything including silence, no faster than 1.5× real time since the session began. */
4925
+ sendDue() {
4926
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
4927
+ const allowed = this.base + (Date.now() - this.connectedAt) / 1e3 * 1.5 + 2;
4928
+ while (this.queue.length && this.sent < allowed) {
4929
+ const pcm = this.queue.shift();
4930
+ this.queued -= pcm.length / BYTES_PER_SEC;
4931
+ this.send({
4932
+ type: "session.input_audio_buffer.append",
4933
+ audio: this.up.push(pcm).toString("base64")
4934
+ });
4935
+ this.sent += pcm.length / BYTES_PER_SEC;
4936
+ }
4882
4937
  }
4883
- /** Audio sent but not yet in a finished line. */
4938
+ /** Audio received but not yet in a finished line (queued, or sent and still being worked on). */
4884
4939
  backlog() {
4885
- return Math.max(0, this.sent - this.readyAt);
4940
+ return this.queued + Math.max(0, this.sent - this.readyAt);
4886
4941
  }
4887
4942
  onEvent(ev) {
4888
4943
  if (ev.type === "session.input_transcript.delta" || ev.type === "session.output_transcript.delta") {
4889
4944
  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;
4945
+ const behind = Math.max(0, this.sent - t);
4946
+ this.lag = this.lag ? this.lag * .9 + behind * .1 : behind;
4892
4947
  const piece = {
4893
4948
  t,
4894
4949
  text: String(ev.delta ?? "")
4895
4950
  };
4896
4951
  if (ev.type === "session.input_transcript.delta") this.src.add(piece);
4897
- else this.tr.add(piece);
4952
+ else {
4953
+ const next = this.src.done[this.srcUsed];
4954
+ if (next && this.tr.text.trim() && t - next.end >= 1.2) {
4955
+ if (!this.tr.closeAtPause() && t - next.end >= 3) this.tr.close();
4956
+ }
4957
+ this.tr.add(piece);
4958
+ }
4898
4959
  this.pair(false);
4899
4960
  this.draft();
4900
4961
  } else if (ev.type === "error") this.onError?.(/* @__PURE__ */ new Error(`OpenAI: ${ev.error?.message ?? "error"}`));
4901
4962
  }
4902
4963
  /** Close streams that went quiet (a pause without punctuation), then pair what is complete. */
4903
4964
  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();
4965
+ for (const s of [this.src, this.tr]) if (s.last !== void 0 && Date.now() - s.lastWall > QUIET_SEC * 1e3) s.close();
4906
4966
  this.pair(false);
4907
4967
  this.draft();
4908
4968
  }
@@ -4913,7 +4973,10 @@ var OpenAITranslateProvider = class {
4913
4973
  */
4914
4974
  pair(all) {
4915
4975
  while (this.tr.done.length) {
4916
- const t = this.tr.done.shift();
4976
+ const t = this.tr.done[0];
4977
+ const srcReady = this.srcUsed < this.src.done.length && this.src.done[this.srcUsed].end <= t.end + .5;
4978
+ if (!all && !srcReady && this.src.text.trim() && Date.now() - t.wall < 1500) break;
4979
+ this.tr.done.shift();
4917
4980
  const want = Array.from(t.text).length * this.ratio;
4918
4981
  const src = [];
4919
4982
  let len = 0;
@@ -4936,28 +4999,37 @@ var OpenAITranslateProvider = class {
4936
4999
  }
4937
5000
  line(t, src) {
4938
5001
  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);
5002
+ const spoken = src.length ? {
5003
+ start: src[0].start - .6,
5004
+ end: src.at(-1).end - .2
5005
+ } : {
5006
+ start: (t?.start ?? 0) - 1.4,
5007
+ end: (t?.end ?? 0) - .8
5008
+ };
5009
+ const start = Math.max(0, this.lastEnd, spoken.start);
5010
+ const end = Math.max(start + 1, spoken.end);
5011
+ this.lastEnd = end;
4941
5012
  this.emit?.({
4942
5013
  key: `ot:${this.seq++}`,
4943
5014
  start,
4944
- end: Math.max(end, start + .8),
5015
+ end,
4945
5016
  src: srcText,
4946
5017
  tr: t ? { [this.opts.target]: t.text } : void 0,
4947
5018
  final: true
4948
5019
  });
4949
- this.readyAt = Math.max(this.readyAt, end);
5020
+ this.readyAt = Math.max(this.readyAt, t?.end ?? end, end);
4950
5021
  }
4951
5022
  /** The sentence being translated, as a draft line. */
4952
5023
  draft() {
4953
5024
  const tr = this.tr.text.trim();
4954
5025
  if (!tr) return;
4955
5026
  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);
5027
+ const from = this.src.done[this.srcUsed]?.start ?? this.src.start;
5028
+ const start = Math.max(0, this.lastEnd, from !== void 0 ? from - .6 : (this.tr.start ?? 0) - 1.4);
4957
5029
  this.emit?.({
4958
5030
  key: `ot:${this.seq}`,
4959
5031
  start,
4960
- end: Math.max(this.tr.last ?? start, start + .8),
5032
+ end: Math.max(start + 1, (this.src.last ?? this.tr.last ?? start) - .2),
4961
5033
  src: srcText,
4962
5034
  tr: { [this.opts.target]: tr },
4963
5035
  final: false
@@ -4970,6 +5042,7 @@ var OpenAITranslateProvider = class {
4970
5042
  async stop() {
4971
5043
  await new Promise((r) => setTimeout(r, 2500));
4972
5044
  if (this.quiet) clearInterval(this.quiet);
5045
+ if (this.drain) clearInterval(this.drain);
4973
5046
  this.src.close();
4974
5047
  this.tr.close();
4975
5048
  this.pair(true);
@@ -5046,11 +5119,19 @@ function startOverlay(port, current, onJob, log) {
5046
5119
  res.end(PAGE);
5047
5120
  return;
5048
5121
  }
5122
+ if (url.pathname === "/screen" || url.pathname === "/screen/") {
5123
+ res.writeHead(200, {
5124
+ "content-type": "text/html; charset=utf-8",
5125
+ "cache-control": "no-store"
5126
+ });
5127
+ res.end(SCREEN);
5128
+ return;
5129
+ }
5049
5130
  res.writeHead(404);
5050
5131
  res.end();
5051
5132
  });
5052
5133
  server.on("error", (err) => log(`overlay: port ${port} unavailable (${err.message})`));
5053
- server.listen(port, "127.0.0.1", () => log(`overlay: http://127.0.0.1:${port}/overlay`));
5134
+ server.listen(port, () => log(`overlay: http://127.0.0.1:${port}/overlay, /screen`));
5054
5135
  return () => {
5055
5136
  offJob();
5056
5137
  detach?.();
@@ -5098,6 +5179,63 @@ function connect() {
5098
5179
  }
5099
5180
  connect()
5100
5181
  <\/script></body></html>`;
5182
+ /**
5183
+ * Subtitles as the whole picture, for a big screen (a hall, a projector, a
5184
+ * second monitor, or OBS as a source of its own): 16:9, the current line large
5185
+ * with the original under it, the two before it smaller and dimmed above.
5186
+ * …/screen?lines=zh,src&theme=dark|light
5187
+ */
5188
+ const SCREEN = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vidi · subtitles</title>
5189
+ <style>
5190
+ html,body{margin:0;height:100%;background:#000;overflow:hidden;cursor:none}
5191
+ body.light{background:#f4f3ef}
5192
+ #stage{position:absolute;inset:0;margin:auto;width:min(100vw,177.78vh);height:min(56.25vw,100vh);display:flex;flex-direction:column;justify-content:flex-end;box-sizing:border-box;padding:4% 6% 7%;
5193
+ font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif;color:#fff;container-type:inline-size}
5194
+ body.light #stage{color:#151513}
5195
+ .cue{text-align:center;text-wrap:balance;line-break:strict;transition:opacity .4s,font-size .3s}
5196
+ .cue+.cue{margin-top:3.2cqw}
5197
+ .cue .main{display:block;font-weight:600;line-height:1.35}
5198
+ .cue .sub{display:block;margin-top:.8cqw;opacity:.62;line-height:1.4}
5199
+ .cue.now .main{font-size:5.4cqw}.cue.now .sub{font-size:2.9cqw}
5200
+ .cue.old .main{font-size:3cqw}.cue.old .sub{font-size:1.9cqw}
5201
+ .cue.old{opacity:.38}.cue.old2{opacity:.2}
5202
+ .cue.draft .main{opacity:.75}
5203
+ </style></head><body><div id="stage"></div>
5204
+ <script>
5205
+ const q = new URLSearchParams(location.search)
5206
+ const lines = (q.get('lines') || 'zh,src').split(',')
5207
+ if (q.get('theme') === 'light') document.body.classList.add('light')
5208
+ const stage = document.getElementById('stage')
5209
+ let done = [], draft = null
5210
+ function texts(c) {
5211
+ const seen = new Set()
5212
+ return lines.map(k => (k === 'src' ? c.src : (c.tr || {})[k]) || '').map(s => s.trim()).filter(s => s && !seen.has(s) && seen.add(s))
5213
+ }
5214
+ function render() {
5215
+ const list = [...done.slice(-3), ...(draft ? [draft] : [])].slice(-3)
5216
+ stage.innerHTML = ''
5217
+ list.forEach((c, i) => {
5218
+ const t = texts(c); if (!t.length) return
5219
+ const d = document.createElement('div')
5220
+ const age = list.length - 1 - i
5221
+ d.className = 'cue ' + (age === 0 ? 'now' : age === 1 ? 'old' : 'old old2') + (c.final ? '' : ' draft')
5222
+ const m = document.createElement('span'); m.className = 'main'; m.textContent = t[0]; d.appendChild(m)
5223
+ if (t[1]) { const s = document.createElement('span'); s.className = 'sub'; s.textContent = t[1]; d.appendChild(s) }
5224
+ stage.appendChild(d)
5225
+ })
5226
+ }
5227
+ function connect() {
5228
+ const es = new EventSource('/overlay/events')
5229
+ es.onmessage = e => {
5230
+ let c; try { c = JSON.parse(e.data) } catch { return }
5231
+ if (!c) return
5232
+ if (c.final) { draft = null; if (!done.length || done.at(-1).src !== c.src) done.push(c); done = done.slice(-6) } else draft = c
5233
+ render()
5234
+ }
5235
+ es.onerror = () => { es.close(); setTimeout(connect, 2000) }
5236
+ }
5237
+ connect()
5238
+ <\/script></body></html>`;
5101
5239
  // src/tools.ts
5102
5240
  const CLOCK = (s) => {
5103
5241
  const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, ss = Math.floor(s % 60);
@@ -5865,7 +6003,11 @@ function apply(ctx, config) {
5865
6003
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
5866
6004
  const route = url.pathname.replace(/^\/live\/api\/?/, "");
5867
6005
  try {
5868
- if (route === "overlay") return send(res, 200, { url: `http://127.0.0.1:${config.overlayPort}/overlay` });
6006
+ if (route === "overlay") return send(res, 200, {
6007
+ url: `http://127.0.0.1:${config.overlayPort}/overlay`,
6008
+ screen: `http://127.0.0.1:${config.overlayPort}/screen`,
6009
+ screenLan: `http://${lanAddress()}:${config.overlayPort}/screen`
6010
+ });
5869
6011
  if (route === "deps") {
5870
6012
  const [deps] = await Promise.all([checkDeps(config.whisperModel), keys.refresh()]);
5871
6013
  localReady = deps.filter((d) => [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.7.0",
3
+ "version": "0.7.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",