dsh-vidi 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +181 -30
  2. package/lib/index.mjs +89 -16
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1603,7 +1603,7 @@ function fragmentsAreEqual(frag, otherFrag) {
1603
1603
  /**
1604
1604
  * Object representing parsed data from an HLS Segment. Found in {@link hls.js#LevelDetails.fragments}.
1605
1605
  */
1606
- var Fragment = class extends BaseSegment {
1606
+ var Fragment$1 = class extends BaseSegment {
1607
1607
  constructor(type, base) {
1608
1608
  super(base);
1609
1609
  this._decryptdata = null;
@@ -9753,7 +9753,7 @@ var M3U8Parser = class M3U8Parser {
9753
9753
  let discontinuityCounter = 0;
9754
9754
  let currentBitrate = 0;
9755
9755
  let prevFrag = null;
9756
- let frag = new Fragment(type, base);
9756
+ let frag = new Fragment$1(type, base);
9757
9757
  let result;
9758
9758
  let i;
9759
9759
  let levelkeys;
@@ -9771,7 +9771,7 @@ var M3U8Parser = class M3U8Parser {
9771
9771
  while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) {
9772
9772
  if (createNextFrag) {
9773
9773
  createNextFrag = false;
9774
- frag = new Fragment(type, base);
9774
+ frag = new Fragment$1(type, base);
9775
9775
  frag.playlistOffset = totalduration;
9776
9776
  frag.setStart(totalduration);
9777
9777
  frag.sn = currentSN;
@@ -9819,7 +9819,7 @@ var M3U8Parser = class M3U8Parser {
9819
9819
  const byteRange = frag.byteRange;
9820
9820
  if (byteRange.length === 2) frag.bitrate = (byteRange[1] - byteRange[0]) * 8 / frag.duration | 0;
9821
9821
  if (level.iframesOnly && byteRange[0] && ((_currentInitSegment = currentInitSegment) == null ? void 0 : _currentInitSegment.cc) !== discontinuityCounter) {
9822
- const init = new Fragment(type, base);
9822
+ const init = new Fragment$1(type, base);
9823
9823
  init.relurl = frag.relurl;
9824
9824
  init.setByteRange(`${Math.min(byteRange[0], 1316)}@0`);
9825
9825
  init.level = id;
@@ -9953,7 +9953,7 @@ var M3U8Parser = class M3U8Parser {
9953
9953
  case "MAP": {
9954
9954
  const mapAttrs = new AttrList(value1, level);
9955
9955
  if (frag.duration) {
9956
- const init = new Fragment(type, base);
9956
+ const init = new Fragment$1(type, base);
9957
9957
  setInitSegment(init, mapAttrs, id, levelkeys);
9958
9958
  currentInitSegment = init;
9959
9959
  frag.initSegment = currentInitSegment;
@@ -33343,6 +33343,95 @@ var Hls = class Hls {
33343
33343
  };
33344
33344
  Hls.defaultConfig = void 0;
33345
33345
 
33346
+ // src/wrap.ts
33347
+ /**
33348
+ * Line breaking for subtitles drawn on a canvas (burned into clips and
33349
+ * restreams, screenshots): break where the text allows, never start a line
33350
+ * with closing punctuation, and spread the text evenly over the lines it
33351
+ * needs — so a long line wraps as two halves instead of a full line plus one
33352
+ * orphaned character.
33353
+ */
33354
+ /** Characters that may not begin a line (they stay with the text before them). */
33355
+ const NO_START = new Set(Array.from(",。、;:!?)》」』】〕〉”’…—,.;:!?)]}%"));
33356
+ /** Where a sentence pauses: breaking right after these reads best. */
33357
+ const PAUSE = ",。、;:!?,;:!? ";
33358
+ /** Greedy breaking at `width`: Latin text at spaces, CJK between any two characters. */
33359
+ function greedy(chars, width, measure) {
33360
+ const lines = [];
33361
+ let cur = "";
33362
+ for (const ch of chars) {
33363
+ const next = cur + ch;
33364
+ if (cur && measure(next) > width && !NO_START.has(ch)) {
33365
+ const sp = cur.lastIndexOf(" ");
33366
+ const pause = Math.max(...Array.from(PAUSE).map((p) => cur.lastIndexOf(p)));
33367
+ if (/[A-Za-z0-9]/.test(ch) && /[A-Za-z0-9]$/.test(cur) && sp > cur.length * .3) {
33368
+ lines.push(cur.slice(0, sp));
33369
+ cur = cur.slice(sp + 1) + ch;
33370
+ } else if (pause > cur.length * .6 && pause < cur.length - 1) {
33371
+ lines.push(cur.slice(0, pause + 1).trimEnd());
33372
+ cur = cur.slice(pause + 1).trimStart() + ch;
33373
+ } else {
33374
+ lines.push(cur.trimEnd());
33375
+ cur = ch.trimStart();
33376
+ }
33377
+ } else cur = next;
33378
+ }
33379
+ if (cur.trim()) lines.push(cur.trimEnd());
33380
+ return lines;
33381
+ }
33382
+ /** Wrap `text` to at most `maxWidth`, using as few lines as possible, each about the same length. */
33383
+ function wrapBalanced(text, maxWidth, measure) {
33384
+ const chars = Array.from(text.trim());
33385
+ const first = greedy(chars, maxWidth, measure);
33386
+ if (first.length < 2) return first;
33387
+ let lo = measure(text) / first.length * .9, hi = maxWidth, best = first;
33388
+ for (let i = 0; i < 14 && hi - lo > 1; i++) {
33389
+ const mid = (lo + hi) / 2;
33390
+ const lines = greedy(chars, mid, measure);
33391
+ if (lines.length <= first.length) {
33392
+ best = lines;
33393
+ hi = mid;
33394
+ } else lo = mid;
33395
+ }
33396
+ return atPauses(best, maxWidth, measure);
33397
+ }
33398
+ /** Move each break to a nearby pause (within 4 characters) when both lines still fit. */
33399
+ function atPauses(lines, maxWidth, measure) {
33400
+ const out = lines.map((l) => Array.from(l));
33401
+ for (let k = 0; k + 1 < out.length; k++) {
33402
+ const a = out[k], b = out[k + 1];
33403
+ if (PAUSE.includes(a.at(-1) ?? "")) continue;
33404
+ const sep = /[\x21-\x7e]$/.test(a.join("")) && /^[\x21-\x7e]/.test(b.join("")) ? [" "] : [];
33405
+ let pick;
33406
+ for (let d = 1; d <= 4 && pick === void 0; d++) for (const shift of [d, -d]) {
33407
+ const cut = a.length + shift;
33408
+ if (cut <= 0) continue;
33409
+ const joined = [
33410
+ ...a,
33411
+ ...sep,
33412
+ ...b
33413
+ ];
33414
+ if (cut >= joined.length) continue;
33415
+ if (!PAUSE.includes(joined[cut - 1])) continue;
33416
+ const na = joined.slice(0, cut).join("").trimEnd(), nb = joined.slice(cut).join("").trimStart();
33417
+ if (nb && measure(na) <= maxWidth && measure(nb) <= maxWidth) {
33418
+ pick = shift;
33419
+ break;
33420
+ }
33421
+ }
33422
+ if (pick !== void 0) {
33423
+ const joined = [
33424
+ ...a,
33425
+ ...sep,
33426
+ ...b
33427
+ ], cut = a.length + pick;
33428
+ out[k] = Array.from(joined.slice(0, cut).join("").trimEnd());
33429
+ out[k + 1] = Array.from(joined.slice(cut).join("").trimStart());
33430
+ }
33431
+ }
33432
+ return out.map((l) => l.join(""));
33433
+ }
33434
+
33346
33435
  // src/client/subStyle.tsx
33347
33436
  const STORE = "dsh-live.subStyle";
33348
33437
  /** First line is the main one (largest); the original, when not first, is the smallest. */
@@ -33400,11 +33489,29 @@ function visibleRows(texts, style) {
33400
33489
  return true;
33401
33490
  });
33402
33491
  }
33492
+ let measurer;
33403
33493
  function SubtitleOverlay({ texts, style, draft }) {
33404
33494
  const rows = visibleRows(texts, style);
33405
- if (!rows.length) return null;
33495
+ const box = (0, react.useRef)(null);
33496
+ const [width, setWidth] = (0, react.useState)(0);
33497
+ (0, react.useLayoutEffect)(() => {
33498
+ const el = box.current;
33499
+ if (!el) return;
33500
+ const ro = new ResizeObserver(() => setWidth(el.clientWidth));
33501
+ ro.observe(el);
33502
+ setWidth(el.clientWidth);
33503
+ return () => ro.disconnect();
33504
+ }, []);
33505
+ const lines = (text, size, weight) => {
33506
+ if (measurer === void 0) measurer = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
33507
+ if (!width || !measurer || !box.current) return [text];
33508
+ const px = size / 100 * (width / .92);
33509
+ measurer.font = `${weight} ${px}px ${getComputedStyle(box.current).fontFamily}`;
33510
+ return wrapBalanced(text, (width - px * .9) * .97, (t) => measurer.measureText(t).width);
33511
+ };
33406
33512
  const edge = style.position === "top" ? { top: `${style.offset}%` } : { bottom: `${style.offset}%` };
33407
33513
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
33514
+ ref: box,
33408
33515
  className: "dshl-sub",
33409
33516
  "data-pos": style.position,
33410
33517
  style: {
@@ -33414,7 +33521,7 @@ function SubtitleOverlay({ texts, style, draft }) {
33414
33521
  children: rows.map((l, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33415
33522
  className: `${i === 0 ? "tgt" : "src"} ${l.key === "src" && draft ? "draft" : ""} ${l.key.startsWith("site:") ? "site" : ""}`,
33416
33523
  style: { fontSize: `${l.size}cqw` },
33417
- children: texts[l.key]
33524
+ 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
33525
  }) }, l.key))
33419
33526
  });
33420
33527
  }
@@ -33429,14 +33536,7 @@ function drawSubtitles(g, W, H, texts, style) {
33429
33536
  const blocks = rows.map((l, i) => {
33430
33537
  const px = l.size / 100 * W;
33431
33538
  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);
33539
+ const lines = wrapBalanced(String(texts[l.key]), W * .88, (t) => g.measureText(t).width);
33440
33540
  return {
33441
33541
  px,
33442
33542
  weight: i === 0 ? 600 : 400,
@@ -33525,11 +33625,31 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33525
33625
  const idleTimer = (0, react.useRef)();
33526
33626
  /** Where playback was, so a new source (quality switch) continues from there. */
33527
33627
  const lastTime = (0, react.useRef)(0);
33628
+ const [held, setHeld] = (0, react.useState)(null);
33629
+ const heldRef = (0, react.useRef)(false);
33528
33630
  (0, react.useImperativeHandle)(ref, () => ({
33529
33631
  seek: (time) => {
33530
33632
  if (video.current) video.current.currentTime = Math.max(0, time);
33531
33633
  },
33532
- closeMenu: () => setMenu(null)
33634
+ closeMenu: () => setMenu(null),
33635
+ hold: (text, onSkip) => {
33636
+ const el = video.current;
33637
+ if (!el) return;
33638
+ if (text) {
33639
+ if (!heldRef.current) {
33640
+ heldRef.current = true;
33641
+ el.pause();
33642
+ }
33643
+ setHeld((h) => h?.text === text ? h : {
33644
+ text,
33645
+ skip: onSkip
33646
+ });
33647
+ } else if (heldRef.current) {
33648
+ heldRef.current = false;
33649
+ setHeld(null);
33650
+ el.play().catch(() => {});
33651
+ }
33652
+ }
33533
33653
  }), []);
33534
33654
  (0, react.useEffect)(() => {
33535
33655
  const el = video.current;
@@ -33895,6 +34015,18 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33895
34015
  className: "dshl-overlay",
33896
34016
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "dshl-spinner" }), waitingText]
33897
34017
  }),
34018
+ ready && held && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34019
+ className: "dshl-holdnote",
34020
+ children: [
34021
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { className: "dshl-spinner sm" }),
34022
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: held.text }),
34023
+ held.skip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34024
+ className: "dshl-link",
34025
+ onClick: () => held.skip?.(),
34026
+ children: t("hold.skip")
34027
+ })
34028
+ ]
34029
+ }),
33898
34030
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SubtitleOverlay, {
33899
34031
  texts,
33900
34032
  style,
@@ -35055,6 +35187,27 @@ function LiveView(props) {
35055
35187
  state: "off",
35056
35188
  at: 0
35057
35189
  };
35190
+ const [noWait, setNoWait] = (0, react.useState)(false);
35191
+ (0, react.useEffect)(() => {
35192
+ setNoWait(false);
35193
+ }, [mode, job?.id]);
35194
+ const lastPolished = (0, react.useMemo)(() => cues.reduce((m, c) => c.polished && c.end > m ? c.end : m, 0), [cues]);
35195
+ (0, react.useEffect)(() => {
35196
+ const p = player.current;
35197
+ if (!p) return;
35198
+ const waiting = !!job && job.status === "running" && !job.vod && mode !== "site" && subs.state === "running" && !noWait;
35199
+ const ready = mode === "polished" ? Math.max(lastPolished, subs.at - 150) : subs.at;
35200
+ if (waiting && now > ready + .5) p.hold(t(mode === "polished" ? "hold.polished" : "hold.realtime"), () => setNoWait(true));
35201
+ else if (!waiting || ready > now + (mode === "polished" ? 20 : 4)) p.hold(null);
35202
+ }, [
35203
+ now,
35204
+ subs.at,
35205
+ subs.state,
35206
+ lastPolished,
35207
+ mode,
35208
+ noWait,
35209
+ job?.status
35210
+ ]);
35058
35211
  (0, react.useEffect)(() => {
35059
35212
  if (!job?.title || !props.onTitle) return;
35060
35213
  const key = `dsh-live.named.${sessionId}`;
@@ -35548,9 +35701,7 @@ function LiveView(props) {
35548
35701
  onTerms: setTerms
35549
35702
  })
35550
35703
  ] });
