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