dsh-vidi 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +77 -0
- package/README.md +6 -9
- package/README.zh-CN.md +6 -9
- package/icon.svg +12 -0
- package/lib/client.js +542 -36
- package/lib/index.mjs +688 -11
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +28 -6
package/lib/index.mjs
CHANGED
|
@@ -1088,6 +1088,504 @@ function findOriginal(dir) {
|
|
|
1088
1088
|
const f = readdirSync(dir).find((n) => /^original\.(mp4|mkv|webm|m4a|mp3)$/.test(n));
|
|
1089
1089
|
return f ? join(dir, f) : void 0;
|
|
1090
1090
|
}
|
|
1091
|
+
// src/restream.ts
|
|
1092
|
+
/** Platforms people push to; only a label on our side — the address and key come from the platform's own streaming tool. */
|
|
1093
|
+
const PLATFORMS = [
|
|
1094
|
+
"bilibili",
|
|
1095
|
+
"douyin",
|
|
1096
|
+
"kuaishou",
|
|
1097
|
+
"wechat",
|
|
1098
|
+
"douyu",
|
|
1099
|
+
"huya",
|
|
1100
|
+
"yy",
|
|
1101
|
+
"cc",
|
|
1102
|
+
"youtube",
|
|
1103
|
+
"twitch",
|
|
1104
|
+
"custom"
|
|
1105
|
+
];
|
|
1106
|
+
const mask$1 = (k) => !k ? "" : k.length > 10 ? `${k.slice(0, 3)}…${k.slice(-4)}` : "••••";
|
|
1107
|
+
/** The full push address: the server plus the key, unless the server already ends with it. */
|
|
1108
|
+
function pushUrl(t) {
|
|
1109
|
+
const server = t.server.trim();
|
|
1110
|
+
const key = t.key.trim();
|
|
1111
|
+
if (!key || server.endsWith(key)) return server;
|
|
1112
|
+
return server.endsWith("/") ? server + key : `${server}/${key}`;
|
|
1113
|
+
}
|
|
1114
|
+
var Targets = class {
|
|
1115
|
+
file;
|
|
1116
|
+
constructor(file) {
|
|
1117
|
+
this.file = file;
|
|
1118
|
+
}
|
|
1119
|
+
list() {
|
|
1120
|
+
if (!existsSync(this.file)) return [];
|
|
1121
|
+
try {
|
|
1122
|
+
return JSON.parse(readFileSync(this.file, "utf8")).filter((t) => t && t.id && t.server);
|
|
1123
|
+
} catch {
|
|
1124
|
+
return [];
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
view() {
|
|
1128
|
+
return this.list().map((t) => ({
|
|
1129
|
+
id: t.id,
|
|
1130
|
+
platform: t.platform,
|
|
1131
|
+
server: t.server,
|
|
1132
|
+
key: mask$1(t.key)
|
|
1133
|
+
}));
|
|
1134
|
+
}
|
|
1135
|
+
/** Add or change one (an empty key keeps the saved one); returns the saved list. */
|
|
1136
|
+
save(input) {
|
|
1137
|
+
const all = this.list();
|
|
1138
|
+
const platform = PLATFORMS.includes(String(input.platform)) ? input.platform : "custom";
|
|
1139
|
+
const server = String(input.server ?? "").trim();
|
|
1140
|
+
if (!/^(rtmps?|srt):\/\//i.test(server)) throw new Error("the server address should start with rtmp://, rtmps:// or srt://");
|
|
1141
|
+
const old = input.id ? all.find((t) => t.id === input.id) : void 0;
|
|
1142
|
+
const key = String(input.key ?? "").trim() || old?.key || "";
|
|
1143
|
+
const next = {
|
|
1144
|
+
id: old?.id ?? Math.random().toString(36).slice(2, 10),
|
|
1145
|
+
platform,
|
|
1146
|
+
server,
|
|
1147
|
+
key
|
|
1148
|
+
};
|
|
1149
|
+
this.write(old ? all.map((t) => t.id === old.id ? next : t) : [...all, next]);
|
|
1150
|
+
return this.view();
|
|
1151
|
+
}
|
|
1152
|
+
remove(id) {
|
|
1153
|
+
this.write(this.list().filter((t) => t.id !== id));
|
|
1154
|
+
return this.view();
|
|
1155
|
+
}
|
|
1156
|
+
write(list) {
|
|
1157
|
+
writeFileSync(this.file, JSON.stringify(list, null, 2), { mode: 384 });
|
|
1158
|
+
try {
|
|
1159
|
+
chmodSync(this.file, 384);
|
|
1160
|
+
} catch {}
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
let canvasLib;
|
|
1164
|
+
/** The canvas library is loaded on first use (it is a native module; restreaming still works without subtitles if it fails). */
|
|
1165
|
+
const loadCanvas = () => canvasLib ??= import("@napi-rs/canvas").then((m) => m, () => void 0);
|
|
1166
|
+
const FONT = "\"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Noto Sans CJK SC\", \"Noto Sans SC\", \"Source Han Sans SC\", \"Helvetica Neue\", Arial, sans-serif";
|
|
1167
|
+
/**
|
|
1168
|
+
* Draws the subtitle lines for one moment as a transparent PNG the size of
|
|
1169
|
+
* the output picture, matching the player: the main line large and bold,
|
|
1170
|
+
* the rest smaller, each on a rounded dark box, wrapped to 92% of the width.
|
|
1171
|
+
*/
|
|
1172
|
+
async function drawSubtitles(rows, style, w, h) {
|
|
1173
|
+
const lib = await loadCanvas();
|
|
1174
|
+
if (!lib) return void 0;
|
|
1175
|
+
const c = lib.createCanvas(w, h);
|
|
1176
|
+
const g = c.getContext("2d");
|
|
1177
|
+
const maxW = w * .92;
|
|
1178
|
+
const blocks = rows.map((r) => {
|
|
1179
|
+
const px = Math.round(r.size / 100 * w);
|
|
1180
|
+
g.font = `${r.main ? 600 : 500} ${px}px ${FONT}`;
|
|
1181
|
+
const lines = [];
|
|
1182
|
+
let cur = "";
|
|
1183
|
+
for (const ch of Array.from(r.text)) {
|
|
1184
|
+
const next = cur + ch;
|
|
1185
|
+
if (cur && g.measureText(next).width > maxW - px * .9) {
|
|
1186
|
+
const sp = cur.lastIndexOf(" ");
|
|
1187
|
+
if (sp > cur.length * .5 && /[A-Za-z]/.test(ch)) {
|
|
1188
|
+
lines.push(cur.slice(0, sp));
|
|
1189
|
+
cur = cur.slice(sp + 1) + ch;
|
|
1190
|
+
} else {
|
|
1191
|
+
lines.push(cur);
|
|
1192
|
+
cur = ch.trimStart();
|
|
1193
|
+
}
|
|
1194
|
+
} else cur = next;
|
|
1195
|
+
}
|
|
1196
|
+
if (cur.trim()) lines.push(cur);
|
|
1197
|
+
return {
|
|
1198
|
+
px,
|
|
1199
|
+
main: r.main,
|
|
1200
|
+
lines
|
|
1201
|
+
};
|
|
1202
|
+
});
|
|
1203
|
+
const lineH = (px) => px * 1.55;
|
|
1204
|
+
const gap = (px) => px * .18;
|
|
1205
|
+
const total = blocks.reduce((n, b) => n + b.lines.length * lineH(b.px) + gap(b.px), 0);
|
|
1206
|
+
let y = style.position === "top" ? style.offset / 100 * h : h - style.offset / 100 * h - total;
|
|
1207
|
+
for (const b of blocks) {
|
|
1208
|
+
g.font = `${b.main ? 600 : 500} ${b.px}px ${FONT}`;
|
|
1209
|
+
g.textAlign = "center";
|
|
1210
|
+
g.textBaseline = "middle";
|
|
1211
|
+
for (const line of b.lines) {
|
|
1212
|
+
const tw = g.measureText(line).width;
|
|
1213
|
+
const padX = b.px * .45, boxH = b.px * 1.4;
|
|
1214
|
+
const cy = y + lineH(b.px) / 2;
|
|
1215
|
+
if (style.bg > .02) {
|
|
1216
|
+
g.fillStyle = `rgba(0,0,0,${style.bg})`;
|
|
1217
|
+
roundRect(g, w / 2 - tw / 2 - padX, cy - boxH / 2, tw + padX * 2, boxH, b.px * .22);
|
|
1218
|
+
g.fill();
|
|
1219
|
+
} else {
|
|
1220
|
+
g.shadowColor = "rgba(0,0,0,.85)";
|
|
1221
|
+
g.shadowBlur = b.px * .18;
|
|
1222
|
+
g.shadowOffsetY = b.px * .04;
|
|
1223
|
+
}
|
|
1224
|
+
g.fillStyle = b.main ? "#ffffff" : "#e8e8e8";
|
|
1225
|
+
g.fillText(line, w / 2, cy);
|
|
1226
|
+
g.shadowColor = "transparent";
|
|
1227
|
+
g.shadowBlur = 0;
|
|
1228
|
+
g.shadowOffsetY = 0;
|
|
1229
|
+
y += lineH(b.px);
|
|
1230
|
+
}
|
|
1231
|
+
y += gap(b.px);
|
|
1232
|
+
}
|
|
1233
|
+
return c.toBuffer("image/png");
|
|
1234
|
+
}
|
|
1235
|
+
function roundRect(g, x, y, w, h, r) {
|
|
1236
|
+
g.beginPath();
|
|
1237
|
+
g.moveTo(x + r, y);
|
|
1238
|
+
g.arcTo(x + w, y, x + w, y + h, r);
|
|
1239
|
+
g.arcTo(x + w, y + h, x, y + h, r);
|
|
1240
|
+
g.arcTo(x, y + h, x, y, r);
|
|
1241
|
+
g.arcTo(x, y, x + w, y, r);
|
|
1242
|
+
g.closePath();
|
|
1243
|
+
}
|
|
1244
|
+
const FPS_SUBS = 5;
|
|
1245
|
+
const RETRY_MS = [
|
|
1246
|
+
2e3,
|
|
1247
|
+
5e3,
|
|
1248
|
+
1e4,
|
|
1249
|
+
2e4,
|
|
1250
|
+
3e4
|
|
1251
|
+
];
|
|
1252
|
+
/**
|
|
1253
|
+
* Restreaming: one ffmpeg reads the recording in real time from `from`,
|
|
1254
|
+
* overlays the subtitle pictures (drawn here, a few per second, for the
|
|
1255
|
+
* exact media time of each one) and encodes once to MPEG-TS; every
|
|
1256
|
+
* destination gets its own small ffmpeg that copies that stream to the
|
|
1257
|
+
* platform as FLV. A platform that drops or refuses only retries itself.
|
|
1258
|
+
*/
|
|
1259
|
+
var Restream = class extends EventEmitter {
|
|
1260
|
+
source;
|
|
1261
|
+
opts;
|
|
1262
|
+
log;
|
|
1263
|
+
encoder;
|
|
1264
|
+
pushes = /* @__PURE__ */ new Map();
|
|
1265
|
+
status;
|
|
1266
|
+
stopped = false;
|
|
1267
|
+
sentFrames = 0;
|
|
1268
|
+
outTime = 0;
|
|
1269
|
+
subsTimer;
|
|
1270
|
+
lastKey = "";
|
|
1271
|
+
lastPng;
|
|
1272
|
+
blank;
|
|
1273
|
+
startAt = 0;
|
|
1274
|
+
spawnedAt = 0;
|
|
1275
|
+
constructor(source, opts, targets, log) {
|
|
1276
|
+
super();
|
|
1277
|
+
this.source = source;
|
|
1278
|
+
this.opts = opts;
|
|
1279
|
+
this.log = log;
|
|
1280
|
+
this.status = {
|
|
1281
|
+
running: true,
|
|
1282
|
+
at: opts.from,
|
|
1283
|
+
startedAt: Date.now(),
|
|
1284
|
+
burn: opts.burn,
|
|
1285
|
+
height: opts.height,
|
|
1286
|
+
targets: []
|
|
1287
|
+
};
|
|
1288
|
+
for (const t of targets) this.pushes.set(t.id, {
|
|
1289
|
+
target: t,
|
|
1290
|
+
tries: 0,
|
|
1291
|
+
status: {
|
|
1292
|
+
id: t.id,
|
|
1293
|
+
platform: t.platform,
|
|
1294
|
+
server: t.server,
|
|
1295
|
+
state: "connecting",
|
|
1296
|
+
since: Date.now()
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
get summary() {
|
|
1301
|
+
return {
|
|
1302
|
+
...this.status,
|
|
1303
|
+
targets: [...this.pushes.values()].map((p) => ({ ...p.status }))
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
changed() {
|
|
1307
|
+
this.emit("status", this.summary);
|
|
1308
|
+
}
|
|
1309
|
+
async start() {
|
|
1310
|
+
const h = this.opts.height, w = Math.round(h * 16 / 9);
|
|
1311
|
+
const { args: input, at } = this.source.input(this.opts.from);
|
|
1312
|
+
this.startAt = at;
|
|
1313
|
+
this.status.at = at;
|
|
1314
|
+
const burn = this.opts.burn && !!await loadCanvas();
|
|
1315
|
+
if (this.opts.burn && !burn) this.status.message = "no-burn";
|
|
1316
|
+
this.status.burn = burn;
|
|
1317
|
+
if (burn) this.blank = await drawSubtitles([], this.opts.style, w, h);
|
|
1318
|
+
const video = `[0:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=25,setpts=PTS-STARTPTS`;
|
|
1319
|
+
const filter = burn ? `${video}[v];[1:v]format=rgba,setpts=PTS-STARTPTS[s];[v][s]overlay=0:0:eof_action=repeat:shortest=1,format=yuv420p[o]` : `${video},format=yuv420p[o]`;
|
|
1320
|
+
const b = this.opts.bitrateK;
|
|
1321
|
+
const args = [
|
|
1322
|
+
"-hide_banner",
|
|
1323
|
+
"-loglevel",
|
|
1324
|
+
"error",
|
|
1325
|
+
"-re",
|
|
1326
|
+
...input,
|
|
1327
|
+
...burn ? [
|
|
1328
|
+
"-probesize",
|
|
1329
|
+
"65536",
|
|
1330
|
+
"-analyzeduration",
|
|
1331
|
+
"0",
|
|
1332
|
+
"-f",
|
|
1333
|
+
"image2pipe",
|
|
1334
|
+
"-framerate",
|
|
1335
|
+
String(FPS_SUBS),
|
|
1336
|
+
"-c:v",
|
|
1337
|
+
"png",
|
|
1338
|
+
"-i",
|
|
1339
|
+
"pipe:0"
|
|
1340
|
+
] : [],
|
|
1341
|
+
"-filter_complex",
|
|
1342
|
+
filter,
|
|
1343
|
+
"-map",
|
|
1344
|
+
"[o]",
|
|
1345
|
+
"-map",
|
|
1346
|
+
"0:a:0?",
|
|
1347
|
+
"-c:v",
|
|
1348
|
+
"libx264",
|
|
1349
|
+
"-preset",
|
|
1350
|
+
"veryfast",
|
|
1351
|
+
"-tune",
|
|
1352
|
+
"zerolatency",
|
|
1353
|
+
"-profile:v",
|
|
1354
|
+
"high",
|
|
1355
|
+
"-b:v",
|
|
1356
|
+
`${b}k`,
|
|
1357
|
+
"-maxrate",
|
|
1358
|
+
`${b}k`,
|
|
1359
|
+
"-bufsize",
|
|
1360
|
+
`${b * 2}k`,
|
|
1361
|
+
"-g",
|
|
1362
|
+
"50",
|
|
1363
|
+
"-keyint_min",
|
|
1364
|
+
"50",
|
|
1365
|
+
"-sc_threshold",
|
|
1366
|
+
"0",
|
|
1367
|
+
"-c:a",
|
|
1368
|
+
"aac",
|
|
1369
|
+
"-b:a",
|
|
1370
|
+
"128k",
|
|
1371
|
+
"-ar",
|
|
1372
|
+
"44100",
|
|
1373
|
+
"-ac",
|
|
1374
|
+
"2",
|
|
1375
|
+
"-progress",
|
|
1376
|
+
"pipe:3",
|
|
1377
|
+
"-f",
|
|
1378
|
+
"mpegts",
|
|
1379
|
+
"pipe:1"
|
|
1380
|
+
];
|
|
1381
|
+
const enc = spawn("ffmpeg", args, { stdio: [
|
|
1382
|
+
"pipe",
|
|
1383
|
+
"pipe",
|
|
1384
|
+
"pipe",
|
|
1385
|
+
"pipe"
|
|
1386
|
+
] });
|
|
1387
|
+
this.encoder = enc;
|
|
1388
|
+
this.spawnedAt = Date.now();
|
|
1389
|
+
process.once("exit", this.killAll);
|
|
1390
|
+
let errTail = "";
|
|
1391
|
+
enc.stderr.on("data", (d) => {
|
|
1392
|
+
errTail = (errTail + d.toString()).slice(-600);
|
|
1393
|
+
});
|
|
1394
|
+
enc.stdio[3].on("data", (d) => {
|
|
1395
|
+
const m = /out_time_us=(\d+)/g;
|
|
1396
|
+
let last, hit = null;
|
|
1397
|
+
while (last = m.exec(d.toString())) hit = last;
|
|
1398
|
+
if (hit) {
|
|
1399
|
+
this.outTime = Number(hit[1]) / 1e6;
|
|
1400
|
+
this.status.at = this.startAt + this.outTime;
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
enc.stdin.on("error", () => {});
|
|
1404
|
+
enc.stdout.on("data", (chunk) => {
|
|
1405
|
+
for (const p of this.pushes.values()) {
|
|
1406
|
+
const sin = p.proc?.stdin;
|
|
1407
|
+
if (sin && !sin.destroyed && sin.writableLength < 8 << 20) sin.write(chunk);
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
enc.on("exit", (code) => {
|
|
1411
|
+
if (this.stopped) return;
|
|
1412
|
+
const reason = code === 0 ? "ended" : errTail.trim().split("\n").pop() || `encoder exited (${code})`;
|
|
1413
|
+
this.log(`restream: ${reason}`);
|
|
1414
|
+
this.stop(reason);
|
|
1415
|
+
});
|
|
1416
|
+
if (burn) this.feedSubtitles(w, h);
|
|
1417
|
+
for (const p of this.pushes.values()) this.connect(p.target.id);
|
|
1418
|
+
this.changed();
|
|
1419
|
+
}
|
|
1420
|
+
/** Subtitle pictures for media times from the start on, kept about a second ahead of the encoder. */
|
|
1421
|
+
feedSubtitles(w, h) {
|
|
1422
|
+
const sin = this.encoder.stdin;
|
|
1423
|
+
const tick = async () => {
|
|
1424
|
+
if (this.stopped) return;
|
|
1425
|
+
const ahead = Math.max(4, (Date.now() - this.spawnedAt) / 1e3 + 2.5);
|
|
1426
|
+
while (!this.stopped && this.sentFrames / FPS_SUBS <= ahead && sin.writableLength < 4 << 20) {
|
|
1427
|
+
const t = this.startAt + this.sentFrames / FPS_SUBS;
|
|
1428
|
+
const rows = this.source.rowsAt(t);
|
|
1429
|
+
const key = JSON.stringify(rows);
|
|
1430
|
+
if (key !== this.lastKey) {
|
|
1431
|
+
this.lastKey = key;
|
|
1432
|
+
try {
|
|
1433
|
+
this.lastPng = rows.length ? await drawSubtitles(rows, this.opts.style, w, h) ?? this.blank : this.blank;
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
this.log(`restream: subtitle drawing failed (${err.message})`);
|
|
1436
|
+
this.lastPng = this.blank;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (this.lastPng) sin.write(this.lastPng);
|
|
1440
|
+
this.sentFrames++;
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
let busy = false;
|
|
1444
|
+
this.subsTimer = setInterval(() => {
|
|
1445
|
+
if (busy) return;
|
|
1446
|
+
busy = true;
|
|
1447
|
+
tick().catch((err) => this.log(`restream: subtitles: ${err.message}`)).finally(() => {
|
|
1448
|
+
busy = false;
|
|
1449
|
+
});
|
|
1450
|
+
}, 100);
|
|
1451
|
+
}
|
|
1452
|
+
/** Start (or restart) one destination. */
|
|
1453
|
+
connect(id) {
|
|
1454
|
+
const p = this.pushes.get(id);
|
|
1455
|
+
if (!p || this.stopped) return;
|
|
1456
|
+
const url = pushUrl(p.target);
|
|
1457
|
+
const proc = spawn("ffmpeg", [
|
|
1458
|
+
"-hide_banner",
|
|
1459
|
+
"-nostats",
|
|
1460
|
+
"-loglevel",
|
|
1461
|
+
"info",
|
|
1462
|
+
"-f",
|
|
1463
|
+
"mpegts",
|
|
1464
|
+
"-i",
|
|
1465
|
+
"pipe:0",
|
|
1466
|
+
"-c",
|
|
1467
|
+
"copy",
|
|
1468
|
+
"-f",
|
|
1469
|
+
"flv",
|
|
1470
|
+
"-flvflags",
|
|
1471
|
+
"no_duration_filesize",
|
|
1472
|
+
url
|
|
1473
|
+
], { stdio: [
|
|
1474
|
+
"pipe",
|
|
1475
|
+
"ignore",
|
|
1476
|
+
"pipe"
|
|
1477
|
+
] });
|
|
1478
|
+
p.proc = proc;
|
|
1479
|
+
p.status = {
|
|
1480
|
+
...p.status,
|
|
1481
|
+
state: p.tries ? "retrying" : "connecting",
|
|
1482
|
+
since: Date.now(),
|
|
1483
|
+
message: void 0
|
|
1484
|
+
};
|
|
1485
|
+
let errTail = "";
|
|
1486
|
+
let opened = false;
|
|
1487
|
+
proc.stderr.on("data", (d) => {
|
|
1488
|
+
const text = d.toString();
|
|
1489
|
+
errTail = (errTail + text).slice(-600);
|
|
1490
|
+
if (!opened && /Output #0/.test(errTail) && p.proc === proc) {
|
|
1491
|
+
opened = true;
|
|
1492
|
+
p.tries = 0;
|
|
1493
|
+
p.status = {
|
|
1494
|
+
...p.status,
|
|
1495
|
+
state: "live",
|
|
1496
|
+
message: void 0,
|
|
1497
|
+
since: Date.now()
|
|
1498
|
+
};
|
|
1499
|
+
this.changed();
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
proc.stdin.on("error", () => {});
|
|
1503
|
+
proc.on("exit", () => {
|
|
1504
|
+
if (p.proc !== proc || this.stopped) return;
|
|
1505
|
+
p.proc = void 0;
|
|
1506
|
+
const reason = errTail.split("\n").map((l) => l.replace(/^\[[^\]]*\]\s*/, "").trim()).filter(Boolean).reverse().find((l) => /error|fail|refused|denied|invalid|timed out|not found|unauthor|forbidden|reset|broken/i.test(l)) || "the platform closed the connection";
|
|
1507
|
+
if (p.tries >= RETRY_MS.length) {
|
|
1508
|
+
p.status = {
|
|
1509
|
+
...p.status,
|
|
1510
|
+
state: "error",
|
|
1511
|
+
message: reason,
|
|
1512
|
+
since: Date.now()
|
|
1513
|
+
};
|
|
1514
|
+
this.changed();
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
p.status = {
|
|
1518
|
+
...p.status,
|
|
1519
|
+
state: "retrying",
|
|
1520
|
+
message: reason,
|
|
1521
|
+
since: Date.now()
|
|
1522
|
+
};
|
|
1523
|
+
p.timer = setTimeout(() => this.connect(id), RETRY_MS[p.tries++]);
|
|
1524
|
+
this.changed();
|
|
1525
|
+
});
|
|
1526
|
+
this.changed();
|
|
1527
|
+
}
|
|
1528
|
+
/** Add a destination while running (no interruption for the others). */
|
|
1529
|
+
add(t) {
|
|
1530
|
+
if (this.pushes.has(t.id) || this.stopped) return;
|
|
1531
|
+
this.pushes.set(t.id, {
|
|
1532
|
+
target: t,
|
|
1533
|
+
tries: 0,
|
|
1534
|
+
status: {
|
|
1535
|
+
id: t.id,
|
|
1536
|
+
platform: t.platform,
|
|
1537
|
+
server: t.server,
|
|
1538
|
+
state: "connecting",
|
|
1539
|
+
since: Date.now()
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
this.connect(t.id);
|
|
1543
|
+
}
|
|
1544
|
+
/** Stop one destination; the rest keep going. Returns true when none are left. */
|
|
1545
|
+
drop(id) {
|
|
1546
|
+
const p = this.pushes.get(id);
|
|
1547
|
+
if (p) {
|
|
1548
|
+
clearTimeout(p.timer);
|
|
1549
|
+
const proc = p.proc;
|
|
1550
|
+
p.proc = void 0;
|
|
1551
|
+
proc?.stdin?.end();
|
|
1552
|
+
proc?.kill("SIGTERM");
|
|
1553
|
+
this.pushes.delete(id);
|
|
1554
|
+
this.changed();
|
|
1555
|
+
}
|
|
1556
|
+
return this.pushes.size === 0;
|
|
1557
|
+
}
|
|
1558
|
+
killAll = () => {
|
|
1559
|
+
this.encoder?.kill("SIGKILL");
|
|
1560
|
+
for (const p of this.pushes.values()) p.proc?.kill("SIGKILL");
|
|
1561
|
+
};
|
|
1562
|
+
async stop(message) {
|
|
1563
|
+
if (this.stopped) return;
|
|
1564
|
+
this.stopped = true;
|
|
1565
|
+
process.off("exit", this.killAll);
|
|
1566
|
+
clearInterval(this.subsTimer);
|
|
1567
|
+
for (const p of this.pushes.values()) {
|
|
1568
|
+
clearTimeout(p.timer);
|
|
1569
|
+
const proc = p.proc;
|
|
1570
|
+
proc?.stdin?.end();
|
|
1571
|
+
if (proc) setTimeout(() => {
|
|
1572
|
+
if (proc.exitCode === null) proc.kill("SIGTERM");
|
|
1573
|
+
}, 3e3);
|
|
1574
|
+
p.status = {
|
|
1575
|
+
...p.status,
|
|
1576
|
+
state: "stopped",
|
|
1577
|
+
since: Date.now()
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
this.encoder?.kill("SIGTERM");
|
|
1581
|
+
this.status = {
|
|
1582
|
+
...this.status,
|
|
1583
|
+
running: false,
|
|
1584
|
+
message: message ?? this.status.message
|
|
1585
|
+
};
|
|
1586
|
+
this.changed();
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1091
1589
|
// src/job.ts
|
|
1092
1590
|
const run$1 = promisify(execFile);
|
|
1093
1591
|
/** URLs ffmpeg can open directly (no yt-dlp resolution). */
|
|
@@ -1199,6 +1697,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
1199
1697
|
hls: this.hlsName,
|
|
1200
1698
|
height: this.height,
|
|
1201
1699
|
heights: this.heights,
|
|
1700
|
+
restream: this.restream,
|
|
1202
1701
|
subs: { ...this.subs },
|
|
1203
1702
|
usage: this.deps.usage.summary(),
|
|
1204
1703
|
site: this.siteTracks,
|
|
@@ -1559,7 +2058,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
1559
2058
|
"pipe"
|
|
1560
2059
|
] });
|
|
1561
2060
|
this.ffmpeg = ff;
|
|
1562
|
-
if (this.
|
|
2061
|
+
if (this.opts.source === "push") {
|
|
1563
2062
|
const wait = setInterval(() => {
|
|
1564
2063
|
if (this.ffmpeg !== ff) {
|
|
1565
2064
|
clearInterval(wait);
|
|
@@ -1621,12 +2120,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
1621
2120
|
let at = Math.max(0, from ?? this.subs.at);
|
|
1622
2121
|
const finished = readFileSync(this.m3u8, "utf8").includes("#EXT-X-ENDLIST");
|
|
1623
2122
|
let input;
|
|
1624
|
-
if (finished) input =
|
|
1625
|
-
"-ss",
|
|
1626
|
-
String(at),
|
|
1627
|
-
"-i",
|
|
1628
|
-
this.m3u8
|
|
1629
|
-
];
|
|
2123
|
+
if (finished) input = this.seekable(at);
|
|
1630
2124
|
else {
|
|
1631
2125
|
const i = Math.max(0, segs.findLastIndex((sg) => sg.start <= at));
|
|
1632
2126
|
at = segs[i].start;
|
|
@@ -1900,9 +2394,96 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
1900
2394
|
}
|
|
1901
2395
|
/** Stop everything (closing the job): recording and subtitles. */
|
|
1902
2396
|
async close(reason = "closed") {
|
|
2397
|
+
await this.restreamer?.stop();
|
|
1903
2398
|
await this.stop(reason);
|
|
1904
2399
|
await this.pauseSubs();
|
|
1905
2400
|
}
|
|
2401
|
+
restreamer;
|
|
2402
|
+
get restream() {
|
|
2403
|
+
return this.restreamer?.summary;
|
|
2404
|
+
}
|
|
2405
|
+
/** Push this video (from media time `from`, subtitles drawn in) to the chosen destinations. */
|
|
2406
|
+
async startRestream(targets, opts) {
|
|
2407
|
+
if (this.restreamer?.summary.running) {
|
|
2408
|
+
for (const t of targets) this.restreamer.add(t);
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
if (!this.segments().length) throw new Error("nothing recorded yet");
|
|
2412
|
+
const r = new Restream({
|
|
2413
|
+
input: (from) => this.restreamInput(from),
|
|
2414
|
+
rowsAt: (t) => this.rowsAt(t, opts.style)
|
|
2415
|
+
}, {
|
|
2416
|
+
...opts,
|
|
2417
|
+
bitrateK: opts.height === 1080 ? 4500 : 2500
|
|
2418
|
+
}, targets, (m) => this.deps.log(`[${this.id}] ${m}`));
|
|
2419
|
+
this.restreamer = r;
|
|
2420
|
+
r.on("status", () => this.emit("state", this.summary()));
|
|
2421
|
+
await r.start();
|
|
2422
|
+
}
|
|
2423
|
+
async stopRestream(targetId) {
|
|
2424
|
+
if (!this.restreamer) return;
|
|
2425
|
+
if (targetId && !this.restreamer.drop(targetId)) return;
|
|
2426
|
+
await this.restreamer.stop();
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Input arguments for a finished recording from media time `from`. Seeking
|
|
2430
|
+
* an HLS playlist to 0 skips its whole first segment when the stream's
|
|
2431
|
+
* timestamps don't start at 0, so: no seek from the start, and the single
|
|
2432
|
+
* recording file (exact seeking) once it exists.
|
|
2433
|
+
*/
|
|
2434
|
+
seekable(from) {
|
|
2435
|
+
const file = this.captureDone && existsSync(join(this.deps.dir, "recording.mp4")) ? join(this.deps.dir, "recording.mp4") : this.m3u8;
|
|
2436
|
+
return from > .05 ? [
|
|
2437
|
+
"-ss",
|
|
2438
|
+
String(from),
|
|
2439
|
+
"-i",
|
|
2440
|
+
file
|
|
2441
|
+
] : ["-i", file];
|
|
2442
|
+
}
|
|
2443
|
+
/** The recording from `from` on, for an ffmpeg that reads it in real time. */
|
|
2444
|
+
restreamInput(from) {
|
|
2445
|
+
const segs = this.segments();
|
|
2446
|
+
if (readFileSync(this.m3u8, "utf8").includes("#EXT-X-ENDLIST")) return {
|
|
2447
|
+
args: this.seekable(from),
|
|
2448
|
+
at: Math.max(0, from)
|
|
2449
|
+
};
|
|
2450
|
+
const i = Math.max(0, segs.findLastIndex((sg) => sg.start <= from));
|
|
2451
|
+
return {
|
|
2452
|
+
args: [
|
|
2453
|
+
"-live_start_index",
|
|
2454
|
+
String(i),
|
|
2455
|
+
"-max_reload",
|
|
2456
|
+
"100000",
|
|
2457
|
+
"-i",
|
|
2458
|
+
this.m3u8
|
|
2459
|
+
],
|
|
2460
|
+
at: segs[i]?.start ?? 0
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
/** What the player would show at media time t, line by line (the same rules: no repeats, line-end marks dropped). */
|
|
2464
|
+
rowsAt(t, style) {
|
|
2465
|
+
const cue = this.cues.find((c) => c.final && c.start <= t && t < Math.max(c.end, c.start + 1.2));
|
|
2466
|
+
const text = (key) => {
|
|
2467
|
+
if (key.startsWith("site:")) return this.siteCues.get(key)?.find((c) => c.start <= t && t < c.end)?.text;
|
|
2468
|
+
if (!cue) return void 0;
|
|
2469
|
+
if (key === "src") return cue.src;
|
|
2470
|
+
const tr = cue.tr?.[key];
|
|
2471
|
+
return tr && tr.trim() !== cue.src.trim() ? tr : cue.lang?.split("-")[0] === key.split("-")[0] ? cue.src : void 0;
|
|
2472
|
+
};
|
|
2473
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2474
|
+
const rows = [];
|
|
2475
|
+
for (const l of style.lines) {
|
|
2476
|
+
const x = l.show ? text(l.key)?.trim() : void 0;
|
|
2477
|
+
if (!x || seen.has(x)) continue;
|
|
2478
|
+
seen.add(x);
|
|
2479
|
+
rows.push({
|
|
2480
|
+
text: subtitleText(x),
|
|
2481
|
+
size: l.size,
|
|
2482
|
+
main: rows.length === 0
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
return rows;
|
|
2486
|
+
}
|
|
1906
2487
|
/** Write the default subtitle and transcript set (source, each target, multilingual). */
|
|
1907
2488
|
save() {
|
|
1908
2489
|
for (const { name, langs, formats } of defaultExports(this.opts.tgtLangs)) for (const f of formats) {
|
|
@@ -2380,6 +2961,10 @@ var KeyStore = class {
|
|
|
2380
2961
|
settings;
|
|
2381
2962
|
cache = /* @__PURE__ */ new Map();
|
|
2382
2963
|
creds;
|
|
2964
|
+
/** Model providers configured in DSH (their key names and addresses), read fresh on each refresh. */
|
|
2965
|
+
discover;
|
|
2966
|
+
/** Addresses from DSH's provider setup, per service (e.g. SiliconFlow's international endpoint). */
|
|
2967
|
+
bases = /* @__PURE__ */ new Map();
|
|
2383
2968
|
constructor(file, settings) {
|
|
2384
2969
|
this.file = file;
|
|
2385
2970
|
this.settings = settings;
|
|
@@ -2395,17 +2980,33 @@ var KeyStore = class {
|
|
|
2395
2980
|
async refresh() {
|
|
2396
2981
|
const file = this.readFile();
|
|
2397
2982
|
const next = /* @__PURE__ */ new Map();
|
|
2983
|
+
const found = (() => {
|
|
2984
|
+
try {
|
|
2985
|
+
return this.discover?.() ?? [];
|
|
2986
|
+
} catch {
|
|
2987
|
+
return [];
|
|
2988
|
+
}
|
|
2989
|
+
})();
|
|
2990
|
+
this.bases = new Map(found.filter((p) => p.base).map((p) => [p.service, p.base]));
|
|
2398
2991
|
for (const name of KEY_NAMES) {
|
|
2399
2992
|
let hit;
|
|
2400
|
-
|
|
2993
|
+
const refs = [...found.filter((p) => p.service === name && p.ref).map((p) => ({
|
|
2994
|
+
ref: p.ref,
|
|
2995
|
+
provider: p.name
|
|
2996
|
+
})), ...KEY_REFS[name].map((ref) => ({
|
|
2997
|
+
ref,
|
|
2998
|
+
provider: void 0
|
|
2999
|
+
}))];
|
|
3000
|
+
for (const { ref, provider } of refs) {
|
|
2401
3001
|
const r = await this.creds?.resolve(ref).catch(() => void 0);
|
|
2402
3002
|
if (r?.value.trim()) {
|
|
2403
3003
|
hit = {
|
|
2404
3004
|
key: r.value.trim(),
|
|
2405
3005
|
info: {
|
|
2406
3006
|
set: true,
|
|
2407
|
-
source: r.source === "file" ? "dsh" : "env",
|
|
2408
|
-
ref
|
|
3007
|
+
source: r.source === "file" || provider ? "dsh" : "env",
|
|
3008
|
+
ref,
|
|
3009
|
+
provider
|
|
2409
3010
|
}
|
|
2410
3011
|
};
|
|
2411
3012
|
break;
|
|
@@ -3142,6 +3743,21 @@ const SERVICES = [
|
|
|
3142
3743
|
}
|
|
3143
3744
|
];
|
|
3144
3745
|
const service = (id) => SERVICES.find((s) => s.id === id);
|
|
3746
|
+
/** Which speech service a DSH model provider also is, by its address (or its id when it has none). */
|
|
3747
|
+
function speechServiceOf(id, baseURL) {
|
|
3748
|
+
const k = `${(() => {
|
|
3749
|
+
try {
|
|
3750
|
+
return baseURL ? new URL(baseURL).host : "";
|
|
3751
|
+
} catch {
|
|
3752
|
+
return "";
|
|
3753
|
+
}
|
|
3754
|
+
})()} ${id}`.toLowerCase();
|
|
3755
|
+
if (/generativelanguage\.googleapis\.com|^ (google|gemini)$/.test(k)) return "gemini";
|
|
3756
|
+
if (/api\.openai\.com|^ openai$/.test(k)) return "openai";
|
|
3757
|
+
if (/api\.groq\.com|^ groq$/.test(k)) return "groq";
|
|
3758
|
+
if (/siliconflow/.test(k)) return "siliconflow";
|
|
3759
|
+
if (/bigmodel\.cn|(^|\.)z\.ai|^ (glm|zhipu|zhipuai|zai)$/.test(k)) return "glm";
|
|
3760
|
+
}
|
|
3145
3761
|
const TIMEOUT = 3e4;
|
|
3146
3762
|
async function fail(r) {
|
|
3147
3763
|
const body = await r.text().catch(() => "");
|
|
@@ -3802,7 +4418,30 @@ function apply(ctx, config) {
|
|
|
3802
4418
|
}, "dsh-vidi: translator model");
|
|
3803
4419
|
});
|
|
3804
4420
|
const translator = new Translator(() => llm);
|
|
4421
|
+
const restreamTargets = new Targets(join(config.dataDir, "restream.json"));
|
|
3805
4422
|
const keys = new KeyStore(join(config.dataDir, "keys.json"), () => ({ openai: config.openaiApiKey }));
|
|
4423
|
+
keys.discover = () => {
|
|
4424
|
+
const loader = ctx.get?.("loader") ?? ctx.loader;
|
|
4425
|
+
if (!loader?.entries) return [];
|
|
4426
|
+
const val = (x) => x && typeof x.get === "function" ? x.get() : x;
|
|
4427
|
+
const out = [];
|
|
4428
|
+
for (const row of loader.entries()) {
|
|
4429
|
+
if (!/llm-pi-ai$/.test(String(row.options?.name ?? ""))) continue;
|
|
4430
|
+
const providers = val((row.fiber?.effectiveConfig ?? row.options?.config ?? {}).providers) ?? {};
|
|
4431
|
+
for (const [id, raw] of Object.entries(providers)) {
|
|
4432
|
+
const p = val(raw) ?? {};
|
|
4433
|
+
const base = typeof val(p.baseURL) === "string" ? String(val(p.baseURL)).replace(/\/+$/, "") : void 0;
|
|
4434
|
+
const service = speechServiceOf(id, base);
|
|
4435
|
+
if (service) out.push({
|
|
4436
|
+
service,
|
|
4437
|
+
ref: val(p.apiKeyEnv),
|
|
4438
|
+
base: service === "gemini" || service === "openai" ? void 0 : base,
|
|
4439
|
+
name: String(val(p.displayName) ?? id)
|
|
4440
|
+
});
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
return out;
|
|
4444
|
+
};
|
|
3806
4445
|
keys.refresh();
|
|
3807
4446
|
ctx.inject(["credentials"], (c) => {
|
|
3808
4447
|
c.effect(() => {
|
|
@@ -3874,7 +4513,10 @@ function apply(ctx, config) {
|
|
|
3874
4513
|
});
|
|
3875
4514
|
const key = () => keys.get(svc.id);
|
|
3876
4515
|
return new LocalProvider({
|
|
3877
|
-
engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : whisperApiEngine(
|
|
4516
|
+
engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : whisperApiEngine({
|
|
4517
|
+
...svc,
|
|
4518
|
+
base: keys.bases.get(svc.id) ?? svc.base
|
|
4519
|
+
}, svc.model, key),
|
|
3878
4520
|
lang: opts.srcLang === "auto" ? "auto" : opts.srcLang,
|
|
3879
4521
|
minSec: 3,
|
|
3880
4522
|
maxSec: 10,
|
|
@@ -4195,6 +4837,41 @@ function apply(ctx, config) {
|
|
|
4195
4837
|
conversationId: String(b.conversationId ?? "default")
|
|
4196
4838
|
}));
|
|
4197
4839
|
}
|
|
4840
|
+
if (route === "restream/targets") {
|
|
4841
|
+
if (req.method === "POST") {
|
|
4842
|
+
const b = await readJson(req);
|
|
4843
|
+
try {
|
|
4844
|
+
if (typeof b.remove === "string") return send(res, 200, { targets: restreamTargets.remove(b.remove) });
|
|
4845
|
+
return send(res, 200, { targets: restreamTargets.save(b.target ?? {}) });
|
|
4846
|
+
} catch (err) {
|
|
4847
|
+
return send(res, 400, { error: err.message });
|
|
4848
|
+
}
|
|
4849
|
+
}
|
|
4850
|
+
return send(res, 200, { targets: restreamTargets.view() });
|
|
4851
|
+
}
|
|
4852
|
+
if (route === "restream" && req.method === "POST") {
|
|
4853
|
+
const b = await readJson(req);
|
|
4854
|
+
const job = jobs.get(String(b.jobId));
|
|
4855
|
+
if (!job) return send(res, 404, { error: "no such job" });
|
|
4856
|
+
if (b.action === "stop") {
|
|
4857
|
+
await job.stopRestream(typeof b.targetId === "string" ? b.targetId : void 0);
|
|
4858
|
+
return send(res, 200, job.summary());
|
|
4859
|
+
}
|
|
4860
|
+
const ids = Array.isArray(b.targets) ? b.targets.map(String) : [];
|
|
4861
|
+
const chosen = restreamTargets.list().filter((t) => ids.includes(t.id));
|
|
4862
|
+
if (!chosen.length) return send(res, 400, { error: "choose where to stream first" });
|
|
4863
|
+
try {
|
|
4864
|
+
await job.startRestream(chosen, {
|
|
4865
|
+
from: typeof b.from === "number" ? b.from : 0,
|
|
4866
|
+
burn: b.burn !== false,
|
|
4867
|
+
style: b.style,
|
|
4868
|
+
height: b.height === 1080 ? 1080 : 720
|
|
4869
|
+
});
|
|
4870
|
+
} catch (err) {
|
|
4871
|
+
return send(res, 400, { error: err.message });
|
|
4872
|
+
}
|
|
4873
|
+
return send(res, 200, job.summary());
|
|
4874
|
+
}
|
|
4198
4875
|
if (route === "models") {
|
|
4199
4876
|
const out = [];
|
|
4200
4877
|
for (const p of llmService?.listProviders?.() ?? []) {
|