35551
- const live = !!job && job.status === "running" && !job.vod;
35552
- const slow = live && mode !== "site" && rate !== null && rate < .95;
35553
- const behindBy = live && mode !== "site" && subs.state === "running" && cues.length > 0 ? now - subs.at : 0;
35704
+ const slow = !!job && job.status === "running" && !job.vod && mode !== "site" && rate !== null && rate < .95;
35554
35705
  const engineNow = providers.find((x) => x.id === usableEngine());
35555
35706
  const liveNote = siteOnly && job && job.status !== "resolving" && !job.site?.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35556
35707
  className: "dshl-lag",
@@ -35566,13 +35717,6 @@ function LiveView(props) {
35566
35717
  onClick: () => setSettingsOpen(true),
35567
35718
  children: t("lag.settings")
35568
35719
  })]
35569
- }) : behindBy > 6 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35570
- className: "dshl-lag",
35571
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("lag.behind").replace("{t}", CLOCK(behindBy)) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35572
- className: "dshl-link",
35573
- onClick: () => player.current?.seek(Math.max(0, subs.at - 2)),
35574
- children: t("lag.wait")
35575
- })]
35576
35720
  }) : null;
35577
35721
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35578
35722
  className: `dshl ${theater && !activeBench ? "theater" : ""} ${pageMode ? "page" : ""} ${activeBench ? "bench" : ""}`,
