dsh-vidi 0.5.1 → 0.6.0

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 +279 -56
  2. package/lib/index.mjs +626 -36
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -821,12 +821,14 @@ function Pick(props) {
821
821
  const room = window.innerHeight - r.bottom - 8, above = r.top - 8;
822
822
  const up = room < 220 && above > room;
823
823
  const maxH = Math.max(120, Math.min(320, up ? above - 4 : room - 4));
824
- const width = Math.min(Math.max(r.width, 180), window.innerWidth - 16);
825
- const left = Math.min(Math.max(8, r.left), window.innerWidth - width - 8);
824
+ const minWidth = Math.min(Math.max(r.width, 180), window.innerWidth - 16);
825
+ const maxWidth = Math.max(minWidth, Math.min(380, window.innerWidth - 16));
826
+ const side = r.left + maxWidth > window.innerWidth - 8 ? { right: Math.max(8, window.innerWidth - r.right) } : { left: Math.max(8, r.left) };
826
827
  setPlace({
827
828
  ...vars,
828
- left,
829
- width,
829
+ ...side,
830
+ minWidth,
831
+ maxWidth,
830
832
  maxHeight: maxH,
831
833
  ...up ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 },
832
834
  fontFamily: cs.fontFamily
@@ -1603,7 +1605,7 @@ function fragmentsAreEqual(frag, otherFrag) {
1603
1605
  /**
1604
1606
  * Object representing parsed data from an HLS Segment. Found in {@link hls.js#LevelDetails.fragments}.
1605
1607
  */
1606
- var Fragment = class extends BaseSegment {
1608
+ var Fragment$1 = class extends BaseSegment {
1607
1609
  constructor(type, base) {
1608
1610
  super(base);
1609
1611
  this._decryptdata = null;
@@ -9753,7 +9755,7 @@ var M3U8Parser = class M3U8Parser {
9753
9755
  let discontinuityCounter = 0;
9754
9756
  let currentBitrate = 0;
9755
9757
  let prevFrag = null;
9756
- let frag = new Fragment(type, base);
9758
+ let frag = new Fragment$1(type, base);
9757
9759
  let result;
9758
9760
  let i;
9759
9761
  let levelkeys;
@@ -9771,7 +9773,7 @@ var M3U8Parser = class M3U8Parser {
9771
9773
  while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) {
9772
9774
  if (createNextFrag) {
9773
9775
  createNextFrag = false;
9774
- frag = new Fragment(type, base);
9776
+ frag = new Fragment$1(type, base);
9775
9777
  frag.playlistOffset = totalduration;
9776
9778
  frag.setStart(totalduration);
9777
9779
  frag.sn = currentSN;
@@ -9819,7 +9821,7 @@ var M3U8Parser = class M3U8Parser {
9819
9821
  const byteRange = frag.byteRange;
9820
9822
  if (byteRange.length === 2) frag.bitrate = (byteRange[1] - byteRange[0]) * 8 / frag.duration | 0;
9821
9823
  if (level.iframesOnly && byteRange[0] && ((_currentInitSegment = currentInitSegment) == null ? void 0 : _currentInitSegment.cc) !== discontinuityCounter) {
9822
- const init = new Fragment(type, base);
9824
+ const init = new Fragment$1(type, base);
9823
9825
  init.relurl = frag.relurl;
9824
9826
  init.setByteRange(`${Math.min(byteRange[0], 1316)}@0`);
9825
9827
  init.level = id;
@@ -9953,7 +9955,7 @@ var M3U8Parser = class M3U8Parser {
9953
9955
  case "MAP": {
9954
9956
  const mapAttrs = new AttrList(value1, level);
9955
9957
  if (frag.duration) {
9956
- const init = new Fragment(type, base);
9958
+ const init = new Fragment$1(type, base);
9957
9959
  setInitSegment(init, mapAttrs, id, levelkeys);
9958
9960
  currentInitSegment = init;
9959
9961
  frag.initSegment = currentInitSegment;
@@ -33343,6 +33345,95 @@ var Hls = class Hls {
33343
33345
  };
33344
33346
  Hls.defaultConfig = void 0;
33345
33347
 
33348
+ // src/wrap.ts
33349
+ /**
33350
+ * Line breaking for subtitles drawn on a canvas (burned into clips and
33351
+ * restreams, screenshots): break where the text allows, never start a line
33352
+ * with closing punctuation, and spread the text evenly over the lines it
33353
+ * needs — so a long line wraps as two halves instead of a full line plus one
33354
+ * orphaned character.
33355
+ */
33356
+ /** Characters that may not begin a line (they stay with the text before them). */
33357
+ const NO_START = new Set(Array.from(",。、;:!?)》」』】〕〉”’…—,.;:!?)]}%"));
33358
+ /** Where a sentence pauses: breaking right after these reads best. */
33359
+ const PAUSE = ",。、;:!?,;:!? ";
33360
+ /** Greedy breaking at `width`: Latin text at spaces, CJK between any two characters. */
33361
+ function greedy(chars, width, measure) {
33362
+ const lines = [];
33363
+ let cur = "";
33364
+ for (const ch of chars) {
33365
+ const next = cur + ch;
33366
+ if (cur && measure(next) > width && !NO_START.has(ch)) {
33367
+ const sp = cur.lastIndexOf(" ");
33368
+ const pause = Math.max(...Array.from(PAUSE).map((p) => cur.lastIndexOf(p)));
33369
+ if (/[A-Za-z0-9]/.test(ch) && /[A-Za-z0-9]$/.test(cur) && sp > cur.length * .3) {
33370
+ lines.push(cur.slice(0, sp));
33371
+ cur = cur.slice(sp + 1) + ch;
33372
+ } else if (pause > cur.length * .6 && pause < cur.length - 1) {
33373
+ lines.push(cur.slice(0, pause + 1).trimEnd());
33374
+ cur = cur.slice(pause + 1).trimStart() + ch;
33375
+ } else {
33376
+ lines.push(cur.trimEnd());
33377
+ cur = ch.trimStart();
33378
+ }
33379
+ } else cur = next;
33380
+ }
33381
+ if (cur.trim()) lines.push(cur.trimEnd());
33382
+ return lines;
33383
+ }
33384
+ /** Wrap `text` to at most `maxWidth`, using as few lines as possible, each about the same length. */
33385
+ function wrapBalanced(text, maxWidth, measure) {
33386
+ const chars = Array.from(text.trim());
33387
+ const first = greedy(chars, maxWidth, measure);
33388
+ if (first.length < 2) return first;
33389
+ let lo = measure(text) / first.length * .9, hi = maxWidth, best = first;
33390
+ for (let i = 0; i < 14 && hi - lo > 1; i++) {
33391
+ const mid = (lo + hi) / 2;
33392
+ const lines = greedy(chars, mid, measure);
33393
+ if (lines.length <= first.length) {
33394
+ best = lines;
33395
+ hi = mid;
33396
+ } else lo = mid;
33397
+ }
33398
+ return atPauses(best, maxWidth, measure);
33399
+ }
33400
+ /** Move each break to a nearby pause (within 4 characters) when both lines still fit. */
33401
+ function atPauses(lines, maxWidth, measure) {
33402
+ const out = lines.map((l) => Array.from(l));
33403
+ for (let k = 0; k + 1 < out.length; k++) {
33404
+ const a = out[k], b = out[k + 1];
33405
+ if (PAUSE.includes(a.at(-1) ?? "")) continue;
33406
+ const sep = /[\x21-\x7e]$/.test(a.join("")) && /^[\x21-\x7e]/.test(b.join("")) ? [" "] : [];
33407
+ let pick;
33408
+ for (let d = 1; d <= 4 && pick === void 0; d++) for (const shift of [d, -d]) {
33409
+ const cut = a.length + shift;
33410
+ if (cut <= 0) continue;
33411
+ const joined = [
33412
+ ...a,
33413
+ ...sep,
33414
+ ...b
33415
+ ];
33416
+ if (cut >= joined.length) continue;
33417
+ if (!PAUSE.includes(joined[cut - 1])) continue;
33418
+ const na = joined.slice(0, cut).join("").trimEnd(), nb = joined.slice(cut).join("").trimStart();
33419
+ if (nb && measure(na) <= maxWidth && measure(nb) <= maxWidth) {
33420
+ pick = shift;
33421
+ break;
33422
+ }
33423
+ }
33424
+ if (pick !== void 0) {
33425
+ const joined = [
33426
+ ...a,
33427
+ ...sep,
33428
+ ...b
33429
+ ], cut = a.length + pick;
33430
+ out[k] = Array.from(joined.slice(0, cut).join("").trimEnd());
33431
+ out[k + 1] = Array.from(joined.slice(cut).join("").trimStart());
33432
+ }
33433
+ }
33434
+ return out.map((l) => l.join(""));
33435
+ }
33436
+
33346
33437
  // src/client/subStyle.tsx
33347
33438
  const STORE = "dsh-live.subStyle";
33348
33439
  /** First line is the main one (largest); the original, when not first, is the smallest. */
@@ -33400,11 +33491,29 @@ function visibleRows(texts, style) {
33400
33491
  return true;
33401
33492
  });
33402
33493
  }
33494
+ let measurer;
33403
33495
  function SubtitleOverlay({ texts, style, draft }) {
33404
33496
  const rows = visibleRows(texts, style);
33405
- if (!rows.length) return null;
33497
+ const box = (0, react.useRef)(null);
33498
+ const [width, setWidth] = (0, react.useState)(0);
33499
+ (0, react.useLayoutEffect)(() => {
33500
+ const el = box.current;
33501
+ if (!el) return;
33502
+ const ro = new ResizeObserver(() => setWidth(el.clientWidth));
33503
+ ro.observe(el);
33504
+ setWidth(el.clientWidth);
33505
+ return () => ro.disconnect();
33506
+ }, []);
33507
+ const lines = (text, size, weight) => {
33508
+ if (measurer === void 0) measurer = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
33509
+ if (!width || !measurer || !box.current) return [text];
33510
+ const px = size / 100 * (width / .92);
33511
+ measurer.font = `${weight} ${px}px ${getComputedStyle(box.current).fontFamily}`;
33512
+ return wrapBalanced(text, (width - px * .9) * .97, (t) => measurer.measureText(t).width);
33513
+ };
33406
33514
  const edge = style.position === "top" ? { top: `${style.offset}%` } : { bottom: `${style.offset}%` };
33407
33515
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
33516
+ ref: box,
33408
33517
  className: "dshl-sub",
33409
33518
  "data-pos": style.position,
33410
33519
  style: {
@@ -33414,7 +33523,7 @@ function SubtitleOverlay({ texts, style, draft }) {
33414
33523
  children: rows.map((l, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33415
33524
  className: `${i === 0 ? "tgt" : "src"} ${l.key === "src" && draft ? "draft" : ""} ${l.key.startsWith("site:") ? "site" : ""}`,
33416
33525
  style: { fontSize: `${l.size}cqw` },
33417
- children: texts[l.key]
33526
+ children: lines(texts[l.key].trim(), l.size, i === 0 ? 600 : 400).map((ln, k) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [k > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("br", {}), ln] }, k))
33418
33527
  }) }, l.key))
33419
33528
  });
33420
33529
  }
@@ -33429,14 +33538,7 @@ function drawSubtitles(g, W, H, texts, style) {
33429
33538
  const blocks = rows.map((l, i) => {
33430
33539
  const px = l.size / 100 * W;
33431
33540
  g.font = `${i === 0 ? 600 : 400} ${px}px ${family}`;
33432
- const words = [...String(texts[l.key])];
33433
- const lines = [];
33434
- let cur = "";
33435
- for (const ch of words) if (g.measureText(cur + ch).width > W * .88 && cur) {
33436
- lines.push(cur);
33437
- cur = ch.trimStart();
33438
- } else cur += ch;
33439
- if (cur) lines.push(cur);
33541
+ const lines = wrapBalanced(String(texts[l.key]), W * .88, (t) => g.measureText(t).width);
33440
33542
  return {
33441
33543
  px,
33442
33544
  weight: i === 0 ? 600 : 400,
@@ -34505,6 +34607,19 @@ function RestreamPanel(props) {
34505
34607
  }
34506
34608
 
34507
34609
  // src/client/Settings.tsx
34610
+ /** Who runs a speech engine (engines of one vendor share its key). */
34611
+ const vendorOf = (p) => p.vendor ?? p.id;
34612
+ /**
34613
+ * Subtitle models worth suggesting first: the fast, cheap tier of each family
34614
+ * (flash / mini / turbo / air / lite / haiku), newest first, and the one DSH
34615
+ * uses by default.
34616
+ */
34617
+ function recommended(models, fallback) {
34618
+ const ver = (n) => Number(/(\d+(?:\.\d+)?)/.exec(n)?.[1] ?? 0);
34619
+ const picks = models.filter((m) => /flash|mini|turbo|\bair\b|-air|lite|haiku/i.test(`${m.model} ${m.name}`) && !/embed|tts|audio|image|vision-preview|realtime/i.test(m.model)).sort((a, b) => ver(b.model) - ver(a.model)).slice(0, 2).map((m) => m.model);
34620
+ if (fallback && models.some((m) => m.model === fallback)) picks.unshift(fallback);
34621
+ return new Set(picks);
34622
+ }
34508
34623
  const engineLabel = (t, id) => {
34509
34624
  const v = t(`engine.${id}`);
34510
34625
  return v === `engine.${id}` ? id : v;
@@ -34528,6 +34643,13 @@ function Settings(props) {
34528
34643
  const [msg, setMsg] = (0, react.useState)(null);
34529
34644
  const cur = providers.find((p) => p.id === engine);
34530
34645
  const key = cur?.key ?? { set: false };
34646
+ const vendorName = (v) => {
34647
+ const x = t(`vendor.${v}`);
34648
+ return x === `vendor.${v}` ? name(v) : x;
34649
+ };
34650
+ const vendors = [...new Map(providers.map((p) => [vendorOf(p), p])).values()];
34651
+ const curVendor = cur ? vendorOf(cur) : "local";
34652
+ const vendorEngines = providers.filter((p) => vendorOf(p) === curVendor);
34531
34653
  (0, react.useEffect)(() => {
34532
34654
  if (!open) return;
34533
34655
  const close = (e) => {
@@ -34546,9 +34668,6 @@ function Settings(props) {
34546
34668
  fetch("/live/api/models").then((r) => r.json()).then(setModels).catch(() => {});
34547
34669
  }, [open, models]);
34548
34670
  const modelKey = (m) => m ? `${m.provider}/${m.model}` : "";
34549
- const modelName = (m) => {
34550
- return (models?.models.find((x) => modelKey(x) === modelKey(m)))?.name ?? m?.model ?? "";
34551
- };
34552
34671
  const chooseModel = (key) => {
34553
34672
  const row = models?.models.find((x) => modelKey(x) === key);
34554
34673
  const subsModel = row ? {
@@ -34561,8 +34680,14 @@ function Settings(props) {
34561
34680
  });
34562
34681
  post$1("settings", { subsModel });
34563
34682
  };
34564
- const providersOf = [...new Set((models?.models ?? []).map((m) => m.providerName))];
34565
- const current = models?.chosen ?? models?.fallback;
34683
+ const current = models?.chosen ?? models?.fallback ?? models?.current ?? void 0;
34684
+ const providersOf = [...new Map([...current && !(models?.models ?? []).some((m) => m.provider === current.provider) ? [[current.provider, {
34685
+ id: current.provider,
34686
+ name: current.provider
34687
+ }]] : [], ...(models?.models ?? []).map((m) => [m.provider, {
34688
+ id: m.provider,
34689
+ name: m.providerName
34690
+ }])]).values()];
34566
34691
  const sees = !!models?.models.find((x) => modelKey(x) === modelKey(current))?.image;
34567
34692
  const saveKey = async (value) => {
34568
34693
  if (!cur) return;
@@ -34602,19 +34727,35 @@ function Settings(props) {
34602
34727
  className: "dshl-pop settings right",
34603
34728
  children: [
34604
34729
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34605
- className: "srow",
34730
+ className: "srow col",
34606
34731
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34607
34732
  title: t("set.engine.tip"),
34608
34733
  children: t("set.engine")
34609
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34610
- value: engine,
34611
- disabled,
34612
- onChange: onEngine,
34613
- options: providers.map((p) => ({
34614
- value: p.id,
34615
- label: name(p.id),
34616
- hint: p.ready ? void 0 : t(p.online ? "set.engine.noKey" : "set.engine.notInstalled")
34617
- }))
34734
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34735
+ className: "duo",
34736
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34737
+ value: curVendor,
34738
+ disabled,
34739
+ onChange: (v) => {
34740
+ const first = providers.find((p) => vendorOf(p) === v);
34741
+ if (first) onEngine(first.id);
34742
+ },
34743
+ options: vendors.map((p) => ({
34744
+ value: vendorOf(p),
34745
+ label: vendorName(vendorOf(p)),
34746
+ hint: p.ready ? void 0 : t(p.online ? "set.engine.noKey" : "set.engine.notInstalled"),
34747
+ group: p.online ? t(p.region === "cn" ? "set.engine.cn" : "set.engine.intl") : void 0
34748
+ }))
34749
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34750
+ value: engine,
34751
+ disabled: disabled || vendorEngines.length < 2,
34752
+ onChange: onEngine,
34753
+ options: vendorEngines.map((p, i) => ({
34754
+ value: p.id,
34755
+ label: p.model ?? name(p.id),
34756
+ hint: t(`engine.note.${p.id}`) !== `engine.note.${p.id}` ? t(`engine.note.${p.id}`) : i === 0 && vendorEngines.length > 1 ? t("set.recommended") : void 0
34757
+ }))
34758
+ })]
34618
34759
  })]
34619
34760
  }),
34620
34761
  cur?.online && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -34622,7 +34763,7 @@ function Settings(props) {
34622
34763
  children: [
34623
34764
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34624
34765
  title: t(shared ? "set.key.tip.shared" : "set.key.tip"),
34625
- children: t("set.key").replace("{s}", name(cur.id))
34766
+ children: t("set.key").replace("{s}", vendorName(curVendor))
34626
34767
  }),
34627
34768
  key.set && !draft && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34628
34769
  className: "keyline",
@@ -34683,28 +34824,60 @@ function Settings(props) {
34683
34824
  }),
34684
34825
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34685
34826
  className: "srow col",
34686
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34687
- className: "pair",
34688
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34827
+ children: [
34828
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34689
34829
  title: t("set.model.tip"),
34690
34830
  children: t("set.model")
34691
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34692
- value: models?.chosen ? modelKey(models.chosen) : "",
34693
- onChange: chooseModel,
34694
- options: [{
34695
- value: "",
34696
- label: models?.fallback ? t("set.model.default").replace("{m}", modelName(models.fallback)) : t("set.model.dsh")
34697
- }, ...providersOf.flatMap((pn) => models.models.filter((m) => m.providerName === pn).map((m) => ({
34698
- value: modelKey(m),
34699
- label: m.name,
34700
- hint: m.image ? t("set.model.image") : void 0,
34701
- group: pn
34702
- })))]
34703
- })]
34704
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34705
- className: "hint",
34706
- children: t(sees ? "set.model.sees" : "set.model.text")
34707
- })]
34831
+ }),
34832
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34833
+ className: "duo",
34834
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34835
+ value: current?.provider ?? "",
34836
+ placeholder: "…",
34837
+ onChange: (v) => {
34838
+ const list = (models?.models ?? []).filter((m) => m.provider === v);
34839
+ const rec = recommended(list, models?.fallback?.provider === v ? models.fallback.model : void 0);
34840
+ const pick = list.find((m) => rec.has(m.model)) ?? list[0];
34841
+ if (pick) chooseModel(modelKey(pick));
34842
+ },
34843
+ options: providersOf.map((p) => ({
34844
+ value: p.id,
34845
+ label: p.name
34846
+ }))
34847
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34848
+ value: modelKey(current),
34849
+ placeholder: "…",
34850
+ onChange: chooseModel,
34851
+ options: (() => {
34852
+ const listed = (models?.models ?? []).filter((m) => m.provider === current?.provider);
34853
+ const list = current && !listed.some((m) => m.model === current.model) ? [{
34854
+ provider: current.provider,
34855
+ providerName: current.provider,
34856
+ model: current.model,
34857
+ name: current.model,
34858
+ image: false
34859
+ }, ...listed] : listed;
34860
+ const rec = recommended(list, models?.fallback?.provider === current?.provider ? models?.fallback?.model : void 0);
34861
+ const tag = (m) => [modelKey(m) === modelKey(models?.fallback) ? t("set.model.dshDefault") : "", m.image ? t("set.model.image") : ""].filter(Boolean).join(" · ") || void 0;
34862
+ return [...list.filter((m) => rec.has(m.model)).map((m) => ({
34863
+ value: modelKey(m),
34864
+ label: m.name,
34865
+ hint: tag(m),
34866
+ group: t("set.recommended")
34867
+ })), ...list.filter((m) => !rec.has(m.model)).map((m) => ({
34868
+ value: modelKey(m),
34869
+ label: m.name,
34870
+ hint: tag(m),
34871
+ group: rec.size ? t("set.allModels") : void 0
34872
+ }))];
34873
+ })()
34874
+ })]
34875
+ }),
34876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34877
+ className: "hint",
34878
+ children: t(sees ? "set.model.sees" : "set.model.text")
34879
+ })
34880
+ ]
34708
34881
  }),
