dsh-vidi 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +155 -52
  2. package/lib/index.mjs +517 -125
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -33589,7 +33589,7 @@ const fmt = (s) => {
33589
33589
  * stage, so subtitles (sized in container units) and menus come along.
33590
33590
  */
33591
33591
  const Player = (0, react.forwardRef)(function Player(props, ref) {
33592
- const { src, live, vod, duration, subsAt, waitingText, cues, siteCues, spoken, style, delay, onDelay, showDelay, theater, onTheater, onTime, action, menus = [], onSnapshot, onClip, onThumb, quality, t } = props;
33592
+ const { trail, src, live, vod, duration, subsAt, waitingText, cues, siteCues, spoken, style, delay, onDelay, showDelay, theater, onTheater, onTime, action, menus = [], onSnapshot, onClip, onThumb, quality, t } = props;
33593
33593
  const stage = (0, react.useRef)(null);
33594
33594
  const video = (0, react.useRef)(null);
33595
33595
  const hls = (0, react.useRef)(null);
@@ -33858,7 +33858,7 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33858
33858
  const total = vod ? Math.max(duration ?? 0, edge) : edge;
33859
33859
  const behind = Math.max(0, edge - now);
33860
33860
  const atLive = behind < delay + 10;
33861
- const current = cues.filter((c) => c.start - .2 <= now && now <= c.end + 1.2).at(-1);
33861
+ const current = cues.filter((c) => c.start - .2 <= now && now <= c.end + 1.2).at(-1) ?? (trail ? cues.filter((c) => c.start <= now && c.end > now - 8).at(-1) : void 0);
33862
33862
  const texts = {};
33863
33863
  if (ccOn) {
33864
33864
  if (current) {
@@ -34066,10 +34066,21 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
34066
34066
  ]
34067
34067
  }),
34068
34068
  openMenu && (() => {
34069
+ const phone = narrow && window.innerWidth < 700 && !full && !pageFull;
34070
+ const floating = narrow && !phone && !full && !pageFull;
34071
+ const r = floating ? stage.current?.getBoundingClientRect() : void 0;
34072
+ const place = r ? {
34073
+ position: "fixed",
34074
+ right: Math.max(8, window.innerWidth - r.right + 8),
34075
+ bottom: Math.max(8, window.innerHeight - r.bottom + 56),
34076
+ width: Math.min(360, window.innerWidth - 16),
34077
+ maxHeight: Math.max(240, r.bottom - 56 - 12)
34078
+ } : void 0;
34069
34079
  const panel = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34070
- className: `dshl-menu ${narrow ? "sheet" : ""}`,
34080
+ className: `dshl-menu ${phone ? "sheet" : ""}`,
34071
34081
  role: "dialog",
34072
34082
  "aria-label": openMenu.title,
34083
+ style: place,
34073
34084
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34074
34085
  className: "head",
34075
34086
  children: [
@@ -34094,13 +34105,13 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
34094
34105
  children: openMenu.content
34095
34106
  })]
34096
34107
  });
34097
- return narrow && !full && !pageFull ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
34108
+ return phone ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
34098
34109
  className: "dshl-sheetwrap",
34099
34110
  onMouseDown: (e) => {
34100
34111
  if (e.target === e.currentTarget) setMenu(null);
34101
34112
  },
34102
34113
  children: panel
34103
- }), document.body) : panel;
34114
+ }), document.body) : floating ? (0, react_dom.createPortal)(panel, document.body) : panel;
34104
34115
  })(),
34105
34116
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34106
34117
  className: "dshl-controls",
@@ -34325,7 +34336,8 @@ function RestreamPanel(props) {
34325
34336
  if (!list.length) setEditing({
34326
34337
  platform: "bilibili",
34327
34338
  server: "",
34328
- key: ""
34339
+ key: "",
34340
+ content: "burn"
34329
34341
  });
34330
34342
  }).catch(() => {});
34331
34343
  }, []);
@@ -34438,6 +34450,28 @@ function RestreamPanel(props) {
34438
34450
  className: "rs-hint",
34439
34451
  children: t(`rs.h.${editing.platform}`)
34440
34452
  }),
34453
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34454
+ className: "rs-field",
34455
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rs.content") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34456
+ value: editing.content,
34457
+ onChange: (v) => setEditing({
34458
+ ...editing,
34459
+ content: v
34460
+ }),
34461
+ options: [
34462
+ "burn",
34463
+ "clean",
34464
+ "subs"
34465
+ ].map((c) => ({
34466
+ value: c,
34467
+ label: t(`rs.c.${c}`)
34468
+ }))
34469
+ })]
34470
+ }),
34471
+ editing.content === "subs" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34472
+ className: "rs-hint",
34473
+ children: t("rs.c.subs.d")
34474
+ }),
34441
34475
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34442
34476
  className: "rs-field",
34443
34477
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rs.server") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -34498,7 +34532,8 @@ function RestreamPanel(props) {
34498
34532
  onClick: () => setEditing({
34499
34533
  platform: "bilibili",
34500
34534
  server: "",
34501
- key: ""
34535
+ key: "",
34536
+ content: "burn"
34502
34537
  }),
34503
34538
  children: ["+ ", t("rs.add")]
34504
34539
  })]
@@ -34518,7 +34553,11 @@ function RestreamPanel(props) {
34518
34553
  }),
34519
34554
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34520
34555
  className: "rs-name",
34521
- children: [pname(x.platform), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("small", { children: host(x.server) })]
34556
+ children: [pname(x.platform), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("small", { children: [
34557
+ host(x.server),
34558
+ " · ",
34559
+ t(`rs.c.${x.content ?? "burn"}`)
34560
+ ] })]
34522
34561
  }),
34523
34562
  running ? st ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34524
34563
  className: `rs-state ${st.state}`,
@@ -34537,7 +34576,8 @@ function RestreamPanel(props) {
34537
34576
  id: x.id,
34538
34577
  platform: x.platform,
34539
34578
  server: x.server,
34540
- key: ""
34579
+ key: "",
34580
+ content: x.content ?? "burn"
34541
34581
  }),
34542
34582
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.pencil, {})
34543
34583
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -34561,23 +34601,16 @@ function RestreamPanel(props) {
34561
34601
  className: "rs-err",
34562
34602
  children: status.targets.filter((x) => x.message && x.state !== "live").map((x) => `${pname(x.platform)}:${x.message}`).join(";")
34563
34603
  }),
34564
- !running && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34604
+ !running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
34565
34605
  className: "rs-opts",
34566
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34567
- className: "rs-check",
34568
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
34569
- type: "checkbox",
34570
- checked: burn,
34571
- onChange: (e) => setBurn(e.target.checked)
34572
- }), t("rs.burn")]
34573
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34606
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34574
34607
  className: "seg",
34575
34608
  children: [720, 1080].map((h) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
34576
34609
  className: height === h ? "on" : "",
34577
34610
  onClick: () => setHeight(h),
34578
34611
  children: [h, "p"]
34579
34612
  }, h))
34580
- })]
34613
+ })
34581
34614
  }),
34582
34615
  status?.message && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34583
34616
  className: "rs-hint",
@@ -34601,9 +34634,54 @@ function RestreamPanel(props) {
34601
34634
  !running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34602
34635
  className: "rs-hint",
34603
34636
  children: t("rs.note")
34637
+ }),
34638
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OverlayLink, { t })
34639
+ ]
34640
+ });
34641
+ }
34642
+ /** Subtitle pages: the overlay for OBS's Browser Source, and the big screen of subtitles only. */
34643
+ function OverlayLink({ t }) {
34644
+ const [urls, setUrls] = (0, react.useState)({});
34645
+ const [copied, setCopied] = (0, react.useState)("");
34646
+ (0, react.useEffect)(() => {
34647
+ fetch("/live/api/overlay").then((r) => r.json()).then(setUrls).catch(() => {});
34648
+ }, []);
34649
+ if (!urls.url) return null;
34650
+ const row = (label, url, note, open) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34651
+ className: "rs-overlay",
34652
+ children: [
34653
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34654
+ className: "rs-sub",
34655
+ children: label
34656
+ }),
34657
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34658
+ className: "rs-url",
34659
+ children: [
34660
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: url }),
34661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34662
+ className: "dshl-link",
34663
+ onClick: () => void navigator.clipboard.writeText(url).then(() => {
34664
+ setCopied(url);
34665
+ setTimeout(() => setCopied(""), 1500);
34666
+ }),
34667
+ children: copied === url ? t("tr.copied") : t("rs.copy")
34668
+ }),
34669
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
34670
+ className: "dshl-link",
34671
+ href: url,
34672
+ target: "_blank",
34673
+ rel: "noreferrer",
34674
+ children: t("rs.openPage")
34675
+ })
34676
+ ]
34677
+ }),
34678
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34679
+ className: "rs-hint",
34680
+ children: note
34604
34681
  })