@@ -36512,8 +36656,9 @@ const en = {
36512
36656
  "mode.polished.tip": "1–2 minutes behind: misheard words fixed with context and the picture — the most accurate (default)",
36513
36657
  "site.none": "This video has no subtitles of its own.",
36514
36658
  "site.switch": "Switch to Polished",
36515
- "lag.behind": "Subtitles are {t} behind the picture.",
36516
- "lag.wait": "Wait for them",
36659
+ "hold.realtime": "Waiting for subtitles…",
36660
+ "hold.polished": "Waiting for polished subtitles…",
36661
+ "hold.skip": "Don’t wait",
36517
36662
  "lag.slow": "Transcription runs slower than the stream ({x}×), so subtitles fall further behind.",
36518
36663
  "lag.slowLocal": "This computer transcribes slower than the stream ({x}×), so subtitles fall further behind. An online service keeps up.",
36519
36664
  "lag.settings": "Choose one",
@@ -36847,8 +36992,9 @@ const zh = {
36847
36992
  "mode.polished.tip": "晚 1–2 分钟:结合上下文和画面改正听错的词,字幕最准(默认)",
36848
36993
  "site.none": "这个视频没有网站自带的字幕。",
36849
36994
  "site.switch": "换成精修",
36850
- "lag.behind": "字幕比画面慢了 {t}。",
36851
- "lag.wait": "等等字幕",
36995
+ "hold.realtime": "等字幕…",
36996
+ "hold.polished": "等精修字幕…",
36997
+ "hold.skip": "不等了",
36852
36998
  "lag.slow": "转写比直播慢({x} 倍速),字幕会越落越远。",
36853
36999
  "lag.slowLocal": "本机转写比直播慢({x} 倍速),字幕会越落越远。换成在线转写就能跟上。",
36854
37000
  "lag.settings": "去选一个",
@@ -37326,6 +37472,9 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37326
37472
  .dshl-stage.pagefull { position: fixed; inset: 0; z-index: 2147483000; width: 100vw; height: 100vh; aspect-ratio: auto; border-radius: 0; }
37327
37473
  .dshl-stage video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
37328
37474
  .dshl-stage.idle { cursor: none; }
37475
+ .dshl-holdnote { position: absolute; top: 14px; left: 50%; z-index: 4; display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; transform: translateX(-50%);
37476
+ background: rgba(20,20,22,.78); color: #f2f2f3; font-size: 12.5px; white-space: nowrap; backdrop-filter: blur(8px); }
37477
+ .dshl-holdnote .dshl-link { color: #9ec0ff; }
37329
37478
  .dshl-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: #cfcfcf; font-size: 14px; }
37330
37479
  .dshl-spinner { width: 28px; height: 28px; border: 2.5px solid rgba(255,255,255,.18); border-top-color: #fff; border-radius: 50%; animation: dshl-spin .9s linear infinite; }
37331
37480
  .dshl-spinner.sm { width: 14px; height: 14px; border-width: 2px; flex: none; }
@@ -37340,6 +37489,8 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
37340
37489
  .dshl-sub .src { color: #e8e8e8; }
37341
37490
  .dshl-sub .draft { color: #b8b8b8; font-style: italic; }
37342
37491
  .dshl-sub div + div { margin-top: .3em; }
37492
+ /* A line that wraps is split into even halves, not a full line and one orphaned character. */
37493
+ .dshl-sub div { line-break: strict; }
37343
37494
  .dshl-stage:not(.idle) .dshl-sub[data-pos=bottom] { transform: translate(-50%, -64px); }
37344
37495
  .dshl-stage:not(.idle) .dshl-sub[data-pos=top] { transform: translate(-50%, 34px); }
37345
37496
 
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",