34709
34882
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34710
34883
  className: "srow col",
@@ -36748,8 +36921,31 @@ const en = {
36748
36921
  "set.engine.notInstalled": "not installed",
36749
36922
  "engine.local": "This computer (Whisper)",
36750
36923
  "engine.openai": "OpenAI",
36924
+ "engine.openai-translate": "OpenAI live translation",
36925
+ "set.engine.intl": "Online",
36926
+ "set.recommended": "Recommended",
36927
+ "set.allModels": "All models",
36928
+ "set.model.dshDefault": "DSH default",
36929
+ "engine.note.openai-translate": "listens and translates",
36930
+ "vendor.local": "This computer",
36931
+ "vendor.openai": "OpenAI",
36932
+ "vendor.gemini": "Google",
36933
+ "vendor.deepgram": "Deepgram",
36934
+ "vendor.elevenlabs": "ElevenLabs",
36935
+ "vendor.mistral": "Mistral",
36936
+ "vendor.groq": "Groq",
36937
+ "vendor.dashscope": "Alibaba Cloud Bailian",
36938
+ "vendor.volc": "Volcengine",
36939
+ "vendor.siliconflow": "SiliconFlow",
36940
+ "vendor.glm": "Zhipu",
36941
+ "set.engine.cn": "Online · mainland China",
36751
36942
  "engine.gemini": "Google Gemini",
36943
+ "engine.deepgram": "Deepgram",
36944
+ "engine.elevenlabs": "ElevenLabs",
36945
+ "engine.mistral": "Mistral (Voxtral)",
36752
36946
  "engine.groq": "Groq",
36947
+ "engine.dashscope": "Alibaba Cloud (Qwen-ASR)",
36948
+ "engine.volc": "Volcengine (Doubao)",
36753
36949
  "engine.siliconflow": "SiliconFlow",
36754
36950
  "engine.glm": "Zhipu GLM",
36755
36951
  "set.local.missing": "Local recognition (whisper.cpp) isn’t installed on this computer. Pick an online service above and add its key.",
@@ -37084,8 +37280,31 @@ const zh = {
37084
37280
  "set.engine.notInstalled": "未安装",
37085
37281
  "engine.local": "本机(Whisper)",
37086
37282
  "engine.openai": "OpenAI",
37283
+ "engine.openai-translate": "OpenAI 实时翻译",
37284
+ "set.engine.intl": "在线 · 海外",
37285
+ "set.recommended": "推荐",
37286
+ "set.allModels": "全部模型",
37287
+ "set.model.dshDefault": "DSH 默认",
37288
+ "engine.note.openai-translate": "边听边翻",
37289
+ "vendor.local": "本机",
37290
+ "vendor.openai": "OpenAI",
37291
+ "vendor.gemini": "Google",
37292
+ "vendor.deepgram": "Deepgram",
37293
+ "vendor.elevenlabs": "ElevenLabs",
37294
+ "vendor.mistral": "Mistral",
37295
+ "vendor.groq": "Groq",
37296
+ "vendor.dashscope": "阿里云百炼",
37297
+ "vendor.volc": "火山引擎",
37298
+ "vendor.siliconflow": "硅基流动",
37299
+ "vendor.glm": "智谱",
37300
+ "set.engine.cn": "在线 · 国内",
37087
37301
  "engine.gemini": "Google Gemini",
37302
+ "engine.deepgram": "Deepgram",
37303
+ "engine.elevenlabs": "ElevenLabs",
37304
+ "engine.mistral": "Mistral(Voxtral)",
37088
37305
  "engine.groq": "Groq",
37306
+ "engine.dashscope": "阿里云百炼(Qwen-ASR)",
37307
+ "engine.volc": "火山引擎(豆包语音)",
37089
37308
  "engine.siliconflow": "硅基流动",
37090
37309
  "engine.glm": "智谱 GLM",
37091
37310
  "set.local.missing": "这台电脑没装本地识别(whisper.cpp)。在上面换一个在线服务,填上它的密钥就能用。",
@@ -37389,6 +37608,8 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37389
37608
  .dshl-sub .src { color: #e8e8e8; }
37390
37609
  .dshl-sub .draft { color: #b8b8b8; font-style: italic; }
37391
37610
  .dshl-sub div + div { margin-top: .3em; }
37611
+ /* A line that wraps is split into even halves, not a full line and one orphaned character. */
37612
+ .dshl-sub div { line-break: strict; }
37392
37613
  .dshl-stage:not(.idle) .dshl-sub[data-pos=bottom] { transform: translate(-50%, -64px); }
37393
37614
  .dshl-stage:not(.idle) .dshl-sub[data-pos=top] { transform: translate(-50%, 34px); }
37394
37615
 
@@ -37693,7 +37914,9 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37693
37914
  .dshl-pop.right { left: auto; right: 0; }
37694
37915
  .dshl-pop.up { top: auto; bottom: calc(100% + 6px); }
37695
37916
  .dshl .srow { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; font-size: 13.5px; }
37696
- .dshl-pop.settings { width: 320px; padding: 6px 12px; }
37917
+ .dshl-pop.settings { width: 380px; padding: 6px 12px; }
37918
+ .dshl .srow .duo { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 8px; }
37919
+ .dshl .srow .duo > .dshl-select { width: 100%; }
37697
37920
  .dshl-pop.settings .srow + .srow { border-top: 1px solid var(--line-soft); }
37698
37921
  .dshl-pop.settings .srow > .dshl-select, .dshl-pop.settings .srow > .dshl-seg { width: 168px; }
37699
37922
  .dshl-pop.settings .srow > .dshl-seg button { flex: 1; padding: 0 6px; }
package/lib/index.mjs CHANGED
@@ -1157,6 +1157,94 @@ function findOriginal(dir) {
1157
1157
  const f = readdirSync(dir).find((n) => /^original\.(mp4|mkv|webm|m4a|mp3)$/.test(n));
1158
1158
  return f ? join(dir, f) : void 0;
1159
1159
  }
1160
+ // src/wrap.ts
1161
+ /**
1162
+ * Line breaking for subtitles drawn on a canvas (burned into clips and
1163
+ * restreams, screenshots): break where the text allows, never start a line
1164
+ * with closing punctuation, and spread the text evenly over the lines it
1165
+ * needs — so a long line wraps as two halves instead of a full line plus one
1166
+ * orphaned character.
1167
+ */
1168
+ /** Characters that may not begin a line (they stay with the text before them). */
1169
+ const NO_START = new Set(Array.from(",。、;:!?)》」』】〕〉”’…—,.;:!?)]}%"));
1170
+ /** Where a sentence pauses: breaking right after these reads best. */
1171
+ const PAUSE = ",。、;:!?,;:!? ";
1172
+ /** Greedy breaking at `width`: Latin text at spaces, CJK between any two characters. */
1173
+ function greedy(chars, width, measure) {
1174
+ const lines = [];
1175
+ let cur = "";
1176
+ for (const ch of chars) {
1177
+ const next = cur + ch;
1178
+ if (cur && measure(next) > width && !NO_START.has(ch)) {
1179
+ const sp = cur.lastIndexOf(" ");
1180
+ const pause = Math.max(...Array.from(PAUSE).map((p) => cur.lastIndexOf(p)));
1181
+ if (/[A-Za-z0-9]/.test(ch) && /[A-Za-z0-9]$/.test(cur) && sp > cur.length * .3) {
1182
+ lines.push(cur.slice(0, sp));
1183
+ cur = cur.slice(sp + 1) + ch;
1184
+ } else if (pause > cur.length * .6 && pause < cur.length - 1) {
1185
+ lines.push(cur.slice(0, pause + 1).trimEnd());
1186
+ cur = cur.slice(pause + 1).trimStart() + ch;
1187
+ } else {
1188
+ lines.push(cur.trimEnd());
1189
+ cur = ch.trimStart();
1190
+ }
1191
+ } else cur = next;
1192
+ }
1193
+ if (cur.trim()) lines.push(cur.trimEnd());
1194
+ return lines;
1195
+ }
1196
+ /** Wrap `text` to at most `maxWidth`, using as few lines as possible, each about the same length. */
1197
+ function wrapBalanced(text, maxWidth, measure) {
1198
+ const chars = Array.from(text.trim());
1199
+ const first = greedy(chars, maxWidth, measure);
1200
+ if (first.length < 2) return first;
1201
+ let lo = measure(text) / first.length * .9, hi = maxWidth, best = first;
1202
+ for (let i = 0; i < 14 && hi - lo > 1; i++) {
1203
+ const mid = (lo + hi) / 2;
1204
+ const lines = greedy(chars, mid, measure);
1205
+ if (lines.length <= first.length) {
1206
+ best = lines;
1207
+ hi = mid;
1208
+ } else lo = mid;
1209
+ }
1210
+ return atPauses(best, maxWidth, measure);
1211
+ }
1212
+ /** Move each break to a nearby pause (within 4 characters) when both lines still fit. */
1213
+ function atPauses(lines, maxWidth, measure) {
1214
+ const out = lines.map((l) => Array.from(l));
1215
+ for (let k = 0; k + 1 < out.length; k++) {
1216
+ const a = out[k], b = out[k + 1];
1217
+ if (PAUSE.includes(a.at(-1) ?? "")) continue;
1218
+ const sep = /[\x21-\x7e]$/.test(a.join("")) && /^[\x21-\x7e]/.test(b.join("")) ? [" "] : [];
1219
+ let pick;
1220
+ for (let d = 1; d <= 4 && pick === void 0; d++) for (const shift of [d, -d]) {
1221
+ const cut = a.length + shift;
1222
+ if (cut <= 0) continue;
1223
+ const joined = [
1224
+ ...a,
1225
+ ...sep,
1226
+ ...b
1227
+ ];
1228
+ if (cut >= joined.length) continue;
1229
+ if (!PAUSE.includes(joined[cut - 1])) continue;
1230
+ const na = joined.slice(0, cut).join("").trimEnd(), nb = joined.slice(cut).join("").trimStart();
1231
+ if (nb && measure(na) <= maxWidth && measure(nb) <= maxWidth) {
1232
+ pick = shift;
1233
+ break;
1234
+ }
1235
+ }
1236
+ if (pick !== void 0) {
1237
+ const joined = [
1238
+ ...a,
1239
+ ...sep,
1240
+ ...b
1241
+ ], cut = a.length + pick;
1242
+ out[k] = Array.from(joined.slice(0, cut).join("").trimEnd());
1243
+ out[k + 1] = Array.from(joined.slice(cut).join("").trimStart());
1244
+ }
1245
+ }
1246
+ return out.map((l) => l.join(""));
1247
+ }
1160
1248
  // src/restream.ts
1161
1249
  /** Platforms people push to; only a label on our side — the address and key come from the platform's own streaming tool. */
1162
1250
  const PLATFORMS = [
@@ -1247,22 +1335,7 @@ async function drawSubtitles(rows, style, w, h) {
1247
1335
  const blocks = rows.map((r) => {
1248
1336
  const px = Math.round(r.size / 100 * w);
1249
1337
  g.font = `${r.main ? 600 : 500} ${px}px ${FONT}`;
1250
- const lines = [];
1251
- let cur = "";
1252
- for (const ch of Array.from(r.text)) {
1253
- const next = cur + ch;
1254
- if (cur && g.measureText(next).width > maxW - px * .9) {
1255
- const sp = cur.lastIndexOf(" ");
1256
- if (sp > cur.length * .5 && /[A-Za-z]/.test(ch)) {
1257
- lines.push(cur.slice(0, sp));
1258
- cur = cur.slice(sp + 1) + ch;
1259
- } else {
1260
- lines.push(cur);
1261
- cur = ch.trimStart();
1262
- }
1263
- } else cur = next;
1264
- }
1265
- if (cur.trim()) lines.push(cur);
1338
+ const lines = wrapBalanced(r.text, maxW - px * .9, (t) => g.measureText(t).width);
1266
1339
  return {
1267
1340
  px,
1268
1341
  main: r.main,
@@ -2906,7 +2979,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2906
2979
  // src/downloads.ts
2907
2980
  const run = promisify(execFile);
2908
2981
  /** ISO 639-2 codes for MP4 subtitle tracks. */
2909
- const ISO3 = {
2982
+ const ISO3$1 = {
2910
2983
  zh: "chi",
2911
2984
  "zh-Hant": "chi",
2912
2985
  en: "eng",
@@ -3052,7 +3125,7 @@ var Downloads = class {
3052
3125
  const file = join(tmp, `${i}.srt`);
3053
3126
  writeFileSync(file, exportCues(cues, t.langs, "srt", title));
3054
3127
  inputs.push("-i", file);
3055
- meta.push(`-metadata:s:s:${i}`, `language=${ISO3[t.code] ?? ISO3[t.code.slice(0, 2)] ?? "und"}`, `-metadata:s:s:${i}`, `title=${t.name}`, `-disposition:s:${i}`, i === 0 ? "default" : "0");
3128
+ meta.push(`-metadata:s:s:${i}`, `language=${ISO3$1[t.code] ?? ISO3$1[t.code.slice(0, 2)] ?? "und"}`, `-metadata:s:s:${i}`, `title=${t.name}`, `-disposition:s:${i}`, i === 0 ? "default" : "0");
3056
3129
  });
3057
3130
  try {
3058
3131
  await run("ffmpeg", [
@@ -3227,7 +3300,12 @@ const KEY_REFS = {
3227
3300
  "GLM_API_KEY",
3228
3301
  "ZHIPUAI_API_KEY",
3229
3302
  "ZAI_API_KEY"
3230
- ]
3303
+ ],
3304
+ deepgram: ["DEEPGRAM_API_KEY"],
3305
+ elevenlabs: ["ELEVENLABS_API_KEY", "XI_API_KEY"],
3306
+ mistral: ["MISTRAL_API_KEY"],
3307
+ dashscope: ["DASHSCOPE_API_KEY", "BAILIAN_API_KEY"],
3308
+ volc: ["VOLC_SPEECH_API_KEY", "VOLCENGINE_SPEECH_API_KEY"]
3231
3309
  };
3232
3310
  const KEY_NAMES = Object.keys(KEY_REFS);
3233
3311
  const mask = (k) => k.length > 10 ? `${k.slice(0, 3)}…${k.slice(-4)}` : "••••";
@@ -3998,39 +4076,107 @@ var OpenAIProvider = class {
3998
4076
  const SERVICES = [
3999
4077
  {
4000
4078
  id: "openai",
4079
+ key: "openai",
4001
4080
  label: "OpenAI",
4002
4081
  keyUrl: "https://platform.openai.com/api-keys",
4003
4082
  model: "gpt-live-transcribe",
4083
+ region: "intl",
4004
4084
  kind: "realtime"
4005
4085
  },
4086
+ {
4087
+ id: "openai-translate",
4088
+ key: "openai",
4089
+ label: "OpenAI live translation",
4090
+ keyUrl: "https://platform.openai.com/api-keys",
4091
+ model: "gpt-realtime-translate",
4092
+ region: "intl",
4093
+ kind: "translate"
4094
+ },
4006
4095
  {
4007
4096
  id: "gemini",
4097
+ key: "gemini",
4008
4098
  label: "Google Gemini",
4009
4099
  keyUrl: "https://aistudio.google.com/apikey",
4010
4100
  model: "auto",
4101
+ modelLabel: "gemini-3.5-transcribe",
4102
+ region: "intl",
4011
4103
  kind: "gemini"
4012
4104
  },
4105
+ {
4106
+ id: "deepgram",
4107
+ key: "deepgram",
4108
+ label: "Deepgram",
4109
+ keyUrl: "https://console.deepgram.com/",
4110
+ model: "nova-3",
4111
+ region: "intl",
4112
+ kind: "deepgram"
4113
+ },
4114
+ {
4115
+ id: "elevenlabs",
4116
+ key: "elevenlabs",
4117
+ label: "ElevenLabs",
4118
+ keyUrl: "https://elevenlabs.io/app/settings/api-keys",
4119
+ model: "scribe_v2",
4120
+ region: "intl",
4121
+ kind: "elevenlabs"
4122
+ },
4123
+ {
4124
+ id: "mistral",
4125
+ key: "mistral",
4126
+ label: "Mistral",
4127
+ keyUrl: "https://console.mistral.ai/api-keys",
4128
+ model: "voxtral-mini-latest",
4129
+ region: "intl",
4130
+ kind: "whisper-api",
4131
+ base: "https://api.mistral.ai/v1"
4132
+ },
4013
4133
  {
4014
4134
  id: "groq",
4135
+ key: "groq",
4015
4136
  label: "Groq",
4016
4137
  keyUrl: "https://console.groq.com/keys",
4017
4138
  model: "whisper-large-v3-turbo",
4139
+ region: "intl",
4018
4140
  kind: "whisper-api",
4019
4141
  base: "https://api.groq.com/openai/v1"
4020
4142
  },
4143
+ {
4144
+ id: "dashscope",
4145
+ key: "dashscope",
4146
+ label: "Alibaba Cloud Bailian",
4147
+ keyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
4148
+ model: "qwen3-asr-flash",
4149
+ region: "cn",
4150
+ kind: "dashscope",
4151
+ base: "https://dashscope.aliyuncs.com/compatible-mode/v1"
4152
+ },
4153
+ {
4154
+ id: "volc",
4155
+ key: "volc",
4156
+ label: "Volcengine Doubao",
4157
+ keyUrl: "https://console.volcengine.com/speech/new/",
4158
+ model: "volc.bigasr.auc_turbo",
4159
+ modelLabel: "Seed-ASR 极速版",
4160
+ region: "cn",
4161
+ kind: "volc"
4162
+ },
4021
4163
  {
4022
4164
  id: "siliconflow",
4165
+ key: "siliconflow",
4023
4166
  label: "SiliconFlow",
4024
4167
  keyUrl: "https://cloud.siliconflow.cn/account/ak",
4025
4168
  model: "FunAudioLLM/SenseVoiceSmall",
4169
+ region: "cn",
4026
4170
  kind: "whisper-api",
4027
4171
  base: "https://api.siliconflow.cn/v1"
4028
4172
  },
4029
4173
  {
4030
4174
  id: "glm",
4175
+ key: "glm",
4031
4176
  label: "Zhipu GLM",
4032
4177
  keyUrl: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys",
4033
4178
  model: "glm-asr-2512",
4179
+ region: "cn",
4034
4180
  kind: "whisper-api",
4035
4181
  base: "https://open.bigmodel.cn/api/paas/v4"
4036
4182
  }
@@ -4050,6 +4196,8 @@ function speechServiceOf(id, baseURL) {
4050
4196
  if (/api\.groq\.com|^ groq$/.test(k)) return "groq";
4051
4197
  if (/siliconflow/.test(k)) return "siliconflow";
4052
4198
  if (/bigmodel\.cn|(^|\.)z\.ai|^ (glm|zhipu|zhipuai|zai)$/.test(k)) return "glm";
4199
+ if (/api\.mistral\.ai|^ mistral$/.test(k)) return "mistral";
4200
+ if (/dashscope|maas\.aliyuncs\.com|^ (dashscope|bailian|aliyun|qwen)$/.test(k)) return "dashscope";
4053
4201
  }
4054
4202
  const TIMEOUT = 3e4;
4055
4203
  async function fail(r) {
@@ -4103,6 +4251,172 @@ function whisperApiEngine(svc, model, key) {
4103
4251
  }
4104
4252
  };
4105
4253
  }
4254
+ /** Glossary terms from a recogniser hint ("Glossary: a, b, c. …"), for services that take a word list. */
4255
+ const termsOf = (prompt) => (/Glossary: ([^\n]*?)\.(?:\s|$)/.exec(prompt)?.[1] ?? "").split(", ").map((x) => x.trim()).filter(Boolean);
4256
+ /** Three-letter language codes some services answer with, to the two-letter ones used here. */
4257
+ const ISO3 = {
4258
+ eng: "en",
4259
+ zho: "zh",
4260
+ cmn: "zh",
4261
+ yue: "yue",
4262
+ jpn: "ja",
4263
+ kor: "ko",
4264
+ fra: "fr",
4265
+ deu: "de",
4266
+ spa: "es",
4267
+ por: "pt",
4268
+ rus: "ru",
4269
+ ita: "it",
4270
+ ara: "ar",
4271
+ hin: "hi",
4272
+ vie: "vi",
4273
+ tha: "th",
4274
+ ind: "id"
4275
+ };
4276
+ const lang2 = (l) => {
4277
+ if (!l) return void 0;
4278
+ const k = l.toLowerCase().split(/[-_]/)[0];
4279
+ return ISO3[k] ?? ISO[k] ?? k;
4280
+ };
4281
+ /** Deepgram (Nova-3): one request per chunk, glossary as key terms, language detected per chunk. */
4282
+ function deepgramEngine(model, key) {
4283
+ return {
4284
+ name: "deepgram",
4285
+ async transcribe(wav, lang, prompt) {
4286
+ const apiKey = key();
4287
+ if (!apiKey) throw new Error("no Deepgram key");
4288
+ const q = new URLSearchParams({
4289
+ model,
4290
+ smart_format: "true",
4291
+ punctuate: "true"
4292
+ });
4293
+ if (lang !== "auto") q.set("language", lang);
4294
+ else q.set("detect_language", "true");
4295
+ for (const t of termsOf(prompt ?? "").slice(0, 50)) q.append("keyterm", t);
4296
+ const r = await fetch(`https://api.deepgram.com/v1/listen?${q}`, {
4297
+ method: "POST",
4298
+ headers: {
4299
+ Authorization: `Token ${apiKey}`,
4300
+ "content-type": "audio/wav"
4301
+ },
4302
+ body: new Uint8Array(wav),
4303
+ signal: AbortSignal.timeout(TIMEOUT)
4304
+ });
4305
+ if (!r.ok) await fail(r);
4306
+ const ch = (await r.json()).results?.channels?.[0];
4307
+ return {
4308
+ text: (ch?.alternatives?.[0]?.transcript ?? "").trim(),
4309
+ lang: lang2(ch?.detected_language)
4310
+ };
4311
+ }
4312
+ };
4313
+ }
4314
+ /** ElevenLabs Scribe: multipart upload per chunk; glossary as key terms. */
4315
+ function elevenLabsEngine(model, key) {
4316
+ return {
4317
+ name: "elevenlabs",
4318
+ async transcribe(wav, lang, prompt) {
4319
+ const apiKey = key();
4320
+ if (!apiKey) throw new Error("no ElevenLabs key");
4321
+ const form = new FormData();
4322
+ form.append("file", new Blob([new Uint8Array(wav)], { type: "audio/wav" }), "chunk.wav");
4323
+ form.append("model_id", model);
4324
+ form.append("tag_audio_events", "false");
4325
+ if (lang !== "auto") form.append("language_code", lang);
4326
+ for (const t of termsOf(prompt ?? "").slice(0, 50)) form.append("keyterms", t);
4327
+ const r = await fetch("https://api.elevenlabs.io/v1/speech-to-text", {
4328
+ method: "POST",
4329
+ headers: { "xi-api-key": apiKey },
4330
+ body: form,
4331
+ signal: AbortSignal.timeout(TIMEOUT)
4332
+ });
4333
+ if (!r.ok) await fail(r);
4334
+ const j = await r.json();
4335
+ return {
4336
+ text: (j.text ?? "").replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim(),
4337
+ lang: lang2(j.language_code)
4338
+ };
4339
+ }
4340
+ };
4341
+ }
4342
+ /** Alibaba Cloud Bailian (Qwen3-ASR-Flash), OpenAI-compatible chat call with the chunk inline. */
4343
+ function dashscopeEngine(base, model, key) {
4344
+ return {
4345
+ name: "dashscope",
4346
+ async transcribe(wav, lang) {
4347
+ const apiKey = key();
4348
+ if (!apiKey) throw new Error("no Alibaba Cloud Bailian key");
4349
+ const body = {
4350
+ model,
4351
+ stream: false,
4352
+ messages: [{
4353
+ role: "user",
4354
+ content: [{
4355
+ type: "input_audio",
4356
+ input_audio: { data: `data:audio/wav;base64,${wav.toString("base64")}` }
4357
+ }]
4358
+ }],
4359
+ asr_options: {
4360
+ enable_itn: true,
4361
+ ...lang !== "auto" ? { language: lang } : {}
4362
+ }
4363
+ };
4364
+ const r = await fetch(`${base}/chat/completions`, {
4365
+ method: "POST",
4366
+ headers: {
4367
+ Authorization: `Bearer ${apiKey}`,
4368
+ "content-type": "application/json"
4369
+ },
4370
+ body: JSON.stringify(body),
4371
+ signal: AbortSignal.timeout(TIMEOUT)
4372
+ });
4373
+ if (!r.ok) await fail(r);
4374
+ const m = (await r.json()).choices?.[0]?.message;
4375
+ return {
4376
+ text: (m?.content ?? "").replace(/\s+/g, " ").trim(),
4377
+ lang: lang2(m?.annotations?.[0]?.language)
4378
+ };
4379
+ }
4380
+ };
4381
+ }
4382
+ /** Volcengine Doubao speech (recording recognition, flash edition): one request per chunk, API key from the new speech console. */
4383
+ function volcEngine(resource, key) {
4384
+ return {
4385
+ name: "volc",
4386
+ async transcribe(wav) {
4387
+ const apiKey = key();
4388
+ if (!apiKey) throw new Error("no Volcengine speech key");
4389
+ const body = {
4390
+ user: { uid: "dsh-vidi" },
4391
+ audio: { data: wav.toString("base64") },
4392
+ request: {
4393
+ model_name: "bigmodel",
4394
+ enable_itn: true,
4395
+ enable_punc: true
4396
+ }
4397
+ };
4398
+ const r = await fetch("https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash", {
4399
+ method: "POST",
4400
+ headers: {
4401
+ "X-Api-Key": apiKey,
4402
+ "X-Api-Resource-Id": resource,
4403
+ "X-Api-Request-Id": crypto.randomUUID(),
4404
+ "X-Api-Sequence": "-1",
4405
+ "content-type": "application/json"
4406
+ },
4407
+ body: JSON.stringify(body),
4408
+ signal: AbortSignal.timeout(TIMEOUT)
4409
+ });
4410
+ const code = r.headers.get("X-Api-Status-Code");
4411
+ if (code === "20000003") return { text: "" };
4412
+ if (!r.ok || code && code !== "20000000") {
4413
+ const msg = r.headers.get("X-Api-Message") ?? (await r.text().catch(() => "")).slice(0, 200);
4414
+ throw new Error(`Volcengine ${code ?? r.status}: ${msg}`);
4415
+ }
4416
+ return { text: ((await r.json()).result?.text ?? "").trim() };
4417
+ }
4418
+ };
4419
+ }
4106
4420
  /** Newest Gemini Flash model the key can use (when the setting says "auto"). */
4107
4421
  async function pickGeminiModel(apiKey) {
4108
4422
  const r = await fetch("https://generativelanguage.googleapis.com/v1beta/models?pageSize=200", {
@@ -4110,7 +4424,10 @@ async function pickGeminiModel(apiKey) {
4110
4424
  signal: AbortSignal.timeout(TIMEOUT)
4111
4425
  });
4112
4426
  if (!r.ok) await fail(r);
4113
- const flash = ((await r.json()).models ?? []).filter((m) => m.supportedGenerationMethods?.includes("generateContent")).map((m) => m.name.replace(/^models\//, "")).filter((n) => /^gemini-\d+(\.\d+)?-flash(-preview)?$/.test(n));
4427
+ const j = await r.json();
4428
+ const transcribe = (j.models ?? []).map((m) => m.name.replace(/^models\//, "")).filter((n) => /^gemini-\d+(\.\d+)?-transcribe$/.test(n)).sort((a, b) => Number(/\d+(\.\d+)?/.exec(b)[0]) - Number(/\d+(\.\d+)?/.exec(a)[0]));
4429
+ if (transcribe[0]) return transcribe[0];
4430
+ const flash = (j.models ?? []).filter((m) => m.supportedGenerationMethods?.includes("generateContent")).map((m) => m.name.replace(/^models\//, "")).filter((n) => /^gemini-\d+(\.\d+)?-flash(-preview)?$/.test(n));
4114
4431
  const version = (n) => Number(/^gemini-(\d+(?:\.\d+)?)/.exec(n)[1]);
4115
4432
  flash.sort((a, b) => version(b) - version(a) || (a.includes("preview") ? 1 : 0) - (b.includes("preview") ? 1 : 0));
4116
4433
  return flash[0] ?? "gemini-2.5-flash";
@@ -4137,6 +4454,7 @@ function geminiEngine(model, key, onUsage, log) {
4137
4454
  async transcribe(wav, lang, prompt) {
4138
4455
  const apiKey = key();
4139
4456
  if (!apiKey) throw new Error("no Google Gemini key");
4457
+ if (/-transcribe$/.test(chosen)) return transcribeModel(apiKey, chosen, wav, lang, prompt ?? "", onUsage);
4140
4458
  const instruction = [
4141
4459
  "Transcribe the speech in this audio exactly as spoken, in the language spoken (do not translate).",
4142
4460
  lang !== "auto" ? `The speaker uses language code "${lang}".` : "",
@@ -4198,20 +4516,95 @@ function geminiEngine(model, key, onUsage, log) {
4198
4516
  }
4199
4517
  };
4200
4518
  }
4519
+ const BCP47 = {
4520
+ zh: "zh-CN",
4521
+ en: "en-US",
4522
+ ja: "ja-JP",
4523
+ ko: "ko-KR",
4524
+ fr: "fr-FR",
4525
+ de: "de-DE",
4526
+ es: "es-ES",
4527
+ pt: "pt-BR",
4528
+ ru: "ru-RU",
4529
+ it: "it-IT"
4530
+ };
4531
+ /** Gemini's dedicated transcription model (Interactions API): verbatim text, glossary as custom vocabulary. */
4532
+ async function transcribeModel(apiKey, model, wav, lang, prompt, onUsage) {
4533
+ const vocab = termsOf(prompt).slice(0, 100);
4534
+ const body = {
4535
+ model: `models/${model}`,
4536
+ input: [{
4537
+ type: "audio",
4538
+ data: wav.toString("base64"),
4539
+ mime_type: "audio/wav"
4540
+ }],
4541
+ generation_config: { transcription_config: {
4542
+ ...lang !== "auto" ? { language_codes: [BCP47[lang] ?? lang] } : {},
4543
+ ...vocab.length ? { custom_vocabulary: vocab } : {},
4544
+ mode: { type: "verbatim" }
4545
+ } }
4546
+ };
4547
+ const r = await fetch("https://generativelanguage.googleapis.com/v1beta/interactions", {
4548
+ method: "POST",
4549
+ headers: {
4550
+ "x-goog-api-key": apiKey,
4551
+ "content-type": "application/json"
4552
+ },
4553
+ body: JSON.stringify(body),
4554
+ signal: AbortSignal.timeout(TIMEOUT)
4555
+ });
4556
+ if (!r.ok) await fail(r);
4557
+ const j = await r.json();
4558
+ if (j.usage) onUsage(model, {
4559
+ inputTokens: j.usage.total_input_tokens,
4560
+ outputTokens: j.usage.total_output_tokens
4561
+ });
4562
+ const clean = (j.steps ?? []).filter((st) => st.type === "model_output").flatMap((st) => st.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("").replace(/\s+/g, " ").trim();
4563
+ return {
4564
+ text: clean,
4565
+ lang: lang !== "auto" ? lang : scriptLang(clean)
4566
+ };
4567
+ }
4568
+ /** The language a transcript is written in, when its script says so (kana, hangul, Han); else unknown. */
4569
+ function scriptLang(text) {
4570
+ const n = (re) => (text.match(re) ?? []).length;
4571
+ const han = n(/\p{Script=Han}/gu), kana = n(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu), hangul = n(/\p{Script=Hangul}/gu);
4572
+ const letters = n(/\p{L}/gu);
4573
+ if (!letters) return void 0;
4574
+ if (kana > letters * .1) return "ja";
4575
+ if (hangul > letters * .3) return "ko";
4576
+ if (han > letters * .3) return "zh";
4577
+ }
4201
4578
  /** Check a key before saving it: a cheap authenticated read on the service. */
4202
4579
  async function verifyKey(svc, key) {
4203
4580
  try {
4204
- const url = svc.kind === "gemini" ? "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1" : svc.id === "openai" ? "https://api.openai.com/v1/models" : svc.id === "glm" ? null : `${svc.base}/models`;
4205
- if (!url) return { ok: true };
4206
- const r = await fetch(url, {
4207
- headers: svc.kind === "gemini" ? { "x-goog-api-key": key } : { Authorization: `Bearer ${key}` },
4581
+ const probe = svc.kind === "gemini" ? {
4582
+ url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
4583
+ headers: { "x-goog-api-key": key }
4584
+ } : svc.key === "openai" ? {
4585
+ url: "https://api.openai.com/v1/models",
4586
+ headers: { Authorization: `Bearer ${key}` }
4587
+ } : svc.kind === "deepgram" ? {
4588
+ url: "https://api.deepgram.com/v1/projects",
4589
+ headers: { Authorization: `Token ${key}` }
4590
+ } : svc.kind === "elevenlabs" ? {
4591
+ url: "https://api.elevenlabs.io/v1/models",
4592
+ headers: { "xi-api-key": key }
4593
+ } : svc.id === "glm" || svc.kind === "volc" ? null : {
4594
+ url: `${svc.base}/models`,
4595
+ headers: { Authorization: `Bearer ${key}` }
4596
+ };
4597
+ if (!probe) return { ok: true };
4598
+ const r = await fetch(probe.url, {
4599
+ headers: probe.headers,
4208
4600
  signal: AbortSignal.timeout(1e4)
4209
4601
  });
4210
4602
  if (r.ok) return { ok: true };
4211
4603
  const body = await r.json().catch(() => ({}));
4604
+ const detail = typeof body.detail === "object" ? body.detail?.message : body.detail;
4212
4605
  return {
4213
4606
  ok: false,
4214
- message: (typeof body.error === "object" ? body.error?.message : body.error) ?? body.message ?? `HTTP ${r.status}`
4607
+ message: (typeof body.error === "object" ? body.error?.message : body.error) ?? body.message ?? detail ?? body.err_msg ?? `HTTP ${r.status}`
4215
4608
  };
4216
4609
  } catch (err) {
4217
4610
  return {
@@ -4220,6 +4613,185 @@ async function verifyKey(svc, key) {
4220
4613
  };
4221
4614
  }
4222
4615
  }
4616
+ // src/providers/openai-translate.ts
4617
+ /** Output languages gpt-realtime-translate speaks (the input can be any of 70+). */
4618
+ const TRANSLATE_TARGETS = [
4619
+ "es",
4620
+ "pt",
4621
+ "fr",
4622
+ "ja",
4623
+ "ru",
4624
+ "zh",
4625
+ "de",
4626
+ "ko",
4627
+ "hi",
4628
+ "id",
4629
+ "vi",
4630
+ "it",
4631
+ "en"
4632
+ ];
4633
+ /**
4634
+ * 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.
4639
+ */
4640
+ var OpenAITranslateProvider = class {
4641
+ opts;
4642
+ name = "openai-translate";
4643
+ translates = true;
4644
+ ws;
4645
+ chunker;
4646
+ up = new Upsampler();
4647
+ emit;
4648
+ onError;
4649
+ stopped = false;
4650
+ reconnects = 0;
4651
+ /** Text of the line being collected (source transcript and translation). */
4652
+ src = "";
4653
+ tr = "";
4654
+ lineStart = 0;
4655
+ seq = 0;
4656
+ /** A finished stretch of audio whose text may still be arriving. */
4657
+ closing;
4658
+ constructor(opts) {
4659
+ this.opts = opts;
4660
+ 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));
4662
+ }
4663
+ async start(emit, onError) {
4664
+ this.emit = emit;
4665
+ this.onError = onError;
4666
+ await this.connect();
4667
+ }
4668
+ connect() {
4669
+ return new Promise((resolve, reject) => {
4670
+ const url = this.opts.url ?? `wss://api.openai.com/v1/realtime/translations?model=${encodeURIComponent(this.opts.model)}`;
4671
+ const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${this.opts.apiKey}` } });
4672
+ this.ws = ws;
4673
+ let opened = false;
4674
+ ws.onopen = () => {
4675
+ opened = true;
4676
+ this.reconnects = 0;
4677
+ this.send({
4678
+ type: "session.update",
4679
+ session: { audio: {
4680
+ input: {
4681
+ transcription: { model: "gpt-realtime-whisper" },
4682
+ noise_reduction: { type: "far_field" }
4683
+ },
4684
+ output: { language: this.opts.target }
4685
+ } }
4686
+ });
4687
+ resolve();
4688
+ };
4689
+ ws.onmessage = (ev) => {
4690
+ try {
4691
+ this.onEvent(JSON.parse(String(ev.data)));
4692
+ } catch {}
4693
+ };
4694
+ ws.onerror = () => {
4695
+ if (!opened) reject(/* @__PURE__ */ new Error("could not reach OpenAI live translation (check the API key and network)"));
4696
+ };
4697
+ ws.onclose = (ev) => {
4698
+ if (!opened || this.stopped || this.ws !== ws) return;
4699
+ this.opts.log?.(`openai-translate: connection closed (${ev.code} ${ev.reason || ""}), reconnecting`);
4700
+ if (++this.reconnects > 5) {
4701
+ this.onError?.(/* @__PURE__ */ new Error("OpenAI live translation keeps dropping"));
4702
+ return;
4703
+ }
4704
+ setTimeout(() => {
4705
+ if (!this.stopped) this.connect().catch((err) => this.onError?.(err));
4706
+ }, 1e3 * this.reconnects);
4707
+ };
4708
+ });
4709
+ }
4710
+ send(event) {
4711
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(event));
4712
+ }
4713
+ push(pcm) {
4714
+ if (this.stopped) return;
4715
+ this.send({
4716
+ type: "session.input_audio_buffer.append",
4717
+ audio: this.up.push(Buffer.from(pcm)).toString("base64")
4718
+ });
4719
+ this.chunker.push(pcm);
4720
+ }
4721
+ backlog() {
4722
+ return this.closing ? this.chunker.now - this.closing.start : 0;
4723
+ }
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
+ });
4734
+ }
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;
4741
+ }
4742
+ this.lineStart = start;
4743
+ this.closing = {
4744
+ start,
4745
+ end,
4746
+ timer: setTimeout(() => this.finish(), 1500)
4747
+ };
4748
+ }
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?.({
4756
+ 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
4762
+ });
4763
+ this.seq++;
4764
+ this.src = "";
4765
+ this.tr = "";
4766
+ this.lineStart = c.end;
4767
+ }
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
+ }
4784
+ }
4785
+ async stop() {
4786
+ this.chunker.flush(.5);
4787
+ await new Promise((r) => setTimeout(r, 1500));
4788
+ this.finish();
4789
+ this.stopped = true;
4790
+ try {
4791
+ this.ws?.close();
4792
+ } catch {}
4793
+ }
4794
+ };
4223
4795
  // src/tools.ts
4224
4796
  const CLOCK = (s) => {
4225
4797
  const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, ss = Math.floor(s % 60);
@@ -4758,7 +5330,7 @@ function apply(ctx, config) {
4758
5330
  ].includes(x.name)).every((x) => x.ok);
4759
5331
  });
4760
5332
  /** Recognition to use when nothing was chosen: this computer if it can, else the first online service with a key. */
4761
- const defaultEngine = () => localReady ? "local" : SERVICES.find((s) => keys.get(s.id))?.id ?? "local";
5333
+ const defaultEngine = () => localReady ? "local" : SERVICES.find((s) => keys.get(s.key))?.id ?? "local";
4762
5334
  const settingsFile = join(config.dataDir, "settings.json");
4763
5335
  const prefs = () => {
4764
5336
  try {
@@ -4792,7 +5364,7 @@ function apply(ctx, config) {
4792
5364
  function makeProvider(opts, glossary, usage, job) {
4793
5365
  const svc = service(opts.provider);
4794
5366
  if (svc) {
4795
- if (!keys.get(svc.id)) throw new Error(`no ${svc.label} key yet: add one in the video page settings or on the DSH Models page`);
5367
+ 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`);
4796
5368
  if (svc.kind === "realtime") return new OpenAIProvider({
4797
5369
  apiKey: keys.get("openai"),
4798
5370
  model: config.openaiModel,
@@ -4804,11 +5376,24 @@ function apply(ctx, config) {
4804
5376
  log,
4805
5377
  onUsage: (u) => usage.add("openai", config.openaiModel, u)
4806
5378
  });
4807
- const key = () => keys.get(svc.id);
5379
+ if (svc.kind === "translate") {
5380
+ const target = opts.tgtLangs[0];
5381
+ if (!target) throw new Error("OpenAI live translation needs a subtitle language to translate into");
5382
+ return new OpenAITranslateProvider({
5383
+ apiKey: keys.get("openai"),
5384
+ model: svc.model,
5385
+ target,
5386
+ minSec: 2,
5387
+ maxSec: 8,
5388
+ log
5389
+ });
5390
+ }
5391
+ const key = () => keys.get(svc.key);
5392
+ const base = keys.bases.get(svc.key) ?? svc.base;
4808
5393
  return new LocalProvider({
4809
- engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : whisperApiEngine({
5394
+ engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : svc.kind === "deepgram" ? deepgramEngine(svc.model, key) : svc.kind === "elevenlabs" ? elevenLabsEngine(svc.model, key) : svc.kind === "dashscope" ? dashscopeEngine(base, svc.model, key) : svc.kind === "volc" ? volcEngine(svc.model, key) : whisperApiEngine({
4810
5395
  ...svc,
4811
- base: keys.bases.get(svc.id) ?? svc.base
5396
+ base
4812
5397
  }, svc.model, key),
4813
5398
  lang: opts.srcLang === "auto" ? "auto" : opts.srcLang,
4814
5399
  minSec: 3,
@@ -4979,14 +5564,19 @@ function apply(ctx, config) {
4979
5564
  dataDir: config.dataDir,
4980
5565
  providers: [{
4981
5566
  id: "local",
5567
+ vendor: "local",
5568
+ model: "whisper-large-v3-turbo",
4982
5569
  online: false,
4983
5570
  ready: localReady
4984
5571
  }, ...SERVICES.map((s) => ({
4985
5572
  id: s.id,
5573
+ vendor: s.key,
5574
+ model: s.modelLabel ?? s.model,
4986
5575
  label: s.label,
4987
5576
  online: true,
4988
- ready: keys.info(s.id).set,
4989
- key: keys.info(s.id),
5577
+ region: s.region,
5578
+ ready: keys.info(s.key).set,
5579
+ key: keys.info(s.key),
4990
5580
  keyUrl: s.keyUrl
4991
5581
  }))],
4992
5582
  defaultEngine: defaultEngine(),
@@ -5003,10 +5593,10 @@ function apply(ctx, config) {
5003
5593
  const check = await verifyKey(svc, key);
5004
5594
  if (!check.ok) return send(res, 400, { error: check.message ?? "key rejected" });
5005
5595
  }
5006
- await keys.save(svc.id, key);
5007
- return send(res, 200, { key: keys.info(svc.id) });
5596
+ await keys.save(svc.key, key);
5597
+ return send(res, 200, { key: keys.info(svc.key) });
5008
5598
  }
5009
- const key = keys.get(svc.id);
5599
+ const key = keys.get(svc.key);
5010
5600
  if (!key) return send(res, 400, { error: "no key" });
5011
5601
  const check = await verifyKey(svc, key);
5012
5602
  return send(res, check.ok ? 200 : 400, check.ok ? { ok: true } : { error: check.message });
@@ -5123,7 +5713,7 @@ function apply(ctx, config) {
5123
5713
  const svc = opts.provider ? service(opts.provider) : void 0;
5124
5714
  if (svc) {
5125
5715
  await keys.refresh();
5126
- if (!keys.get(svc.id)) return send(res, 400, { error: `no ${svc.label} key` });
5716
+ if (!keys.get(svc.key)) return send(res, 400, { error: `no ${svc.label} key` });
5127
5717
  }
5128
5718
  savePrefs({ last: {
5129
5719
  ...lastOptions(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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",