34605
34682
  ]
34606
34683
  });
34684
+ 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)] });
34607
34685
  }
34608
34686
 
34609
34687
  // src/client/Settings.tsx
@@ -34972,7 +35050,7 @@ function Settings(props) {
34972
35050
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.mode") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34973
35051
  className: "dshl-seg",
34974
35052
  children: [
34975
- "site",
35053
+ "live",
34976
35054
  "realtime",
34977
35055
  "polished"
34978
35056
  ].map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -35125,7 +35203,10 @@ function LiveView(props) {
35125
35203
  setLinesRaw(l);
35126
35204
  store.set("lines", l);
35127
35205
  };
35128
- const [mode, setModeRaw] = (0, react.useState)(() => store.get("mode", "polished"));
35206
+ const [mode, setModeRaw] = (0, react.useState)(() => {
35207
+ const m = store.get("mode", "polished");
35208
+ return m === "site" ? "live" : m;
35209
+ });
35129
35210
  const setMode = (m) => {
35130
35211
  setModeRaw(m);
35131
35212
  store.set("mode", m);
@@ -35178,13 +35259,12 @@ function LiveView(props) {
35178
35259
  t,
35179
35260
  ui
35180
35261
  ]);
35181
- const siteBest = (0, react.useMemo)(() => {
35262
+ (0, react.useMemo)(() => {
35182
35263
  const list = job?.site ?? [];
35183
35264
  const base = (l) => l.replace(/-orig$/, "").split("-")[0];
35184
35265
  return (list.find((x) => !x.auto && base(x.lang) === ui) ?? list.find((x) => !x.auto) ?? list.find((x) => base(x.lang) === ui) ?? list[0])?.key;
35185
35266
  }, [job?.site, ui]);
35186
- const siteOnly = mode === "site" && !!job;
35187
- const screenLines = siteOnly && siteBest ? lines.some((l) => l.startsWith("site:")) ? lines.filter((l) => l.startsWith("site:")) : [siteBest] : lines;
35267
+ const screenLines = lines;
35188
35268
  const shownLines = lines;
35189
35269
  /** The chosen recognition, or — when it can't run here (no key, not installed) — the first one that can. */
35190
35270
  const usableEngine = () => {
@@ -35354,13 +35434,17 @@ function LiveView(props) {
35354
35434
  (0, react.useEffect)(() => {
35355
35435
  const p = player.current;
35356
35436
  if (!p) return;
35357
- const waiting = !!job && job.status === "running" && !job.vod && mode !== "site" && subs.state === "running" && !noWait;
35358
- const ready = mode === "polished" ? Math.max(lastPolished, subs.at - 150) : subs.at;
35359
- if (waiting && now > ready + .5) p.hold(t(mode === "polished" ? "hold.polished" : "hold.realtime"), () => setNoWait(true));
35437
+ const waiting = !!job && job.status === "running" && !job.vod && mode !== "live" && subs.state === "running" && !noWait;
35438
+ const firstPass = subs.ready ?? subs.at;
35439
+ const ready = mode === "polished" ? Math.max(lastPolished, firstPass - 150) : firstPass;
35440
+ const behind = Math.max(0, subs.at - firstPass);
35441
+ const text = t(mode === "polished" ? "hold.polished" : "hold.realtime") + (mode !== "polished" && behind >= 1 ? ` ${t("hold.behind").replace("{s}", behind.toFixed(1))}` : "");
35442
+ if (waiting && now > ready + .5) p.hold(text, () => setNoWait(true));
35360
35443
  else if (!waiting || ready > now + (mode === "polished" ? 20 : 4)) p.hold(null);
35361
35444
  }, [
35362
35445
  now,
35363
35446
  subs.at,
35447
+ subs.ready,
35364
35448
  subs.state,
35365
35449
  lastPolished,
35366
35450
  mode,
@@ -35431,10 +35515,6 @@ function LiveView(props) {
35431
35515
  if (m === mode) return;
35432
35516
  setMode(m);
35433
35517
  if (!job || job.status === "error") return;
35434
- if (m === "site") {
35435
- if (subs.state === "running") pauseSubs();
35436
- return;
35437
- }
35438
35518
  startSubs(subs.state === "off" ? now : void 0, m);
35439
35519
  };
35440
35520
  const changeLines = async (next) => {
@@ -35860,16 +35940,9 @@ function LiveView(props) {
35860
35940
  onTerms: setTerms
35861
35941
  })
35862
35942
  ] });
35863
- const slow = !!job && job.status === "running" && !job.vod && mode !== "site" && rate !== null && rate < .95;
35943
+ const slow = !!job && job.status === "running" && !job.vod && rate !== null && rate < .95;
35864
35944
  const engineNow = providers.find((x) => x.id === usableEngine());
35865
- const liveNote = siteOnly && job && job.status !== "resolving" && !job.site?.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35866
- className: "dshl-lag",
35867
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("site.none") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35868
- className: "dshl-link",
35869
- onClick: () => changeMode("polished"),
35870
- children: t("site.switch")
35871
- })]
35872
- }) : slow ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35945
+ const liveNote = slow ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35873
35946
  className: "dshl-lag warn",
35874
35947
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(engineNow && !engineNow.online ? "lag.slowLocal" : "lag.slow").replace("{x}", rate.toFixed(2)) }), engineNow && !engineNow.online && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35875
35948
  className: "dshl-link",
