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.
- package/lib/client.js +181 -30
- package/lib/index.mjs +89 -16
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
36516
|
-
"
|
|
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
|
-
"
|
|
36851
|
-
"
|
|
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.
|
|
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",
|