@@ -36029,10 +36102,11 @@ function LiveView(props) {
36029
36102
  subsAt: subs.state === "off" ? void 0 : subs.at,
36030
36103
  waitingText: job.status === "resolving" ? statusText : t("player.waiting"),
36031
36104
  cues,
36105
+ trail: mode === "live" && !job.vod,
36032
36106
  siteCues: shownSite,
36033
36107
  spoken: spokenBase,
36034
36108
  style: subStyle,
36035
- delay: job.vod ? delay : mode === "polished" ? Math.max(delay, 75) : mode === "site" ? 3 : delay,
36109
+ delay: job.vod ? delay : mode === "polished" ? Math.max(delay, 75) : mode === "live" ? 3 : delay,
36036
36110
  onDelay: setDelay,
36037
36111
  showDelay: job.status === "running" && !job.vod,
36038
36112
  onSnapshot: saveSnapshot,
@@ -36247,7 +36321,7 @@ function SubsStarter(props) {
36247
36321
  const box = (0, react.useRef)(null);
36248
36322
  const pop = (0, react.useRef)(null);
36249
36323
  const toggle = () => {
36250
- setSheet((box.current?.closest(".dshl-stage")?.clientWidth ?? 999) < 560 && !document.fullscreenElement);
36324
+ setSheet(window.innerWidth < 700 && !document.fullscreenElement);
36251
36325
  setOpen((v) => !v);
36252
36326
  };
36253
36327
  (0, react.useEffect)(() => {
@@ -36746,6 +36820,17 @@ const en = {
36746
36820
  "rs.keyPh": "Paste the stream key",
36747
36821
  "rs.keyKeep": "Leave empty to keep the saved key",
36748
36822
  "rs.save": "Save",
36823
+ "rs.overlay": "Subtitles for OBS (Browser Source)",
36824
+ "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
+ "rs.copy": "Copy",
36826
+ "rs.openPage": "Open",
36827
+ "rs.screen": "Subtitle screen (16:9)",
36828
+ "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.",
36829
+ "rs.content": "Sends",
36830
+ "rs.c.burn": "Picture + subtitles",
36831
+ "rs.c.clean": "Picture only",
36832
+ "rs.c.subs": "Subtitles only (green screen)",
36833
+ "rs.c.subs.d": "Subtitles on a green background, no sound: key out the green in OBS or a broadcast console to lay them over your own picture.",
36749
36834
  "rs.burn": "Subtitles in the picture",
36750
36835
  "rs.start": "Start restreaming ({n})",
36751
36836
  "rs.pick": "Tick where to stream",
@@ -36807,17 +36892,18 @@ const en = {
36807
36892
  "in.subs": "Subtitles",
36808
36893
  "in.addLang": "Add a language",
36809
36894
  "in.remove": "Remove",
36810
- "mode.realtime": "Real-time",
36895
+ "mode.realtime": "In sync",
36811
36896
  "mode.polished": "Polished",
36812
- "mode.site": "No delay",
36813
- "mode.site.tip": "The site’s own subtitles only: nothing is transcribed or translated, the picture is not held back",
36814
- "mode.realtime.tip": "A few seconds behind: transcribed and translated as it is heard",
36897
+ "mode.live": "Picture first",
36898
+ "mode.live.tip": "The picture does not wait; subtitles follow a few seconds later",
36899
+ "mode.realtime.tip": "Picture a few seconds behind, in step with its subtitles (OpenAI live translation when transcribing with OpenAI)",
36815
36900
  "mode.polished.tip": "1–2 minutes behind: misheard words fixed with context and the picture — the most accurate (default)",
36816
36901
  "site.none": "This video has no subtitles of its own.",
36817
36902
  "site.switch": "Switch to Polished",
36818
36903
  "hold.realtime": "Waiting for subtitles…",
36819
36904
  "hold.polished": "Waiting for polished subtitles…",
36820
36905
  "hold.skip": "Don’t wait",
36906
+ "hold.behind": "(subtitles run {s}s behind the sound)",
36821
36907
  "lag.slow": "Transcription runs slower than the stream ({x}×), so subtitles fall further behind.",
36822
36908
  "lag.slowLocal": "This computer transcribes slower than the stream ({x}×), so subtitles fall further behind. An online service keeps up.",
36823
36909
  "lag.settings": "Choose one",
@@ -37115,6 +37201,17 @@ const zh = {
37115
37201
  "rs.keyPh": "粘贴推流码",
37116
37202
  "rs.keyKeep": "留空则沿用已保存的推流码",
37117
37203
  "rs.save": "保存",
37204
+ "rs.overlay": "OBS 字幕叠加(浏览器源)",
37205
+ "rs.overlay.d": "在 OBS 里加一个「浏览器源」,填这个地址(1920×1080):透明背景上只有最新的字幕,叠在摄像头或任何画面上。地址后加 ?lines=zh,src&size=4.5 可以选行和字号。",
37206
+ "rs.copy": "复制",
37207
+ "rs.openPage": "打开",
37208
+ "rs.screen": "字幕大屏(16:9)",
37209
+ "rs.screen.d": "只有字幕、字很大,适合大屏和投影:在同一网络里任何浏览器打开、全屏即可(本机用 {local})。地址后加 ?theme=light 换浅色;也可以作为 OBS 的一个独立画面来源。",
37210
+ "rs.content": "内容",
37211
+ "rs.c.burn": "画面 + 字幕",
37212
+ "rs.c.clean": "只要画面",
37213
+ "rs.c.subs": "只要字幕(绿幕)",
37214
+ "rs.c.subs.d": "绿色背景上只有字幕、没有声音:在 OBS 或导播台里抠掉绿色,就能把字幕叠在你自己的画面上。",
37118
37215
  "rs.burn": "字幕显示在画面里",
37119
37216
  "rs.start": "开始转播({n} 个平台)",
37120
37217
  "rs.pick": "先勾选要推到哪里",
@@ -37176,17 +37273,18 @@ const zh = {
37176
37273
  "in.subs": "字幕",
37177
37274
  "in.addLang": "添加语言",
37178
37275
  "in.remove": "移除",
37179
- "mode.realtime": "实时",
37276
+ "mode.realtime": "同步",
37180
37277
  "mode.polished": "精修",
37181
- "mode.site": "无延迟",
37182
- "mode.site.tip": "只看网站自带的字幕:不转写、不翻译,画面也不往后拖",
37183
- "mode.realtime.tip": "晚几秒:听到就转写、翻译",
37278
+ "mode.live": "抢先",
37279
+ "mode.live.tip": "画面不等,和直播一样快;字幕晚几秒跟上",
37280
+ "mode.realtime.tip": "画面晚几秒,和字幕对齐(转写选 OpenAI 时用实时翻译模型)",
37184
37281
  "mode.polished.tip": "晚 1–2 分钟:结合上下文和画面改正听错的词,字幕最准(默认)",
37185
37282
  "site.none": "这个视频没有网站自带的字幕。",
37186
37283
  "site.switch": "换成精修",
37187
37284
  "hold.realtime": "等字幕…",
37188
37285
  "hold.polished": "等精修字幕…",
37189
37286
  "hold.skip": "不等了",
37287
+ "hold.behind": "(字幕比声音晚 {s} 秒)",
37190
37288
  "lag.slow": "转写比直播慢({x} 倍速),字幕会越落越远。",
37191
37289
  "lag.slowLocal": "本机转写比直播慢({x} 倍速),字幕会越落越远。换成在线转写就能跟上。",
37192
37290
  "lag.settings": "去选一个",
@@ -37867,6 +37965,10 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37867
37965
  .dshl-rs .rs-hint { margin: 0; color: var(--faint); font-size: 12px; line-height: 1.55; }
37868
37966
  .dshl-rs .rs-err { margin: 0; color: #ff8f92; font-size: 12px; line-height: 1.55; }
37869
37967
  .dshl-rs .rs-field { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); }
37968
+ .dshl-rs .rs-overlay { display: grid; gap: 4px; margin-top: 4px; padding-top: 10px; border-top: 1px solid var(--line); }
37969
+ .dshl-rs .rs-sub { color: var(--muted); font-size: 12.5px; font-weight: 550; }
37970
+ .dshl-rs .rs-url { display: flex; align-items: center; gap: 8px; min-width: 0; }
37971
+ .dshl-rs .rs-url code { overflow: hidden; color: var(--ink); font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; user-select: all; }
37870
37972
  .dshl-rs .rs-field .dshl-select { width: 100%; height: 34px; background: var(--l1); }
37871
37973
  .dshl-rs .rs-field input { width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--l1); color: var(--ink); font: inherit; font-size: 13px; }
37872
37974
  .dshl-rs .rs-field input:focus { outline: none; border-color: var(--accent); }
@@ -37892,8 +37994,9 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37892
37994
  .dshl-dl .dshl-chips .chip { background: transparent; }
37893
37995
 
37894
37996
  /* Menus as a bottom sheet on small players (phones) */
37895
- .dshl-sheetwrap { position: fixed; inset: 0; z-index: 2147483002; display: flex; align-items: flex-end; background: rgba(0,0,0,.4); }
37896
- .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;
37997
+ .dshl-sheetwrap { position: fixed; inset: 0; z-index: 2147483002; display: flex; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.4); }
37998
+ body > .dshl-menu { z-index: 2147483002; }
37999
+ .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;
37897
38000
  font-family: system-ui, -apple-system, "PingFang SC", sans-serif; font-size: 15px; }
37898
38001
  .dshl-menu.sheet .head { padding: 14px 12px 8px 18px; font-size: 16px; }
37899
38002
  .dshl-menu.sheet .body { padding: 4px 14px 16px; }
package/lib/index.mjs CHANGED
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
7
7
  import { EventEmitter } from "node:events";
8
8
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
9
9
  import { createServer } from "node:net";
10
+ import { createServer as createServer$1 } from "node:http";
10
11
  // src/deps.ts
11
12
  const run$4 = promisify(execFile);
12
13
  async function which(bin) {
@@ -1260,6 +1261,11 @@ const PLATFORMS = [
1260
1261
  "twitch",
1261
1262
  "custom"
1262
1263
  ];
1264
+ const CONTENTS = [
1265
+ "burn",
1266
+ "clean",
1267
+ "subs"
1268
+ ];
1263
1269
  const mask$1 = (k) => !k ? "" : k.length > 10 ? `${k.slice(0, 3)}…${k.slice(-4)}` : "••••";
1264
1270
  /** The full push address: the server plus the key, unless the server already ends with it. */
1265
1271
  function pushUrl(t) {
@@ -1286,7 +1292,8 @@ var Targets = class {
1286
1292
  id: t.id,
1287
1293
  platform: t.platform,
1288
1294
  server: t.server,
1289
- key: mask$1(t.key)
1295
+ key: mask$1(t.key),
1296
+ content: t.content ?? "burn"
1290
1297
  }));
1291
1298
  }
1292
1299
  /** Add or change one (an empty key keeps the saved one); returns the saved list. */
@@ -1297,11 +1304,13 @@ var Targets = class {
1297
1304
  if (!/^(rtmps?|srt):\/\//i.test(server)) throw new Error("the server address should start with rtmp://, rtmps:// or srt://");
1298
1305
  const old = input.id ? all.find((t) => t.id === input.id) : void 0;
1299
1306
  const key = String(input.key ?? "").trim() || old?.key || "";
1307
+ const content = CONTENTS.includes(String(input.content)) ? input.content : old?.content ?? "burn";
1300
1308
  const next = {
1301
1309
  id: old?.id ?? Math.random().toString(36).slice(2, 10),
1302
1310
  platform,
1303
1311
  server,
1304
- key
1312
+ key,
1313
+ content
1305
1314
  };
1306
1315
  this.write(old ? all.map((t) => t.id === old.id ? next : t) : [...all, next]);
1307
1316
  return this.view();
@@ -1417,6 +1426,8 @@ var Restream = class extends EventEmitter {
1417
1426
  watchdog;
1418
1427
  lastOut = 0;
1419
1428
  stuckSince = Date.now();
1429
+ /** The encoder's outputs, in order (first on stdout, the next on pipes 4 and 5). */
1430
+ outs = [];
1420
1431
  constructor(source, opts, targets, log) {
1421
1432
  super();
1422
1433
  this.source = source;
@@ -1437,10 +1448,15 @@ var Restream = class extends EventEmitter {
1437
1448
  id: t.id,
1438
1449
  platform: t.platform,
1439
1450
  server: t.server,
1451
+ content: this.contentOf(t),
1440
1452
  state: "connecting",
1441
1453
  since: Date.now()
1442
1454
  }
1443
1455
  });
1456
+ this.outs = CONTENTS.filter((c) => targets.some((t) => this.contentOf(t) === c));
1457
+ }
1458
+ contentOf(t) {
1459
+ return t.content ?? (this.opts.burn ? "burn" : "clean");
1444
1460
  }
1445
1461
  get summary() {
1446
1462
  return {
@@ -1456,39 +1472,26 @@ var Restream = class extends EventEmitter {
1456
1472
  const { args: input, at } = this.source.input(this.opts.from);
1457
1473
  this.startAt = at;
1458
1474
  this.status.at = at;
1459
- const burn = this.opts.burn && !!await loadCanvas();
1460
- if (this.opts.burn && !burn) this.status.message = "no-burn";
1461
- this.status.burn = burn;
1462
- if (burn) this.blank = await drawSubtitles([], this.opts.style, w, h);
1463
- const video = `[0:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=25,setpts=PTS-STARTPTS`;
1464
- const filter = burn ? `${video}[v];[1:v]format=rgba,setpts=PTS-STARTPTS[s];[v][s]overlay=0:0:eof_action=repeat:shortest=1,format=yuv420p[o]` : `${video},format=yuv420p[o]`;
1475
+ if (!!!await loadCanvas() && this.outs.some((o) => o !== "clean")) {
1476
+ this.status.message = "no-burn";
1477
+ this.outs = [...new Set(this.outs.map((o) => o === "burn" ? "clean" : o).filter((o) => o !== "subs"))];
1478
+ for (const p of this.pushes.values()) if (this.contentOf(p.target) === "burn") p.target = {
1479
+ ...p.target,
1480
+ content: "clean"
1481
+ };
1482
+ }
1483
+ const subs = this.outs.some((o) => o !== "clean");
1484
+ this.status.burn = this.outs.includes("burn");
1485
+ if (subs) this.blank = await drawSubtitles([], this.opts.style, w, h);
1486
+ const vids = this.outs.filter((o) => o !== "subs"), subUses = this.outs.filter((o) => o !== "clean");
1487
+ const parts = [`[0:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=25,setpts=PTS-STARTPTS${vids.length > 1 ? `,split=${vids.length}${vids.map((o) => `[v_${o}]`).join("")}` : vids.length ? `[v_${vids[0]}]` : ""}`];
1488
+ if (!vids.length) parts[0] = `[0:v]nullsink`;
1489
+ if (subUses.length) parts.push(`[1:v]format=rgba,setpts=PTS-STARTPTS${subUses.length > 1 ? `,split=${subUses.length}${subUses.map((o) => `[s_${o}]`).join("")}` : `[s_${subUses[0]}]`}`);
1490
+ if (this.outs.includes("burn")) parts.push(`[v_burn][s_burn]overlay=0:0:eof_action=repeat:shortest=1,format=yuv420p[o_burn]`);
1491
+ if (this.outs.includes("clean")) parts.push(`[v_clean]format=yuv420p[o_clean]`);
1492
+ if (this.outs.includes("subs")) parts.push(`color=c=0x00FF00:s=${w}x${h}:r=25[g];[g][s_subs]overlay=0:0:eof_action=repeat:shortest=1,format=yuv420p[o_subs]`);
1465
1493
  const b = this.opts.bitrateK;
1466
- const args = [
1467
- "-hide_banner",
1468
- "-loglevel",
1469
- "error",
1470
- "-re",
1471
- ...input,
1472
- ...burn ? [
1473
- "-probesize",
1474
- "65536",
1475
- "-analyzeduration",
1476
- "0",
1477
- "-f",
1478
- "image2pipe",
1479
- "-framerate",
1480
- String(FPS_SUBS),
1481
- "-c:v",
1482
- "png",
1483
- "-i",
1484
- "pipe:0"
1485
- ] : [],
1486
- "-filter_complex",
1487
- filter,
1488
- "-map",
1489
- "[o]",
1490
- "-map",
1491
- "0:a:0?",
1494
+ const video = [
1492
1495
  "-c:v",
1493
1496
  "libx264",
1494
1497
  "-preset",
@@ -1508,7 +1511,11 @@ var Restream = class extends EventEmitter {
1508
1511
  "-keyint_min",
1509
1512
  "50",
1510
1513
  "-sc_threshold",
1511
- "0",
1514
+ "0"
1515
+ ];
1516
+ const audio = [
1517
+ "-map",
1518
+ "0:a:0?",
1512
1519
  "-c:a",
1513
1520
  "aac",
1514
1521
  "-b:a",
@@ -1516,14 +1523,46 @@ var Restream = class extends EventEmitter {
1516
1523
  "-ar",
1517
1524
  "44100",
1518
1525
  "-ac",
1519
- "2",
1526
+ "2"
1527
+ ];
1528
+ const fd = (i) => i === 0 ? 1 : i + 3;
1529
+ const args = [
1530
+ "-hide_banner",
1531
+ "-loglevel",
1532
+ "error",
1533
+ "-re",
1534
+ ...input,
1535
+ ...subs ? [
1536
+ "-probesize",
1537
+ "65536",
1538
+ "-analyzeduration",
1539
+ "0",
1540
+ "-f",
1541
+ "image2pipe",
1542
+ "-framerate",
1543
+ String(FPS_SUBS),
1544
+ "-c:v",
1545
+ "png",
1546
+ "-i",
1547
+ "pipe:0"
1548
+ ] : [],
1549
+ "-filter_complex",
1550
+ parts.join(";"),
1520
1551
  "-progress",
1521
1552
  "pipe:3",
1522
- "-f",
1523
- "mpegts",
1524
- "pipe:1"
1553
+ ...this.outs.flatMap((o, i) => [
1554
+ "-map",
1555
+ `[o_${o}]`,
1556
+ ...video,
1557
+ ...o === "subs" ? ["-an"] : audio,
1558
+ "-f",
1559
+ "mpegts",
1560
+ `pipe:${fd(i)}`
1561
+ ])
1525
1562
  ];
1526
1563
  const enc = spawn("ffmpeg", args, { stdio: [
1564
+ "pipe",
1565
+ "pipe",
1527
1566
  "pipe",
1528
1567
  "pipe",
1529
1568
  "pipe",
@@ -1546,11 +1585,14 @@ var Restream = class extends EventEmitter {
1546
1585
  }
1547
1586
  });
1548
1587
  enc.stdin.on("error", () => {});
1549
- enc.stdout.on("data", (chunk) => {
1550
- for (const p of this.pushes.values()) {
1551
- const sin = p.proc?.stdin;
1552
- if (sin && !sin.destroyed && sin.writableLength < 8 << 20) sin.write(chunk);
1553
- }
1588
+ this.outs.forEach((o, i) => {
1589
+ (i === 0 ? enc.stdout : enc.stdio[i + 3]).on("data", (chunk) => {
1590
+ for (const p of this.pushes.values()) {
1591
+ if (this.contentOf(p.target) !== o) continue;
1592
+ const sin = p.proc?.stdin;
1593
+ if (sin && !sin.destroyed && sin.writableLength < 8 << 20) sin.write(chunk);
1594
+ }
1595
+ });
1554
1596
  });
1555
1597
  enc.on("exit", (code) => {
1556
1598
  if (this.stopped) return;
@@ -1558,7 +1600,7 @@ var Restream = class extends EventEmitter {
1558
1600
  this.log(`restream: ${reason}`);
1559
1601
  this.stop(reason);
1560
1602
  });
1561
- if (burn) this.feedSubtitles(w, h);
1603
+ if (subs) this.feedSubtitles(w, h);
1562
1604
  for (const p of this.pushes.values()) this.connect(p.target.id);
1563
1605
  this.watchdog = setInterval(() => {
1564
1606
  if (this.stopped) return;
@@ -1682,9 +1724,10 @@ var Restream = class extends EventEmitter {
1682
1724
  });
1683
1725
  this.changed();
1684
1726
  }
1685
- /** Add a destination while running (no interruption for the others). */
1727
+ /** Add a destination while running (no interruption for the others); false when it needs an output this run does not make. */
1686
1728
  add(t) {
1687
- if (this.pushes.has(t.id) || this.stopped) return;
1729
+ if (this.pushes.has(t.id) || this.stopped) return true;
1730
+ if (!this.outs.includes(this.contentOf(t))) return false;
1688
1731
  this.pushes.set(t.id, {
1689
1732
  target: t,
1690
1733
  tries: 0,
@@ -1692,11 +1735,17 @@ var Restream = class extends EventEmitter {
1692
1735
  id: t.id,
1693
1736
  platform: t.platform,
1694
1737
  server: t.server,
1738
+ content: this.contentOf(t),
1695
1739
  state: "connecting",
1696
1740
  since: Date.now()
1697
1741
  }
1698
1742
  });
1699
1743
  this.connect(t.id);
1744
+ return true;
1745
+ }
1746
+ /** The destinations this run sends to. */
1747
+ get targets() {
1748
+ return [...this.pushes.values()].map((p) => p.target);
1700
1749
  }
1701
1750
  /** Stop one destination; the rest keep going. Returns true when none are left. */
1702
1751
  drop(id) {
@@ -1859,7 +1908,11 @@ var LiveJob = class LiveJob extends EventEmitter {
1859
1908
  height: this.height,
1860
1909
  heights: this.heights,
1861
1910
  restream: this.restream,
1862
- subs: { ...this.subs },
1911
+ subs: {
1912
+ ...this.subs,
1913
+ ready: this.readyAt(),
1914
+ latency: this.provider?.latency?.()
1915
+ },
1863
1916
  usage: this.deps.usage.summary(),
1864
1917
  site: this.siteTracks,
1865
1918
  spoken: this.spokenLang()
@@ -2441,6 +2494,20 @@ var LiveJob = class LiveJob extends EventEmitter {
2441
2494
  this.persist();
2442
2495
  this.emit("state", this.summary());
2443
2496
  }
2497
+ /**
2498
+ * How far the subtitles are complete (media s): audio read minus what the
2499
+ * recogniser still holds, and before the first line still waiting for its
2500
+ * real-time translation. The page lets the picture wait for this.
2501
+ */
2502
+ readyAt() {
2503
+ let ready = this.subs.at - (this.provider?.backlog?.() ?? 0);
2504
+ if (this.opts.mode === "realtime" && this.pendingTranslations > 0) {
2505
+ const targets = this.targets();
2506
+ const waiting = this.cues.find((c) => c.final && targets.some((t) => !c.tr?.[t]) && c.start > ready - 60);
2507
+ if (waiting) ready = Math.min(ready, waiting.start);
2508
+ }
2509
+ return Math.max(0, ready);
2510
+ }
2444
2511
  /** Is recognition or translation too far behind the media read so far? (`slack` < 1 waits until it is well caught up.) */
2445
2512
  behind(slack = 1) {
2446
2513
  return (this.provider?.backlog?.() ?? 0) > AHEAD_MAX_SEC * slack || this.pendingTranslations > AHEAD_MAX_LINES * slack || this.polisher.backlog > 30 * slack;
@@ -2574,8 +2641,16 @@ var LiveJob = class LiveJob extends EventEmitter {
2574
2641
  /** Push this video (from media time `from`, subtitles drawn in) to the chosen destinations. */
2575
2642
  async startRestream(targets, opts) {
2576
2643
  if (this.restreamer?.summary.running) {
2577
- for (const t of targets) this.restreamer.add(t);
2578
- return;
2644
+ const r = this.restreamer;
2645
+ const misfits = targets.filter((t) => !r.add(t));
2646
+ if (!misfits.length) return;
2647
+ const all = [...r.targets, ...misfits];
2648
+ const at = r.summary.at;
2649
+ await r.stop();
2650
+ return this.startRestream(all, {
2651
+ ...opts,
2652
+ from: at
2653
+ });
2579
2654
  }
2580
2655
  if (!this.segments().length) throw new Error("nothing recorded yet");
2581
2656
  if (this.vod && this.duration && opts.from >= this.duration - 1.5) opts = {
@@ -4630,40 +4705,122 @@ const TRANSLATE_TARGETS = [
4630
4705
  "it",
4631
4706
  "en"
4632
4707
  ];
4708
+ /** Sentence ends; for CJK targets also the full-width ones. */
4709
+ const END = /[.!?。!?]["'”’」』))]*\s*$/;
4710
+ /** Long enough to cut at a comma (characters for CJK, letters otherwise). */
4711
+ const SOFT_CJK = 22;
4712
+ const SOFT_LATIN = 70;
4713
+ /** Never longer than this on one subtitle. */
4714
+ const HARD_CJK = 34;
4715
+ const HARD_LATIN = 110;
4716
+ /** A stream quiet this long ends its sentence (a pause without punctuation). */
4717
+ const QUIET_SEC = 1.6;
4718
+ /**
4719
+ * Builds sentences from one text stream. The service stamps every delta
4720
+ * with the audio position it was produced at (elapsed_ms); a sentence's
4721
+ * times come from those stamps, so lines line up with the picture without
4722
+ * guessing from pauses.
4723
+ */
4724
+ var Stream = class {
4725
+ cjk;
4726
+ open = [];
4727
+ done = [];
4728
+ constructor(cjk) {
4729
+ this.cjk = cjk;
4730
+ }
4731
+ get text() {
4732
+ return this.open.map((p) => p.text).join("");
4733
+ }
4734
+ get start() {
4735
+ return this.open[0]?.t;
4736
+ }
4737
+ get last() {
4738
+ return this.open.at(-1)?.t;
4739
+ }
4740
+ add(p) {
4741
+ const m = /^(.*?[.!?。!?]["'”’」』))]*)(\s*\S.*)$/s.exec(p.text);
4742
+ if (m && !/^\d/.test(m[2].trim())) {
4743
+ this.push({
4744
+ t: p.t,
4745
+ text: m[1]
4746
+ });
4747
+ this.close();
4748
+ this.add({
4749
+ t: p.t,
4750
+ text: m[2]
4751
+ });
4752
+ return;
4753
+ }
4754
+ this.push(p);
4755
+ }
4756
+ push(p) {
4757
+ this.open.push(p);
4758
+ const text = this.text.trim();
4759
+ const len = this.cjk ? Array.from(text).length : text.length;
4760
+ const soft = this.cjk ? SOFT_CJK : SOFT_LATIN, hard = this.cjk ? HARD_CJK : HARD_LATIN;
4761
+ if (END.test(text) || len >= soft && /[,,、;;::]\s*$/.test(text) || len >= hard) this.close();
4762
+ }
4763
+ close() {
4764
+ const text = this.text.trim();
4765
+ if (text) this.done.push({
4766
+ text,
4767
+ start: this.open[0].t,
4768
+ end: this.open.at(-1).t
4769
+ });
4770
+ this.open = [];
4771
+ }
4772
+ };
4633
4773
  /**
4634
4774
  * OpenAI live interpretation (gpt-realtime-translate): audio streams in, the
4635
- * translation and the source transcript stream back as text, so no separate
4636
- * translation pass is needed. The service has no turns of its own, so the
4637
- * shared pause detector decides where a subtitle ends; text that arrives
4638
- * shortly after a pause still belongs to the line before it.
4775
+ * source transcript and the translation stream back as text with audio
4776
+ * positions, so no separate translation pass is needed. A subtitle is one
4777
+ * sentence of translation with the source sentence(s) it renders: the
4778
+ * translation trails the speech by one to three seconds (it waits for enough
4779
+ * meaning), so source sentences are attached in order as each translated
4780
+ * sentence completes.
4639
4781
  */
4640
4782
  var OpenAITranslateProvider = class {
4641
4783
  opts;
4642
4784
  name = "openai-translate";
4643
4785
  translates = true;
4644
4786
  ws;
4645
- chunker;
4646
4787
  up = new Upsampler();
4647
4788
  emit;
4648
4789
  onError;
4649
4790
  stopped = false;
4650
4791
  reconnects = 0;
4651
- /** Text of the line being collected (source transcript and translation). */
4652
- src = "";
4653
- tr = "";
4654
- lineStart = 0;
4792
+ /** Audio sent so far (s); a session's positions count from `base`. */
4793
+ sent = 0;
4794
+ base = 0;
4795
+ src = new Stream(false);
4796
+ tr;
4797
+ /** Source sentences not yet shown with a translation. */
4798
+ srcUsed = 0;
4799
+ /** Source characters per translated character, learned as lines pair up (English is about 3× Chinese). */
4800
+ ratio;
4655
4801
  seq = 0;
4656
- /** A finished stretch of audio whose text may still be arriving. */
4657
- closing;
4802
+ /** End of the last finished line (s): everything before it is on screen. */
4803
+ readyAt = 0;
4804
+ /** Arrival delay of text after its audio position, for the page (s). */
4805
+ lag = 0;
4806
+ connectedAt = 0;
4807
+ quiet;
4658
4808
  constructor(opts) {
4659
4809
  this.opts = opts;
4660
4810
  if (!TRANSLATE_TARGETS.includes(opts.target)) throw new Error(`OpenAI live translation speaks only ${TRANSLATE_TARGETS.join(", ")}; choose one of these as the subtitle language, or another engine`);
4661
- this.chunker = new PauseChunker(opts.minSec, opts.maxSec, (c) => this.onChunk(c));
4811
+ const cjk = [
4812
+ "zh",
4813
+ "ja",
4814
+ "ko"
4815
+ ].includes(opts.target);
4816
+ this.tr = new Stream(cjk);
4817
+ this.ratio = cjk ? 3.2 : 1;
4662
4818
  }
4663
4819
  async start(emit, onError) {
4664
4820
  this.emit = emit;
4665
4821
  this.onError = onError;
4666
4822
  await this.connect();
4823
+ this.quiet = setInterval(() => this.settle(), 400);
4667
4824
  }
4668
4825
  connect() {
4669
4826
  return new Promise((resolve, reject) => {
@@ -4674,6 +4831,8 @@ var OpenAITranslateProvider = class {
4674
4831
  ws.onopen = () => {
4675
4832
  opened = true;
4676
4833
  this.reconnects = 0;
4834
+ this.base = this.sent;
4835
+ this.connectedAt = Date.now();
4677
4836
  this.send({
4678
4837
  type: "session.update",
4679
4838
  session: { audio: {
@@ -4697,6 +4856,9 @@ var OpenAITranslateProvider = class {
4697
4856
  ws.onclose = (ev) => {
4698
4857
  if (!opened || this.stopped || this.ws !== ws) return;
4699
4858
  this.opts.log?.(`openai-translate: connection closed (${ev.code} ${ev.reason || ""}), reconnecting`);
4859
+ this.src.close();
4860
+ this.tr.close();
4861
+ this.pair(true);
4700
4862
  if (++this.reconnects > 5) {
4701
4863
  this.onError?.(/* @__PURE__ */ new Error("OpenAI live translation keeps dropping"));
4702
4864
  return;
@@ -4716,82 +4878,291 @@ var OpenAITranslateProvider = class {
4716
4878
  type: "session.input_audio_buffer.append",
4717
4879
  audio: this.up.push(Buffer.from(pcm)).toString("base64")
4718
4880
  });
4719
- this.chunker.push(pcm);
4881
+ this.sent += pcm.length / BYTES_PER_SEC;
4720
4882
  }
4883
+ /** Audio sent but not yet in a finished line. */
4721
4884
  backlog() {
4722
- return this.closing ? this.chunker.now - this.closing.start : 0;
4885
+ return Math.max(0, this.sent - this.readyAt);
4723
4886
  }
4724
- draft() {
4725
- if (!this.src.trim() && !this.tr.trim()) return;
4726
- this.emit?.({
4727
- key: `ot:${this.seq}`,
4728
- start: this.lineStart,
4729
- end: Math.max(this.chunker.now, this.lineStart + .5),
4730
- src: this.src.trim(),
4731
- tr: this.tr.trim() ? { [this.opts.target]: this.tr.trim() } : void 0,
4732
- final: false
4733
- });
4887
+ onEvent(ev) {
4888
+ if (ev.type === "session.input_transcript.delta" || ev.type === "session.output_transcript.delta") {
4889
+ 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;
4892
+ const piece = {
4893
+ t,
4894
+ text: String(ev.delta ?? "")
4895
+ };
4896
+ if (ev.type === "session.input_transcript.delta") this.src.add(piece);
4897
+ else this.tr.add(piece);
4898
+ this.pair(false);
4899
+ this.draft();
4900
+ } else if (ev.type === "error") this.onError?.(/* @__PURE__ */ new Error(`OpenAI: ${ev.error?.message ?? "error"}`));
4901
+ }
4902
+ /** Close streams that went quiet (a pause without punctuation), then pair what is complete. */
4903
+ 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();
4906
+ this.pair(false);
4907
+ this.draft();
4734
4908
  }
4735
- /** A pause ended a stretch: wait a moment for its last words, then finish the line. */
4736
- onChunk({ start, end }) {
4737
- if (this.closing) this.finish();
4738
- if (!this.src.trim() && !this.tr.trim()) {
4739
- this.lineStart = end;
4740
- return;
4909
+ /**
4910
+ * One line per finished translation sentence, with the source sentences
4911
+ * that ended by then. With `all`, everything left becomes lines (stopping,
4912
+ * reconnecting).
4913
+ */
4914
+ pair(all) {
4915
+ while (this.tr.done.length) {
4916
+ const t = this.tr.done.shift();
4917
+ const want = Array.from(t.text).length * this.ratio;
4918
+ const src = [];
4919
+ let len = 0;
4920
+ while (this.srcUsed < this.src.done.length) {
4921
+ const next = this.src.done[this.srcUsed];
4922
+ if (next.end > t.end + .5) break;
4923
+ if (src.length && Math.abs(len + next.text.length - want) >= Math.abs(len - want)) break;
4924
+ src.push(next);
4925
+ len += next.text.length;
4926
+ this.srcUsed++;
4927
+ }
4928
+ if (src.length) this.ratio = Math.min(6, Math.max(.5, this.ratio * .8 + len / Math.max(1, Array.from(t.text).length) * .2));
4929
+ this.line(t, src);
4930
+ }
4931
+ if (all) {
4932
+ const rest = this.src.done.slice(this.srcUsed);
4933
+ this.srcUsed = this.src.done.length;
4934
+ if (rest.length) this.line(void 0, rest);
4741
4935
  }
4742
- this.lineStart = start;
4743
- this.closing = {
4936
+ }
4937
+ line(t, src) {
4938
+ 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);
4941
+ this.emit?.({
4942
+ key: `ot:${this.seq++}`,
4744
4943
  start,
4745
- end,
4746
- timer: setTimeout(() => this.finish(), 1500)
4747
- };
4944
+ end: Math.max(end, start + .8),
4945
+ src: srcText,
4946
+ tr: t ? { [this.opts.target]: t.text } : void 0,
4947
+ final: true
4948
+ });
4949
+ this.readyAt = Math.max(this.readyAt, end);
4748
4950
  }
4749
- finish() {
4750
- const c = this.closing;
4751
- if (!c) return;
4752
- clearTimeout(c.timer);
4753
- this.closing = void 0;
4754
- const src = this.src.trim(), tr = this.tr.trim();
4755
- if (src || tr) this.emit?.({
4951
+ /** The sentence being translated, as a draft line. */
4952
+ draft() {
4953
+ const tr = this.tr.text.trim();
4954
+ if (!tr) return;
4955
+ 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);
4957
+ this.emit?.({
4756
4958
  key: `ot:${this.seq}`,
4757
- start: c.start,
4758
- end: Math.max(c.end, c.start + .5),
4759
- src: src || tr,
4760
- tr: tr ? { [this.opts.target]: tr } : void 0,
4761
- final: true
4959
+ start,
4960
+ end: Math.max(this.tr.last ?? start, start + .8),
4961
+ src: srcText,
4962
+ tr: { [this.opts.target]: tr },
4963
+ final: false
4762
4964
  });
4763
- this.seq++;
4764
- this.src = "";
4765
- this.tr = "";
4766
- this.lineStart = c.end;
4767
4965
  }
4768
- onEvent(ev) {
4769
- switch (ev.type) {
4770
- case "session.input_transcript.delta":
4771
- this.src += String(ev.delta ?? "");
4772
- this.draft();
4773
- break;
4774
- case "session.output_transcript.delta":
4775
- this.tr += String(ev.delta ?? "");
4776
- this.draft();
4777
- break;
4778
- case "error": {
4779
- const err = ev.error ?? {};
4780
- this.onError?.(/* @__PURE__ */ new Error(`OpenAI: ${err.message ?? "error"}`));
4781
- break;
4782
- }
4783
- }
4966
+ /** Seconds between audio and its text arriving, as measured (for the page). */
4967
+ latency() {
4968
+ return this.lag;
4784
4969
  }
4785
4970
  async stop() {
4786
- this.chunker.flush(.5);
4787
- await new Promise((r) => setTimeout(r, 1500));
4788
- this.finish();
4971
+ await new Promise((r) => setTimeout(r, 2500));
4972
+ if (this.quiet) clearInterval(this.quiet);
4973
+ this.src.close();
4974
+ this.tr.close();
4975
+ this.pair(true);
4789
4976
  this.stopped = true;
4790
4977
  try {
4791
4978
  this.ws?.close();
4792
4979
  } catch {}
4793
4980
  }
4794
4981
  };
4982
+ // src/overlay.ts
4983
+ /**
4984
+ * Subtitles as a page of their own, for OBS's Browser Source (or any
4985
+ * broadcast tool that layers a web page): transparent background, the newest
4986
+ * line of the video being worked on, styled like the player. It lives on a
4987
+ * fixed local port so the address in OBS keeps working across restarts.
4988
+ *
4989
+ * http://127.0.0.1:<port>/overlay newest running video
4990
+ * …/overlay?lines=zh,src&size=4.5&pos=bottom&bg=0.55 lines, size (% of width), position, box opacity
4991
+ */
4992
+ function startOverlay(port, current, onJob, log) {
4993
+ const clients = /* @__PURE__ */ new Set();
4994
+ let watched;
4995
+ let detach;
4996
+ const send = (cue) => {
4997
+ const data = `data: ${JSON.stringify(cue ? {
4998
+ src: cue.src,
4999
+ tr: cue.tr ?? {},
5000
+ final: cue.final,
5001
+ lang: cue.lang
5002
+ } : null)}\n\n`;
5003
+ for (const c of clients) c.write(data);
5004
+ };
5005
+ const follow = (job) => {
5006
+ if (job === watched) return;
5007
+ detach?.();
5008
+ watched = job;
5009
+ if (!job) return;
5010
+ const onCue = (c) => send(c);
5011
+ job.on("cue", onCue);
5012
+ detach = () => job.off("cue", onCue);
5013
+ };
5014
+ follow(current());
5015
+ const offJob = onJob((job) => follow(job));
5016
+ const server = createServer$1((req, res) => {
5017
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
5018
+ if (url.pathname === "/overlay/events") {
5019
+ res.writeHead(200, {
5020
+ "content-type": "text/event-stream",
5021
+ "cache-control": "no-store",
5022
+ connection: "keep-alive",
5023
+ "access-control-allow-origin": "*"
5024
+ });
5025
+ const c = { write: (s) => res.write(s) };
5026
+ clients.add(c);
5027
+ const last = watched?.cues.at(-1);
5028
+ if (last) res.write(`data: ${JSON.stringify({
5029
+ src: last.src,
5030
+ tr: last.tr ?? {},
5031
+ final: last.final,
5032
+ lang: last.lang
5033
+ })}\n\n`);
5034
+ const ping = setInterval(() => res.write(": ping\n\n"), 15e3);
5035
+ req.on("close", () => {
5036
+ clearInterval(ping);
5037
+ clients.delete(c);
5038
+ });
5039
+ return;
5040
+ }
5041
+ if (url.pathname === "/overlay" || url.pathname === "/overlay/") {
5042
+ res.writeHead(200, {
5043
+ "content-type": "text/html; charset=utf-8",
5044
+ "cache-control": "no-store"
5045
+ });
5046
+ res.end(PAGE);
5047
+ return;
5048
+ }
5049
+ if (url.pathname === "/screen" || url.pathname === "/screen/") {
5050
+ res.writeHead(200, {
5051
+ "content-type": "text/html; charset=utf-8",
5052
+ "cache-control": "no-store"
5053
+ });
5054
+ res.end(SCREEN);
5055
+ return;
5056
+ }
5057
+ res.writeHead(404);
5058
+ res.end();
5059
+ });
5060
+ server.on("error", (err) => log(`overlay: port ${port} unavailable (${err.message})`));
5061
+ server.listen(port, () => log(`overlay: http://127.0.0.1:${port}/overlay, /screen`));
5062
+ return () => {
5063
+ offJob();
5064
+ detach?.();
5065
+ server.close();
5066
+ };
5067
+ }
5068
+ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Vidi subtitles</title>
5069
+ <style>
5070
+ html,body{margin:0;height:100%;background:transparent;overflow:hidden}
5071
+ #box{position:absolute;left:50%;width:92%;transform:translateX(-50%);text-align:center;font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif;transition:opacity .3s}
5072
+ #box div{text-wrap:balance;line-break:strict}#box div+div{margin-top:.3em}
5073
+ #box span{display:inline;padding:.08em .45em;border-radius:.22em;color:#fff;line-height:1.55;-webkit-box-decoration-break:clone;box-decoration-break:clone;text-shadow:0 1px 2px rgba(0,0,0,.45)}
5074
+ #box .main{font-weight:600}
5075
+ #box .sub{color:#e8e8e8}
5076
+ </style></head><body><div id="box"></div>
5077
+ <script>
5078
+ const q = new URLSearchParams(location.search)
5079
+ const lines = (q.get('lines') || 'zh,src').split(',')
5080
+ const size = Number(q.get('size') || 4.5), bg = q.get('bg') || '0.55', pos = q.get('pos') || 'bottom', offset = Number(q.get('offset') || 6)
5081
+ const box = document.getElementById('box')
5082
+ box.style[pos === 'top' ? 'top' : 'bottom'] = offset + '%'
5083
+ let hide
5084
+ function show(c) {
5085
+ clearTimeout(hide)
5086
+ if (!c) { box.style.opacity = 0; return }
5087
+ const seen = new Set()
5088
+ const rows = lines.map(k => (k === 'src' ? c.src : (c.tr || {})[k]) || '').map(s => s.trim()).filter(s => s && !seen.has(s) && seen.add(s))
5089
+ box.innerHTML = ''
5090
+ rows.forEach((text, i) => {
5091
+ const d = document.createElement('div'), s = document.createElement('span')
5092
+ s.className = i ? 'sub' : 'main'
5093
+ s.style.fontSize = (i ? size * 0.68 : size) + 'vw'
5094
+ s.style.background = 'rgba(0,0,0,' + bg + ')'
5095
+ s.textContent = text
5096
+ d.appendChild(s); box.appendChild(d)
5097
+ })
5098
+ box.style.opacity = rows.length ? 1 : 0
5099
+ // A line stays up a while after it is finished, then the screen clears.
5100
+ if (c.final) hide = setTimeout(() => { box.style.opacity = 0 }, 6000)
5101
+ }
5102
+ function connect() {
5103
+ const es = new EventSource('/overlay/events')
5104
+ es.onmessage = e => { try { show(JSON.parse(e.data)) } catch {} }
5105
+ es.onerror = () => { es.close(); setTimeout(connect, 2000) }
5106
+ }
5107
+ connect()
5108
+ <\/script></body></html>`;
5109
+ /**
5110
+ * Subtitles as the whole picture, for a big screen (a hall, a projector, a
5111
+ * second monitor, or OBS as a source of its own): 16:9, the current line large
5112
+ * with the original under it, the two before it smaller and dimmed above.
5113
+ * …/screen?lines=zh,src&theme=dark|light
5114
+ */
5115
+ const SCREEN = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vidi · subtitles</title>
5116
+ <style>
5117
+ html,body{margin:0;height:100%;background:#000;overflow:hidden;cursor:none}
5118
+ body.light{background:#f4f3ef}
5119
+ #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%;
5120
+ font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif;color:#fff;container-type:inline-size}
5121
+ body.light #stage{color:#151513}
5122
+ .cue{text-align:center;text-wrap:balance;line-break:strict;transition:opacity .4s,font-size .3s}
5123
+ .cue+.cue{margin-top:3.2cqw}
5124
+ .cue .main{display:block;font-weight:600;line-height:1.35}
5125
+ .cue .sub{display:block;margin-top:.8cqw;opacity:.62;line-height:1.4}
5126
+ .cue.now .main{font-size:5.4cqw}.cue.now .sub{font-size:2.9cqw}
5127
+ .cue.old .main{font-size:3cqw}.cue.old .sub{font-size:1.9cqw}
5128
+ .cue.old{opacity:.38}.cue.old2{opacity:.2}
5129
+ .cue.draft .main{opacity:.75}
5130
+ </style></head><body><div id="stage"></div>
5131
+ <script>
5132
+ const q = new URLSearchParams(location.search)
5133
+ const lines = (q.get('lines') || 'zh,src').split(',')
5134
+ if (q.get('theme') === 'light') document.body.classList.add('light')
5135
+ const stage = document.getElementById('stage')
5136
+ let done = [], draft = null
5137
+ function texts(c) {
5138
+ const seen = new Set()
5139
+ return lines.map(k => (k === 'src' ? c.src : (c.tr || {})[k]) || '').map(s => s.trim()).filter(s => s && !seen.has(s) && seen.add(s))
5140
+ }
5141
+ function render() {
5142
+ const list = [...done.slice(-3), ...(draft ? [draft] : [])].slice(-3)
5143
+ stage.innerHTML = ''
5144
+ list.forEach((c, i) => {
5145
+ const t = texts(c); if (!t.length) return
5146
+ const d = document.createElement('div')
5147
+ const age = list.length - 1 - i
5148
+ d.className = 'cue ' + (age === 0 ? 'now' : age === 1 ? 'old' : 'old old2') + (c.final ? '' : ' draft')
5149
+ const m = document.createElement('span'); m.className = 'main'; m.textContent = t[0]; d.appendChild(m)
5150
+ if (t[1]) { const s = document.createElement('span'); s.className = 'sub'; s.textContent = t[1]; d.appendChild(s) }
5151
+ stage.appendChild(d)
5152
+ })
5153
+ }
5154
+ function connect() {
5155
+ const es = new EventSource('/overlay/events')
5156
+ es.onmessage = e => {
5157
+ let c; try { c = JSON.parse(e.data) } catch { return }
5158
+ if (!c) return
5159
+ if (c.final) { draft = null; if (!done.length || done.at(-1).src !== c.src) done.push(c); done = done.slice(-6) } else draft = c
5160
+ render()
5161
+ }
5162
+ es.onerror = () => { es.close(); setTimeout(connect, 2000) }
5163
+ }
5164
+ connect()
5165
+ <\/script></body></html>`;
4795
5166
  // src/tools.ts
4796
5167
  const CLOCK = (s) => {
4797
5168
  const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, ss = Math.floor(s % 60);
@@ -5151,6 +5522,7 @@ const Config = z.object({
5151
5522
  glossary: z.string().role("textarea").default("").description("Terms to keep consistent, one per line (e.g. \"Dots = Dots\"). Each stream also builds its own glossary automatically."),
5152
5523
  rtmpPort: z.number().default(1935).description("Port for incoming RTMP pushes (OBS → rtmp://<this machine>/live)."),
5153
5524
  srtPort: z.number().default(9e3).description("Port for incoming SRT pushes."),
5525
+ overlayPort: z.number().default(19380).description("Local port of the subtitle overlay page for OBS (http://127.0.0.1:<port>/overlay)."),
5154
5526
  openaiApiKey: z.string().role("secret").default("").description("OpenAI API key for online subtitles. Usually not needed here: keys saved in DSH (Models page) or on the video page are used."),
5155
5527
  openaiModel: z.string().default("gpt-live-transcribe").description("OpenAI realtime transcription model."),
5156
5528
  geminiModel: z.string().default("auto").description("Gemini model for online subtitles (\"auto\" picks the newest Flash model your key can use).")
@@ -5365,6 +5737,17 @@ function apply(ctx, config) {
5365
5737
  const svc = service(opts.provider);
5366
5738
  if (svc) {
5367
5739
  if (!keys.get(svc.key)) throw new Error(`no ${svc.label} key yet: add one in the video page settings or on the DSH Models page`);
5740
+ const target = opts.tgtLangs.find((l) => l !== opts.srcLang);
5741
+ if (svc.kind === "realtime" && opts.mode === "realtime" && target && TRANSLATE_TARGETS.includes(target)) {
5742
+ const tr = service("openai-translate");
5743
+ log(`subtitles in step: ${tr.model} into ${target}`);
5744
+ return new OpenAITranslateProvider({
5745
+ apiKey: keys.get("openai"),
5746
+ model: tr.model,
5747
+ target,
5748
+ log
5749
+ });
5750
+ }
5368
5751
  if (svc.kind === "realtime") return new OpenAIProvider({
5369
5752
  apiKey: keys.get("openai"),
5370
5753
  model: config.openaiModel,
@@ -5383,8 +5766,6 @@ function apply(ctx, config) {
5383
5766
  apiKey: keys.get("openai"),
5384
5767
  model: svc.model,
5385
5768
  target,
5386
- minSec: 2,
5387
- maxSec: 8,
5388
5769
  log
5389
5770
  });
5390
5771
  }
@@ -5528,7 +5909,7 @@ function apply(ctx, config) {
5528
5909
  return job;
5529
5910
  }
5530
5911
  }
5531
- const modeOf = (m) => m === "polished" || m === "site" ? m : "realtime";
5912
+ const modeOf = (m) => m === "polished" ? "polished" : m === "live" || m === "site" ? "live" : "realtime";
5532
5913
  async function startJob(opts, waiting = false) {
5533
5914
  const prev = waiting ? void 0 : byConversation.get(opts.conversationId);
5534
5915
  if (prev) await jobs.get(prev)?.close("replaced");
@@ -5539,7 +5920,7 @@ function apply(ctx, config) {
5539
5920
  job = new LiveJob(id, opts, jobDeps(dir, opts, () => job, waiting));
5540
5921
  jobs.set(id, job);
5541
5922
  if (!waiting) byConversation.set(opts.conversationId, id);
5542
- job.autoSubs = opts.mode !== "site";
5923
+ job.autoSubs = true;
5543
5924
  bus.emit("job", job);
5544
5925
  job.start();
5545
5926
  return job;
@@ -5549,6 +5930,11 @@ function apply(ctx, config) {
5549
5930
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
5550
5931
  const route = url.pathname.replace(/^\/live\/api\/?/, "");
5551
5932
  try {
5933
+ if (route === "overlay") return send(res, 200, {
5934
+ url: `http://127.0.0.1:${config.overlayPort}/overlay`,
5935
+ screen: `http://127.0.0.1:${config.overlayPort}/screen`,
5936
+ screenLan: `http://${lanAddress()}:${config.overlayPort}/screen`
5937
+ });
5552
5938
  if (route === "deps") {
5553
5939
  const [deps] = await Promise.all([checkDeps(config.whisperModel), keys.refresh()]);
5554
5940
  localReady = deps.filter((d) => [
@@ -5709,7 +6095,7 @@ function apply(ctx, config) {
5709
6095
  if (Array.isArray(b.tgtLangs)) opts.tgtLangs = b.tgtLangs.map(String).slice(0, 4);
5710
6096
  if (typeof b.srcLang === "string") opts.srcLang = b.srcLang;
5711
6097
  if (typeof b.provider === "string") opts.provider = b.provider;
5712
- if (b.mode === "realtime" || b.mode === "polished" || b.mode === "site") opts.mode = b.mode;
6098
+ if (typeof b.mode === "string") opts.mode = modeOf(b.mode);
5713
6099
  const svc = opts.provider ? service(opts.provider) : void 0;
5714
6100
  if (svc) {
5715
6101
  await keys.refresh();
@@ -6145,6 +6531,12 @@ function apply(ctx, config) {
6145
6531
  path: "/live/media",
6146
6532
  handler: media
6147
6533
  }), "dsh-vidi: media route");
6534
+ ctx.effect(() => startOverlay(config.overlayPort, () => [...jobs.values()].filter((j) => j.summary().status === "running").at(-1) ?? [...jobs.values()].at(-1), (fn) => {
6535
+ bus.on("job", fn);
6536
+ return () => {
6537
+ bus.off("job", fn);
6538
+ };
6539
+ }, log), "dsh-vidi: subtitle overlay");
6148
6540
  ctx.effect(() => () => {
6149
6541
  for (const job of jobs.values()) job.close("plugin unloaded");
6150
6542
  whisper.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",