dsh-vidi 0.9.5 → 0.10.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/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/lib/client.js +880 -1
- package/lib/index.mjs +1870 -145
- package/package.json +3 -2
package/lib/client.js
CHANGED
|
@@ -365,6 +365,22 @@ const Icon = {
|
|
|
365
365
|
...P,
|
|
366
366
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M7 4v5a5 5 0 0 0 5 5 5 5 0 0 1 5 5v1M17 4v5a5 5 0 0 1-3 4.6M12 17l-3 3M12 17l3 3" })
|
|
367
367
|
}),
|
|
368
|
+
/** A speaking head: the voiceover. */
|
|
369
|
+
voice: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
370
|
+
viewBox: "0 0 24 24",
|
|
371
|
+
width: "18",
|
|
372
|
+
height: "18",
|
|
373
|
+
...P,
|
|
374
|
+
children: [
|
|
375
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5" }),
|
|
376
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
377
|
+
cx: "10",
|
|
378
|
+
cy: "8.5",
|
|
379
|
+
r: "3.5"
|
|
380
|
+
}),
|
|
381
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M17 6.5a4 4 0 0 1 0 5M19.5 4a7.5 7.5 0 0 1 0 10" })
|
|
382
|
+
]
|
|
383
|
+
}),
|
|
368
384
|
timer: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
369
385
|
viewBox: "0 0 24 24",
|
|
370
386
|
width: "18",
|
|
@@ -1280,6 +1296,660 @@ function LoginDialog(props) {
|
|
|
1280
1296
|
});
|
|
1281
1297
|
}
|
|
1282
1298
|
|
|
1299
|
+
// src/client/DubPanel.tsx
|
|
1300
|
+
async function post$3(path, body) {
|
|
1301
|
+
const r = await fetch(`/live/api/${path}`, {
|
|
1302
|
+
method: "POST",
|
|
1303
|
+
headers: { "content-type": "application/json" },
|
|
1304
|
+
body: JSON.stringify(body)
|
|
1305
|
+
});
|
|
1306
|
+
const j = await r.json().catch(() => ({}));
|
|
1307
|
+
if (!r.ok) throw new Error(j.error ?? `HTTP ${r.status}`);
|
|
1308
|
+
return j;
|
|
1309
|
+
}
|
|
1310
|
+
function useVoices(ui) {
|
|
1311
|
+
const [list, setList] = (0, react.useState)(null);
|
|
1312
|
+
const reload = () => fetch(`/live/api/voices?ui=${ui}`).then((r) => r.json()).then(setList).catch(() => {});
|
|
1313
|
+
(0, react.useEffect)(() => {
|
|
1314
|
+
reload();
|
|
1315
|
+
}, [ui]);
|
|
1316
|
+
return {
|
|
1317
|
+
list,
|
|
1318
|
+
reload
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
const VENDOR_NAMES = {
|
|
1322
|
+
openai: "OpenAI",
|
|
1323
|
+
siliconflow: "SiliconFlow",
|
|
1324
|
+
gemini: "Gemini",
|
|
1325
|
+
elevenlabs: "ElevenLabs"
|
|
1326
|
+
};
|
|
1327
|
+
const vendorName = (v, t) => v === "siliconflow" ? t("dub.sf") : VENDOR_NAMES[v] ?? v;
|
|
1328
|
+
/** One test sentence at a time, played right here. */
|
|
1329
|
+
function useTry() {
|
|
1330
|
+
const audio = (0, react.useRef)(null);
|
|
1331
|
+
const [busy, setBusy] = (0, react.useState)(null);
|
|
1332
|
+
const [error, setError] = (0, react.useState)("");
|
|
1333
|
+
const play = async (voice, lang) => {
|
|
1334
|
+
audio.current?.pause();
|
|
1335
|
+
setBusy(voice);
|
|
1336
|
+
setError("");
|
|
1337
|
+
try {
|
|
1338
|
+
const r = await fetch("/live/api/voices/try", {
|
|
1339
|
+
method: "POST",
|
|
1340
|
+
headers: { "content-type": "application/json" },
|
|
1341
|
+
body: JSON.stringify({
|
|
1342
|
+
voice,
|
|
1343
|
+
lang
|
|
1344
|
+
})
|
|
1345
|
+
});
|
|
1346
|
+
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error ?? `HTTP ${r.status}`);
|
|
1347
|
+
const url = URL.createObjectURL(await r.blob());
|
|
1348
|
+
const a = new Audio(url);
|
|
1349
|
+
audio.current = a;
|
|
1350
|
+
a.onended = () => URL.revokeObjectURL(url);
|
|
1351
|
+
await a.play();
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
setError(e.message);
|
|
1354
|
+
}
|
|
1355
|
+
setBusy(null);
|
|
1356
|
+
};
|
|
1357
|
+
(0, react.useEffect)(() => () => audio.current?.pause(), []);
|
|
1358
|
+
return {
|
|
1359
|
+
play,
|
|
1360
|
+
busy,
|
|
1361
|
+
error
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* The player's voiceover menu: switch it on, which subtitle language is
|
|
1366
|
+
* spoken, with which voice (try it), and what happens to the original sound.
|
|
1367
|
+
*/
|
|
1368
|
+
function DubMenu(props) {
|
|
1369
|
+
const { jobId, view, langs, voices, onMake, at, t } = props;
|
|
1370
|
+
const [error, setError] = (0, react.useState)("");
|
|
1371
|
+
const tryer = useTry();
|
|
1372
|
+
const set = async (patch) => {
|
|
1373
|
+
setError("");
|
|
1374
|
+
try {
|
|
1375
|
+
await post$3("dub", {
|
|
1376
|
+
jobId,
|
|
1377
|
+
at,
|
|
1378
|
+
...patch
|
|
1379
|
+
});
|
|
1380
|
+
} catch (e) {
|
|
1381
|
+
setError(e.message);
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
if (!view) return null;
|
|
1385
|
+
const none = voices && !voices.vendors.length;
|
|
1386
|
+
const options = [...(voices?.mine ?? []).map((v) => ({
|
|
1387
|
+
value: v.id,
|
|
1388
|
+
label: v.label,
|
|
1389
|
+
hint: vendorName(v.vendor, t),
|
|
1390
|
+
group: t("dub.mine"),
|
|
1391
|
+
disabled: !v.ready
|
|
1392
|
+
})), ...(voices?.presets ?? []).map((v) => ({
|
|
1393
|
+
value: v.id,
|
|
1394
|
+
label: v.label,
|
|
1395
|
+
group: vendorName(v.vendor, t)
|
|
1396
|
+
}))];
|
|
1397
|
+
const lang = langs.some((l) => l.value === view.lang) ? view.lang : langs[0]?.value ?? view.lang;
|
|
1398
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1399
|
+
className: "dshl-submenu dshl-dubmenu",
|
|
1400
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1401
|
+
className: `dshl-switchrow ${view.on ? "on" : ""}`,
|
|
1402
|
+
disabled: !view.on && (!!none || !langs.length),
|
|
1403
|
+
onClick: () => void set({
|
|
1404
|
+
on: !view.on,
|
|
1405
|
+
lang
|
|
1406
|
+
}),
|
|
1407
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1408
|
+
className: "n",
|
|
1409
|
+
children: t("dub.enable")
|
|
1410
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1411
|
+
className: "sw",
|
|
1412
|
+
"aria-hidden": true,
|
|
1413
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {})
|
|
1414
|
+
})]
|
|
1415
|
+
}), none ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1416
|
+
className: "mt",
|
|
1417
|
+
children: t("dub.noKey")
|
|
1418
|
+
}) : !langs.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1419
|
+
className: "mt",
|
|
1420
|
+
children: t("dub.noLang")
|
|
1421
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1422
|
+
className: "slots",
|
|
1423
|
+
children: [
|
|
1424
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1425
|
+
className: "slot",
|
|
1426
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dub.lang") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
1427
|
+
value: lang,
|
|
1428
|
+
onChange: (v) => void set({ lang: v }),
|
|
1429
|
+
options: langs
|
|
1430
|
+
})]
|
|
1431
|
+
}),
|
|
1432
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1433
|
+
className: "slot",
|
|
1434
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dub.voice") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1435
|
+
className: "vrow",
|
|
1436
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
1437
|
+
value: view.voice,
|
|
1438
|
+
onChange: (v) => void set({ voice: v }),
|
|
1439
|
+
options,
|
|
1440
|
+
placeholder: t("dub.pick")
|
|
1441
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1442
|
+
className: "ghost try",
|
|
1443
|
+
title: t("dub.try"),
|
|
1444
|
+
"aria-label": t("dub.try"),
|
|
1445
|
+
disabled: !!tryer.busy,
|
|
1446
|
+
onClick: () => void tryer.play(view.voice, lang),
|
|
1447
|
+
children: tryer.busy ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "spin" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.play, {})
|
|
1448
|
+
})]
|
|
1449
|
+
})]
|
|
1450
|
+
}),
|
|
1451
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1452
|
+
className: "swap make",
|
|
1453
|
+
onClick: onMake,
|
|
1454
|
+
children: ["+ ", t("dub.make")]
|
|
1455
|
+
})
|
|
1456
|
+
]
|
|
1457
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1458
|
+
className: "opts",
|
|
1459
|
+
children: [
|
|
1460
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1461
|
+
className: "opt",
|
|
1462
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dub.original") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1463
|
+
className: "seg",
|
|
1464
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1465
|
+
className: view.original === "duck" ? "on" : "",
|
|
1466
|
+
onClick: () => void set({ original: "duck" }),
|
|
1467
|
+
children: t("dub.duck")
|
|
1468
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1469
|
+
className: view.original === "off" ? "on" : "",
|
|
1470
|
+
onClick: () => void set({ original: "off" }),
|
|
1471
|
+
children: t("dub.off")
|
|
1472
|
+
})]
|
|
1473
|
+
})]
|
|
1474
|
+
}),
|
|
1475
|
+
(error || tryer.error || view.error) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1476
|
+
className: "err",
|
|
1477
|
+
children: error || tryer.error || t("dub.failed").replace("{e}", view.error ?? "")
|
|
1478
|
+
}),
|
|
1479
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1480
|
+
className: "mt",
|
|
1481
|
+
children: t("dub.how")
|
|
1482
|
+
})
|
|
1483
|
+
]
|
|
1484
|
+
})] })]
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Make a voice from a recording: read a passage into the microphone, give a
|
|
1489
|
+
* file, or take someone talking in this video. The sample is sent to a
|
|
1490
|
+
* service that learns voices (SiliconFlow or ElevenLabs), and the new voice
|
|
1491
|
+
* is chosen for this video.
|
|
1492
|
+
*/
|
|
1493
|
+
function VoiceMaker(props) {
|
|
1494
|
+
const { jobId, at, voices, onDone, onClose, t } = props;
|
|
1495
|
+
const [source, setSource] = (0, react.useState)("mic");
|
|
1496
|
+
const [mics, setMics] = (0, react.useState)([]);
|
|
1497
|
+
const [mic, setMic] = (0, react.useState)("");
|
|
1498
|
+
const [rec, setRec] = (0, react.useState)(null);
|
|
1499
|
+
const [sample, setSample] = (0, react.useState)(null);
|
|
1500
|
+
const [text, setText] = (0, react.useState)(voices.readAloud);
|
|
1501
|
+
const [name, setName] = (0, react.useState)("");
|
|
1502
|
+
const [vendor, setVendor] = (0, react.useState)(voices.clone[0] ?? "");
|
|
1503
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
1504
|
+
const [error, setError] = (0, react.useState)("");
|
|
1505
|
+
const file = (0, react.useRef)(null);
|
|
1506
|
+
(0, react.useEffect)(() => {
|
|
1507
|
+
fetch("/live/api/devices").then((r) => r.json()).then((j) => {
|
|
1508
|
+
const m = (j.devices ?? []).filter((d) => d.kind === "mic");
|
|
1509
|
+
setMics(m);
|
|
1510
|
+
setMic(m.find((d) => d.preferred)?.id ?? m[0]?.id ?? "");
|
|
1511
|
+
}).catch(() => {});
|
|
1512
|
+
}, []);
|
|
1513
|
+
(0, react.useEffect)(() => {
|
|
1514
|
+
if (rec?.state !== "recording") return;
|
|
1515
|
+
const timer = window.setInterval(() => {
|
|
1516
|
+
fetch("/live/api/voices/record").then((r) => r.json()).then((j) => {
|
|
1517
|
+
if (j.error) {
|
|
1518
|
+
setError(t("dub.micFailed").replace("{e}", j.error));
|
|
1519
|
+
stop("cancel");
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
if (j.state === "recording") setRec({
|
|
1523
|
+
state: "recording",
|
|
1524
|
+
sec: j.sec,
|
|
1525
|
+
level: j.level
|
|
1526
|
+
});
|
|
1527
|
+
if (j.sec >= 29.5) stop("stop");
|
|
1528
|
+
}).catch(() => {});
|
|
1529
|
+
}, 120);
|
|
1530
|
+
return () => window.clearInterval(timer);
|
|
1531
|
+
}, [rec?.state]);
|
|
1532
|
+
const startRec = async () => {
|
|
1533
|
+
setError("");
|
|
1534
|
+
setSample(null);
|
|
1535
|
+
try {
|
|
1536
|
+
await post$3("voices/record", {
|
|
1537
|
+
action: "start",
|
|
1538
|
+
mic
|
|
1539
|
+
});
|
|
1540
|
+
setRec({
|
|
1541
|
+
state: "recording",
|
|
1542
|
+
sec: 0,
|
|
1543
|
+
level: 0
|
|
1544
|
+
});
|
|
1545
|
+
} catch (e) {
|
|
1546
|
+
setError(e.message);
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
const stop = async (action) => {
|
|
1550
|
+
setRec((r) => r ? {
|
|
1551
|
+
...r,
|
|
1552
|
+
state: "stopping"
|
|
1553
|
+
} : r);
|
|
1554
|
+
try {
|
|
1555
|
+
const j = await post$3("voices/record", { action });
|
|
1556
|
+
if (j.sample) {
|
|
1557
|
+
if (j.dur < 4) setError(t("dub.tooShort"));
|
|
1558
|
+
else setSample({
|
|
1559
|
+
id: j.sample,
|
|
1560
|
+
dur: j.dur
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
} catch (e) {
|
|
1564
|
+
setError(e.message);
|
|
1565
|
+
}
|
|
1566
|
+
setRec(null);
|
|
1567
|
+
};
|
|
1568
|
+
const upload = async (f) => {
|
|
1569
|
+
setBusy(true);
|
|
1570
|
+
setError("");
|
|
1571
|
+
setSample(null);
|
|
1572
|
+
try {
|
|
1573
|
+
const r = await fetch("/live/api/voices/upload", {
|
|
1574
|
+
method: "POST",
|
|
1575
|
+
body: f
|
|
1576
|
+
});
|
|
1577
|
+
const j = await r.json();
|
|
1578
|
+
if (!r.ok) throw new Error(j.error);
|
|
1579
|
+
setSample({
|
|
1580
|
+
id: j.sample,
|
|
1581
|
+
dur: j.dur
|
|
1582
|
+
});
|
|
1583
|
+
setText("");
|
|
1584
|
+
if (!name) setName(f.name.replace(/\.[^.]+$/, "").slice(0, 30));
|
|
1585
|
+
} catch (e) {
|
|
1586
|
+
setError(e.message);
|
|
1587
|
+
}
|
|
1588
|
+
setBusy(false);
|
|
1589
|
+
};
|
|
1590
|
+
const fromVideo = async () => {
|
|
1591
|
+
if (!jobId) return;
|
|
1592
|
+
setBusy(true);
|
|
1593
|
+
setError("");
|
|
1594
|
+
setSample(null);
|
|
1595
|
+
try {
|
|
1596
|
+
const j = await post$3("voices/fromVideo", {
|
|
1597
|
+
jobId,
|
|
1598
|
+
at
|
|
1599
|
+
});
|
|
1600
|
+
setSample({
|
|
1601
|
+
id: j.sample,
|
|
1602
|
+
dur: j.dur
|
|
1603
|
+
});
|
|
1604
|
+
setText(j.text);
|
|
1605
|
+
if (!name) setName(t("dub.videoVoice"));
|
|
1606
|
+
} catch (e) {
|
|
1607
|
+
setError(e.message);
|
|
1608
|
+
}
|
|
1609
|
+
setBusy(false);
|
|
1610
|
+
};
|
|
1611
|
+
const save = async () => {
|
|
1612
|
+
if (!sample) return;
|
|
1613
|
+
setBusy(true);
|
|
1614
|
+
setError("");
|
|
1615
|
+
try {
|
|
1616
|
+
const j = await post$3("voices/save", {
|
|
1617
|
+
sample: sample.id,
|
|
1618
|
+
name: name.trim() || t("dub.myVoice"),
|
|
1619
|
+
text,
|
|
1620
|
+
vendor
|
|
1621
|
+
});
|
|
1622
|
+
onDone(j.id);
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
setError(e.message);
|
|
1625
|
+
}
|
|
1626
|
+
setBusy(false);
|
|
1627
|
+
};
|
|
1628
|
+
const pickSource = (s) => {
|
|
1629
|
+
if (rec) return;
|
|
1630
|
+
setSource(s);
|
|
1631
|
+
setSample(null);
|
|
1632
|
+
setError("");
|
|
1633
|
+
setText(s === "mic" ? voices.readAloud : "");
|
|
1634
|
+
};
|
|
1635
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1636
|
+
className: "dshl-modal",
|
|
1637
|
+
role: "dialog",
|
|
1638
|
+
"aria-modal": "true",
|
|
1639
|
+
onMouseDown: (e) => {
|
|
1640
|
+
if (e.target === e.currentTarget && !rec) onClose();
|
|
1641
|
+
},
|
|
1642
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1643
|
+
className: "box dshl-voicemaker",
|
|
1644
|
+
children: [
|
|
1645
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1646
|
+
className: "ghost x",
|
|
1647
|
+
onClick: () => {
|
|
1648
|
+
if (rec) stop("cancel");
|
|
1649
|
+
onClose();
|
|
1650
|
+
},
|
|
1651
|
+
title: t("login.close"),
|
|
1652
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.close, {})
|
|
1653
|
+
}),
|
|
1654
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("dub.makeTitle") }),
|
|
1655
|
+
!voices.clone.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("dub.noClone") }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1656
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1657
|
+
className: "dshl-seg src",
|
|
1658
|
+
children: [
|
|
1659
|
+
"mic",
|
|
1660
|
+
"file",
|
|
1661
|
+
"video"
|
|
1662
|
+
].filter((s) => s !== "video" || jobId).map((s) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1663
|
+
className: source === s ? "on" : "",
|
|
1664
|
+
onClick: () => pickSource(s),
|
|
1665
|
+
children: t(`dub.src.${s}`)
|
|
1666
|
+
}, s))
|
|
1667
|
+
}),
|
|
1668
|
+
source === "mic" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1669
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1670
|
+
className: "lead",
|
|
1671
|
+
children: t("dub.micLead")
|
|
1672
|
+
}),
|
|
1673
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("blockquote", {
|
|
1674
|
+
className: "read",
|
|
1675
|
+
children: voices.readAloud
|
|
1676
|
+
}),
|
|
1677
|
+
mics.length > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1678
|
+
className: "row",
|
|
1679
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dev.audio") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
1680
|
+
value: mic,
|
|
1681
|
+
onChange: setMic,
|
|
1682
|
+
options: mics.map((m) => ({
|
|
1683
|
+
value: m.id,
|
|
1684
|
+
label: m.name
|
|
1685
|
+
}))
|
|
1686
|
+
})]
|
|
1687
|
+
}),
|
|
1688
|
+
rec ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1689
|
+
className: "recbar",
|
|
1690
|
+
children: [
|
|
1691
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dot" }),
|
|
1692
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1693
|
+
className: "sec",
|
|
1694
|
+
children: [rec.sec.toFixed(0), "″"]
|
|
1695
|
+
}),
|
|
1696
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1697
|
+
className: "meter",
|
|
1698
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { style: { width: `${Math.min(100, rec.level * 140)}%` } })
|
|
1699
|
+
}),
|
|
1700
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1701
|
+
className: "dshl-btn primary",
|
|
1702
|
+
disabled: rec.state !== "recording",
|
|
1703
|
+
onClick: () => void stop("stop"),
|
|
1704
|
+
children: t("dub.recStop")
|
|
1705
|
+
})
|
|
1706
|
+
]
|
|
1707
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1708
|
+
className: "dshl-btn primary rec",
|
|
1709
|
+
onClick: () => void startRec(),
|
|
1710
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dot" }), sample ? t("dub.recAgain") : t("dub.recStart")]
|
|
1711
|
+
})
|
|
1712
|
+
] }),
|
|
1713
|
+
source === "file" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1714
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1715
|
+
className: "lead",
|
|
1716
|
+
children: t("dub.fileLead")
|
|
1717
|
+
}),
|
|
1718
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1719
|
+
ref: file,
|
|
1720
|
+
type: "file",
|
|
1721
|
+
accept: "audio/*,video/*",
|
|
1722
|
+
hidden: true,
|
|
1723
|
+
onChange: (e) => {
|
|
1724
|
+
const f = e.target.files?.[0];
|
|
1725
|
+
if (f) upload(f);
|
|
1726
|
+
e.target.value = "";
|
|
1727
|
+
}
|
|
1728
|
+
}),
|
|
1729
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1730
|
+
className: "dshl-btn",
|
|
1731
|
+
disabled: busy,
|
|
1732
|
+
onClick: () => file.current?.click(),
|
|
1733
|
+
children: busy ? t("dub.working") : t("dub.pickFile")
|
|
1734
|
+
})
|
|
1735
|
+
] }),
|
|
1736
|
+
source === "video" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1737
|
+
className: "lead",
|
|
1738
|
+
children: t("dub.videoLead")
|
|
1739
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1740
|
+
className: "dshl-btn",
|
|
1741
|
+
disabled: busy,
|
|
1742
|
+
onClick: () => void fromVideo(),
|
|
1743
|
+
children: busy ? t("dub.working") : t("dub.takeVideo")
|
|
1744
|
+
})] }),
|
|
1745
|
+
sample && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1746
|
+
className: "got",
|
|
1747
|
+
children: [
|
|
1748
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("audio", {
|
|
1749
|
+
controls: true,
|
|
1750
|
+
src: `/live/api/voices/sample?sample=${encodeURIComponent(sample.id)}`
|
|
1751
|
+
}),
|
|
1752
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1753
|
+
className: "row",
|
|
1754
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dub.name") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1755
|
+
className: "dshl-input",
|
|
1756
|
+
value: name,
|
|
1757
|
+
placeholder: t("dub.myVoice"),
|
|
1758
|
+
onChange: (e) => setName(e.target.value)
|
|
1759
|
+
})]
|
|
1760
|
+
}),
|
|
1761
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1762
|
+
className: "row top",
|
|
1763
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dub.said") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
|
|
1764
|
+
className: "dshl-input",
|
|
1765
|
+
rows: 3,
|
|
1766
|
+
value: text,
|
|
1767
|
+
placeholder: t("dub.saidHint"),
|
|
1768
|
+
onChange: (e) => setText(e.target.value)
|
|
1769
|
+
})]
|
|
1770
|
+
}),
|
|
1771
|
+
voices.clone.length > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1772
|
+
className: "row",
|
|
1773
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dub.by") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
1774
|
+
value: vendor,
|
|
1775
|
+
onChange: setVendor,
|
|
1776
|
+
options: voices.clone.map((v) => ({
|
|
1777
|
+
value: v,
|
|
1778
|
+
label: vendorName(v, t)
|
|
1779
|
+
}))
|
|
1780
|
+
})]
|
|
1781
|
+
}),
|
|
1782
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1783
|
+
className: "note",
|
|
1784
|
+
children: t("dub.privacy").replace("{s}", vendorName(vendor, t))
|
|
1785
|
+
}),
|
|
1786
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1787
|
+
className: "acts",
|
|
1788
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1789
|
+
className: "dshl-btn primary",
|
|
1790
|
+
disabled: busy || !text.trim(),
|
|
1791
|
+
onClick: () => void save(),
|
|
1792
|
+
children: busy ? t("dub.working") : t("dub.save")
|
|
1793
|
+
})
|
|
1794
|
+
})
|
|
1795
|
+
]
|
|
1796
|
+
}),
|
|
1797
|
+
error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1798
|
+
className: "err",
|
|
1799
|
+
children: error
|
|
1800
|
+
})
|
|
1801
|
+
] })
|
|
1802
|
+
]
|
|
1803
|
+
})
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// src/client/dub.ts
|
|
1808
|
+
/** How loud the original stays while a voice speaks (with the original kept). */
|
|
1809
|
+
const DUCK = .16;
|
|
1810
|
+
/** A voice that came in this late (a live stream) still plays, shifted (s). */
|
|
1811
|
+
const LATE_MAX = 4;
|
|
1812
|
+
/**
|
|
1813
|
+
* Plays the voiceover with the video: each line's voice starts when the
|
|
1814
|
+
* picture reaches it, follows pause, seek and speed, and the original sound
|
|
1815
|
+
* is turned down under it (or off). The video's sound goes through Web Audio
|
|
1816
|
+
* from the first time the voiceover is switched on (a click, so the browser
|
|
1817
|
+
* lets it play); from then on it is mixed here.
|
|
1818
|
+
*/
|
|
1819
|
+
function useVoiceover(video, jobId, view) {
|
|
1820
|
+
const ctx = (0, react.useRef)(null);
|
|
1821
|
+
const graph = (0, react.useRef)(null);
|
|
1822
|
+
const buffers = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
1823
|
+
const playing = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
1824
|
+
/** Clips already played (or skipped) in this stretch of playback: a late one plays once. */
|
|
1825
|
+
const done = (0, react.useRef)(/* @__PURE__ */ new Set());
|
|
1826
|
+
const last = (0, react.useRef)({
|
|
1827
|
+
t: 0,
|
|
1828
|
+
wall: 0
|
|
1829
|
+
});
|
|
1830
|
+
/** Media time where this stretch of continuous playback began (a seek or a pause starts a new one). */
|
|
1831
|
+
const stretch = (0, react.useRef)(0);
|
|
1832
|
+
const viewRef = (0, react.useRef)(view);
|
|
1833
|
+
viewRef.current = view;
|
|
1834
|
+
/** Route the video's sound through Web Audio (once per element). */
|
|
1835
|
+
const ensureGraph = () => {
|
|
1836
|
+
const el = video();
|
|
1837
|
+
if (!el) return false;
|
|
1838
|
+
if (graph.current?.el === el) return true;
|
|
1839
|
+
try {
|
|
1840
|
+
ctx.current ??= new AudioContext({ latencyHint: "interactive" });
|
|
1841
|
+
const c = ctx.current;
|
|
1842
|
+
const src = c.createMediaElementSource(el);
|
|
1843
|
+
const orig = c.createGain(), voice = c.createGain();
|
|
1844
|
+
src.connect(orig).connect(c.destination);
|
|
1845
|
+
voice.connect(c.destination);
|
|
1846
|
+
graph.current = {
|
|
1847
|
+
el,
|
|
1848
|
+
orig,
|
|
1849
|
+
voice
|
|
1850
|
+
};
|
|
1851
|
+
return true;
|
|
1852
|
+
} catch {
|
|
1853
|
+
return false;
|
|
1854
|
+
}
|
|
1855
|
+
};
|
|
1856
|
+
const stopAll = () => {
|
|
1857
|
+
for (const p of playing.current.values()) try {
|
|
1858
|
+
p.src.stop();
|
|
1859
|
+
} catch {}
|
|
1860
|
+
playing.current.clear();
|
|
1861
|
+
};
|
|
1862
|
+
const load = (clip) => {
|
|
1863
|
+
const key = `${clip.id}.${clip.v}`;
|
|
1864
|
+
if (buffers.current.has(key) || !jobId || !ctx.current) return;
|
|
1865
|
+
buffers.current.set(key, "loading");
|
|
1866
|
+
const c = ctx.current;
|
|
1867
|
+
fetch(`/live/api/dub/clip?jobId=${encodeURIComponent(jobId)}&id=${clip.id}&v=${clip.v}`).then((r) => r.ok ? r.arrayBuffer() : Promise.reject(new Error(String(r.status)))).then((b) => c.decodeAudioData(b)).then((buf) => {
|
|
1868
|
+
buffers.current.set(key, buf);
|
|
1869
|
+
}).catch(() => {
|
|
1870
|
+
buffers.current.set(key, "failed");
|
|
1871
|
+
window.setTimeout(() => buffers.current.delete(key), 5e3);
|
|
1872
|
+
});
|
|
1873
|
+
};
|
|
1874
|
+
(0, react.useEffect)(() => {
|
|
1875
|
+
if (!view?.on) return;
|
|
1876
|
+
if (ensureGraph()) ctx.current?.resume();
|
|
1877
|
+
}, [view?.on]);
|
|
1878
|
+
(0, react.useEffect)(() => {
|
|
1879
|
+
const tick = () => {
|
|
1880
|
+
const el = video();
|
|
1881
|
+
const v = viewRef.current;
|
|
1882
|
+
const g = graph.current;
|
|
1883
|
+
if (!g || !ctx.current || g.el !== el) {
|
|
1884
|
+
stopAll();
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
const c = ctx.current;
|
|
1888
|
+
const on = !!v?.on;
|
|
1889
|
+
const now = el.currentTime;
|
|
1890
|
+
const expected = last.current.t + (el.paused ? 0 : (performance.now() - last.current.wall) / 1e3 * el.playbackRate);
|
|
1891
|
+
if (Math.abs(now - expected) > .6) {
|
|
1892
|
+
stopAll();
|
|
1893
|
+
done.current.clear();
|
|
1894
|
+
stretch.current = now;
|
|
1895
|
+
}
|
|
1896
|
+
last.current = {
|
|
1897
|
+
t: now,
|
|
1898
|
+
wall: performance.now()
|
|
1899
|
+
};
|
|
1900
|
+
if (!on || el.paused || el.ended || el.seeking || el.readyState < 3) {
|
|
1901
|
+
for (const id of playing.current.keys()) done.current.delete(id);
|
|
1902
|
+
stretch.current = now;
|
|
1903
|
+
stopAll();
|
|
1904
|
+
g.orig.gain.setTargetAtTime(on && v?.original === "off" ? 0 : 1, c.currentTime, .05);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (c.state === "suspended") c.resume();
|
|
1908
|
+
const clips = v.clips;
|
|
1909
|
+
g.voice.gain.setTargetAtTime(el.muted ? 0 : el.volume, c.currentTime, .03);
|
|
1910
|
+
for (const clip of clips) {
|
|
1911
|
+
if (clip.at > now + 8) break;
|
|
1912
|
+
if (clip.at + clip.dur > now - 1) load(clip);
|
|
1913
|
+
}
|
|
1914
|
+
for (const clip of clips) {
|
|
1915
|
+
if (clip.at > now + .05) break;
|
|
1916
|
+
if (playing.current.has(clip.id) || done.current.has(clip.id)) continue;
|
|
1917
|
+
const offset = now - clip.at;
|
|
1918
|
+
if (offset >= clip.dur) continue;
|
|
1919
|
+
const buf = buffers.current.get(`${clip.id}.${clip.v}`);
|
|
1920
|
+
if (!buf || buf === "loading" || buf === "failed") {
|
|
1921
|
+
if (offset > LATE_MAX) done.current.add(clip.id);
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
const src = c.createBufferSource();
|
|
1925
|
+
src.buffer = buf;
|
|
1926
|
+
src.playbackRate.value = el.playbackRate;
|
|
1927
|
+
src.connect(g.voice);
|
|
1928
|
+
const late = offset > .35 && clip.at >= stretch.current + .1;
|
|
1929
|
+
src.start(0, late ? 0 : Math.max(0, offset));
|
|
1930
|
+
playing.current.set(clip.id, {
|
|
1931
|
+
src,
|
|
1932
|
+
at: clip.at
|
|
1933
|
+
});
|
|
1934
|
+
done.current.add(clip.id);
|
|
1935
|
+
src.onended = () => {
|
|
1936
|
+
if (playing.current.get(clip.id)?.src === src) playing.current.delete(clip.id);
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
for (const p of playing.current.values()) p.src.playbackRate.value = el.playbackRate;
|
|
1940
|
+
const target = v.original === "off" ? 0 : playing.current.size ? DUCK : 1;
|
|
1941
|
+
g.orig.gain.setTargetAtTime(target, c.currentTime, playing.current.size ? .04 : .25);
|
|
1942
|
+
};
|
|
1943
|
+
const timer = window.setInterval(tick, 50);
|
|
1944
|
+
return () => window.clearInterval(timer);
|
|
1945
|
+
}, [jobId]);
|
|
1946
|
+
(0, react.useEffect)(() => () => {
|
|
1947
|
+
stopAll();
|
|
1948
|
+
buffers.current.clear();
|
|
1949
|
+
done.current.clear();
|
|
1950
|
+
}, [jobId]);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1283
1953
|
// node_modules/hls.js/dist/hls.mjs
|
|
1284
1954
|
const isFiniteNumber = Number.isFinite || function(value) {
|
|
1285
1955
|
return typeof value === "number" && isFinite(value);
|
|
@@ -33698,6 +34368,7 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
|
|
|
33698
34368
|
if (video.current) video.current.currentTime = Math.max(0, time);
|
|
33699
34369
|
},
|
|
33700
34370
|
closeMenu: () => setMenu(null),
|
|
34371
|
+
media: () => video.current,
|
|
33701
34372
|
hold: (text, onSkip) => {
|
|
33702
34373
|
const el = video.current;
|
|
33703
34374
|
if (!el) return;
|
|
@@ -35665,6 +36336,9 @@ function LiveView(props) {
|
|
|
35665
36336
|
const [job, setJob] = (0, react.useState)(null);
|
|
35666
36337
|
const [cues, setCues] = (0, react.useState)([]);
|
|
35667
36338
|
const [terms, setTerms] = (0, react.useState)([]);
|
|
36339
|
+
/** Voiceover of this video (settings and the voices made so far), and the voices on offer. */
|
|
36340
|
+
const [dub, setDub] = (0, react.useState)(null);
|
|
36341
|
+
const [makingVoice, setMakingVoice] = (0, react.useState)(false);
|
|
35668
36342
|
const [tasks, setTasks] = (0, react.useState)([]);
|
|
35669
36343
|
const [armed, setArmed] = (0, react.useState)(null);
|
|
35670
36344
|
const [error, setError] = (0, react.useState)("");
|
|
@@ -35738,6 +36412,7 @@ function LiveView(props) {
|
|
|
35738
36412
|
store.set("device.audio", v);
|
|
35739
36413
|
};
|
|
35740
36414
|
const ui = t("view.live") === "Video" ? "en" : "zh";
|
|
36415
|
+
const { list: voices, reload: reloadVoices } = useVoices(ui);
|
|
35741
36416
|
const langOptions = (0, react.useMemo)(() => Object.entries(langs).filter(([k]) => k !== "auto").map(([k, v]) => [k, langLabel(k, ui, v)]), [langs, ui]);
|
|
35742
36417
|
const langName = (0, react.useCallback)((k) => k === "src" ? t("sub.source") : langLabel(k, ui, langs[k]), [
|
|
35743
36418
|
langs,
|
|
@@ -35806,12 +36481,40 @@ function LiveView(props) {
|
|
|
35806
36481
|
}
|
|
35807
36482
|
}).catch(() => {});
|
|
35808
36483
|
}), [sessionId]);
|
|
36484
|
+
useVoiceover(() => player.current?.media() ?? null, job?.id, dub);
|
|
36485
|
+
const dubAt = Math.floor(now / 5);
|
|
36486
|
+
(0, react.useEffect)(() => {
|
|
36487
|
+
if (!job?.id || !dub?.on) return;
|
|
36488
|
+
fetch("/live/api/dub/focus", {
|
|
36489
|
+
method: "POST",
|
|
36490
|
+
headers: { "content-type": "application/json" },
|
|
36491
|
+
body: JSON.stringify({
|
|
36492
|
+
jobId: job.id,
|
|
36493
|
+
at: now
|
|
36494
|
+
})
|
|
36495
|
+
}).catch(() => {});
|
|
36496
|
+
}, [
|
|
36497
|
+
job?.id,
|
|
36498
|
+
dub?.on,
|
|
36499
|
+
dubAt
|
|
36500
|
+
]);
|
|
36501
|
+
const dubLangs = (job?.tgtLangs ?? []).map((l) => ({
|
|
36502
|
+
value: l,
|
|
36503
|
+
label: langName(l)
|
|
36504
|
+
}));
|
|
36505
|
+
const makeVoice = () => {
|
|
36506
|
+
player.current?.closeMenu();
|
|
36507
|
+
reloadVoices();
|
|
36508
|
+
setMakingVoice(true);
|
|
36509
|
+
};
|
|
35809
36510
|
(0, react.useEffect)(() => {
|
|
35810
36511
|
if (!job?.id) return;
|
|
36512
|
+
setDub(null);
|
|
35811
36513
|
const es = new EventSource(`/live/api/events?jobId=${encodeURIComponent(job.id)}`);
|
|
35812
36514
|
es.addEventListener("state", (e) => setJob(JSON.parse(e.data)));
|
|
35813
36515
|
es.addEventListener("cues", (e) => setCues(JSON.parse(e.data)));
|
|
35814
36516
|
es.addEventListener("glossary", (e) => setTerms(JSON.parse(e.data)));
|
|
36517
|
+
es.addEventListener("dub", (e) => setDub(JSON.parse(e.data)));
|
|
35815
36518
|
es.addEventListener("cue", (e) => {
|
|
35816
36519
|
const cue = JSON.parse(e.data);
|
|
35817
36520
|
setCues((prev) => {
|
|
@@ -36913,6 +37616,22 @@ function LiveView(props) {
|
|
|
36913
37616
|
t
|
|
36914
37617
|
}) })
|
|
36915
37618
|
},
|
|
37619
|
+
{
|
|
37620
|
+
id: "dub",
|
|
37621
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.voice, {}),
|
|
37622
|
+
title: t("menu.dub"),
|
|
37623
|
+
label: dub?.on ? `${t("dub.title")} · ${langName(dub.lang)}` : void 0,
|
|
37624
|
+
on: !!dub?.on,
|
|
37625
|
+
content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DubMenu, {
|
|
37626
|
+
jobId: job.id,
|
|
37627
|
+
view: dub,
|
|
37628
|
+
langs: dubLangs,
|
|
37629
|
+
voices,
|
|
37630
|
+
onMake: makeVoice,
|
|
37631
|
+
at: now,
|
|
37632
|
+
t
|
|
37633
|
+
})
|
|
37634
|
+
},
|
|
36916
37635
|
{
|
|
36917
37636
|
id: "how",
|
|
36918
37637
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.timer, {}),
|
|
@@ -37072,6 +37791,23 @@ function LiveView(props) {
|
|
|
37072
37791
|
className: "dshl-dropzone",
|
|
37073
37792
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: t("in.drop") })
|
|
37074
37793
|
}),
|
|
37794
|
+
makingVoice && voices && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VoiceMaker, {
|
|
37795
|
+
jobId: job?.id,
|
|
37796
|
+
at: now,
|
|
37797
|
+
voices,
|
|
37798
|
+
t,
|
|
37799
|
+
onClose: () => setMakingVoice(false),
|
|
37800
|
+
onDone: (id) => {
|
|
37801
|
+
setMakingVoice(false);
|
|
37802
|
+
reloadVoices();
|
|
37803
|
+
if (job) post("dub", {
|
|
37804
|
+
jobId: job.id,
|
|
37805
|
+
voice: id,
|
|
37806
|
+
on: true,
|
|
37807
|
+
at: now
|
|
37808
|
+
}).catch((e) => setError(e.message));
|
|
37809
|
+
}
|
|
37810
|
+
}),
|
|
37075
37811
|
loginFor && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoginDialog, {
|
|
37076
37812
|
site: loginFor.need.site,
|
|
37077
37813
|
browser: loginBrowser,
|
|
@@ -37225,7 +37961,12 @@ function SubsStarter(props) {
|
|
|
37225
37961
|
}
|
|
37226
37962
|
const fmtTokens = (n, ui) => ui === "zh" && n >= 1e4 ? `${(n / 1e4).toFixed(n >= 1e5 ? 0 : 1)}万` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e5 ? 0 : 1)}k` : String(n);
|
|
37227
37963
|
/** Engines billed by the minute of audio (no token counts come back): US$ per minute, from the provider's price list. */
|
|
37228
|
-
|
|
37964
|
+
/** Rough prices per minute of audio: OpenAI interpretation, and spoken voiceover (OpenAI, Gemini speech). */
|
|
37965
|
+
const PER_MINUTE = {
|
|
37966
|
+
"openai-translate": .034,
|
|
37967
|
+
"voice-openai": .015,
|
|
37968
|
+
"voice-gemini": .015
|
|
37969
|
+
};
|
|
37229
37970
|
const audioCost = (audio) => audio.reduce((sum, [k, v]) => sum + (PER_MINUTE[k] ?? 0) * v / 60, 0);
|
|
37230
37971
|
const usd = (v) => `$${v.toFixed(2)}`;
|
|
37231
37972
|
/** Total tokens for this video; hover or click for per-model input / output / cache. */
|
|
@@ -37647,6 +38388,47 @@ const en = {
|
|
|
37647
38388
|
"sub.bg.light": "Light",
|
|
37648
38389
|
"sub.bg.dark": "Dark",
|
|
37649
38390
|
"menu.restream": "Restream",
|
|
38391
|
+
"menu.dub": "Voiceover",
|
|
38392
|
+
"dub.title": "Voiceover",
|
|
38393
|
+
"dub.sf": "SiliconFlow",
|
|
38394
|
+
"dub.mine": "My voices",
|
|
38395
|
+
"dub.noKey": "A voiceover needs a key for OpenAI, SiliconFlow, Gemini or ElevenLabs (settings, or the DSH Models page).",
|
|
38396
|
+
"dub.noLang": "Choose a subtitle language first: the voiceover speaks the subtitles.",
|
|
38397
|
+
"dub.lang": "Language",
|
|
38398
|
+
"dub.enable": "Voice the subtitles",
|
|
38399
|
+
"dub.voice": "Voice",
|
|
38400
|
+
"dub.pick": "Choose a voice",
|
|
38401
|
+
"dub.try": "Hear this voice",
|
|
38402
|
+
"dub.make": "Make a voice from a recording…",
|
|
38403
|
+
"dub.original": "Original sound",
|
|
38404
|
+
"dub.duck": "Lower it",
|
|
38405
|
+
"dub.off": "Off",
|
|
38406
|
+
"dub.failed": "Some lines could not be voiced: {e}",
|
|
38407
|
+
"dub.how": "Each subtitle line is spoken when the picture reaches it; only the part you watch or push out is voiced. Pushed streams and clips carry the voiceover too.",
|
|
38408
|
+
"dub.makeTitle": "Make a voice",
|
|
38409
|
+
"dub.noClone": "Learning a voice needs a SiliconFlow or ElevenLabs key (settings, or the DSH Models page).",
|
|
38410
|
+
"dub.src.mic": "Microphone",
|
|
38411
|
+
"dub.src.file": "Audio file",
|
|
38412
|
+
"dub.src.video": "From this video",
|
|
38413
|
+
"dub.micLead": "Read this aloud in your normal voice, in a quiet place (10–20 seconds).",
|
|
38414
|
+
"dub.recStart": "Start recording",
|
|
38415
|
+
"dub.recAgain": "Record again",
|
|
38416
|
+
"dub.recStop": "Done",
|
|
38417
|
+
"dub.micFailed": "The microphone could not be used: {e}",
|
|
38418
|
+
"dub.tooShort": "That was too short: record at least 5 seconds.",
|
|
38419
|
+
"dub.fileLead": "One person talking, clear, 10–30 seconds (longer files are cut).",
|
|
38420
|
+
"dub.pickFile": "Choose a file",
|
|
38421
|
+
"dub.videoLead": "Takes about ten seconds of someone talking near the current moment, with what they say from the subtitles.",
|
|
38422
|
+
"dub.takeVideo": "Take the voice from here",
|
|
38423
|
+
"dub.working": "Working…",
|
|
38424
|
+
"dub.name": "Name",
|
|
38425
|
+
"dub.said": "What is said",
|
|
38426
|
+
"dub.saidHint": "Type what the recording says (the service matches voice and words).",
|
|
38427
|
+
"dub.by": "Learned by",
|
|
38428
|
+
"dub.privacy": "The recording goes to {s} to learn the voice.",
|
|
38429
|
+
"dub.save": "Use this voice",
|
|
38430
|
+
"dub.myVoice": "My voice",
|
|
38431
|
+
"dub.videoVoice": "Voice from the video",
|
|
37650
38432
|
"rs.live": "Live",
|
|
37651
38433
|
"rs.title": "Push this video, with subtitles, to live platforms",
|
|
37652
38434
|
"rs.running": "Restreaming · {t}",
|
|
@@ -38023,6 +38805,10 @@ const en = {
|
|
|
38023
38805
|
"engine.local": "This computer (Whisper)",
|
|
38024
38806
|
"engine.openai": "OpenAI",
|
|
38025
38807
|
"engine.openai-translate": "OpenAI live translation",
|
|
38808
|
+
"engine.voice-openai": "Voiceover · OpenAI",
|
|
38809
|
+
"engine.voice-siliconflow": "Voiceover · SiliconFlow",
|
|
38810
|
+
"engine.voice-gemini": "Voiceover · Gemini",
|
|
38811
|
+
"engine.voice-elevenlabs": "Voiceover · ElevenLabs",
|
|
38026
38812
|
"set.engine.intl": "Online",
|
|
38027
38813
|
"set.recommended": "Recommended",
|
|
38028
38814
|
"set.others": "Other",
|
|
@@ -38118,6 +38904,47 @@ const zh = {
|
|
|
38118
38904
|
"sub.bg.light": "浅",
|
|
38119
38905
|
"sub.bg.dark": "深",
|
|
38120
38906
|
"menu.restream": "转播",
|
|
38907
|
+
"menu.dub": "配音",
|
|
38908
|
+
"dub.title": "配音",
|
|
38909
|
+
"dub.sf": "硅基流动",
|
|
38910
|
+
"dub.mine": "我的声音",
|
|
38911
|
+
"dub.noKey": "配音需要 OpenAI、硅基流动、Gemini 或 ElevenLabs 的密钥(在设置里,或 DSH 的模型页面添加)。",
|
|
38912
|
+
"dub.noLang": "先选一种字幕语言:配音读的就是字幕。",
|
|
38913
|
+
"dub.lang": "语言",
|
|
38914
|
+
"dub.enable": "给字幕配上声音",
|
|
38915
|
+
"dub.voice": "声音",
|
|
38916
|
+
"dub.pick": "选一个声音",
|
|
38917
|
+
"dub.try": "试听这个声音",
|
|
38918
|
+
"dub.make": "用一段录音做个声音…",
|
|
38919
|
+
"dub.original": "原声",
|
|
38920
|
+
"dub.duck": "压低",
|
|
38921
|
+
"dub.off": "关掉",
|
|
38922
|
+
"dub.failed": "有几句没配上:{e}",
|
|
38923
|
+
"dub.how": "画面放到哪一句,就念哪一句;只给正在看或正在推流的部分配音。推流和剪下的片段也带配音。",
|
|
38924
|
+
"dub.makeTitle": "做一个声音",
|
|
38925
|
+
"dub.noClone": "学声音需要硅基流动或 ElevenLabs 的密钥(在设置里,或 DSH 的模型页面添加)。",
|
|
38926
|
+
"dub.src.mic": "麦克风录",
|
|
38927
|
+
"dub.src.file": "音频文件",
|
|
38928
|
+
"dub.src.video": "视频里的人声",
|
|
38929
|
+
"dub.micLead": "找个安静的地方,用平时说话的语气读下面这段(10–20 秒)。",
|
|
38930
|
+
"dub.recStart": "开始录音",
|
|
38931
|
+
"dub.recAgain": "重新录",
|
|
38932
|
+
"dub.recStop": "录好了",
|
|
38933
|
+
"dub.micFailed": "麦克风用不了:{e}",
|
|
38934
|
+
"dub.tooShort": "太短了,至少录 5 秒。",
|
|
38935
|
+
"dub.fileLead": "一个人在说话、声音清楚,10–30 秒(更长的只取前 30 秒)。",
|
|
38936
|
+
"dub.pickFile": "选择文件",
|
|
38937
|
+
"dub.videoLead": "从当前画面附近取十秒左右一个人说话的声音,说的内容取自字幕。",
|
|
38938
|
+
"dub.takeVideo": "取这里的人声",
|
|
38939
|
+
"dub.working": "处理中…",
|
|
38940
|
+
"dub.name": "名字",
|
|
38941
|
+
"dub.said": "说的内容",
|
|
38942
|
+
"dub.saidHint": "写下录音里说的话(服务会把声音和文字对上)。",
|
|
38943
|
+
"dub.by": "交给",
|
|
38944
|
+
"dub.privacy": "录音会发给{s},用来学这个声音。",
|
|
38945
|
+
"dub.save": "用这个声音",
|
|
38946
|
+
"dub.myVoice": "我的声音",
|
|
38947
|
+
"dub.videoVoice": "视频里的声音",
|
|
38121
38948
|
"rs.live": "转播中",
|
|
38122
38949
|
"rs.title": "把这个视频连同字幕推到直播平台",
|
|
38123
38950
|
"rs.running": "转播中 · {t}",
|
|
@@ -38494,6 +39321,10 @@ const zh = {
|
|
|
38494
39321
|
"engine.local": "本机(Whisper)",
|
|
38495
39322
|
"engine.openai": "OpenAI",
|
|
38496
39323
|
"engine.openai-translate": "OpenAI 实时翻译",
|
|
39324
|
+
"engine.voice-openai": "配音 · OpenAI",
|
|
39325
|
+
"engine.voice-siliconflow": "配音 · 硅基流动",
|
|
39326
|
+
"engine.voice-gemini": "配音 · Gemini",
|
|
39327
|
+
"engine.voice-elevenlabs": "配音 · ElevenLabs",
|
|
38497
39328
|
"set.engine.intl": "在线 · 海外",
|
|
38498
39329
|
"set.recommended": "推荐",
|
|
38499
39330
|
"set.others": "其他",
|
|
@@ -39033,6 +39864,54 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
|
|
|
39033
39864
|
.dshl-submenu .seg button { flex: 1; min-width: 0; height: 26px; padding: 0 10px; border: 0; border-radius: 6px; background: none; color: var(--muted); font-size: 12.5px; cursor: pointer; }
|
|
39034
39865
|
.dshl-submenu .seg button.on { background: rgba(255,255,255,.92); color: #111; font-weight: 600; }
|
|
39035
39866
|
|
|
39867
|
+
/* Voiceover (player menu) */
|
|
39868
|
+
.dshl-dubmenu { width: 320px; max-width: 100%; }
|
|
39869
|
+
.dshl-dubmenu .opts { position: static; margin: 0 -8px -10px; }
|
|
39870
|
+
.dshl-dubmenu .opt { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 34px; padding: 0 6px; color: var(--muted); }
|
|
39871
|
+
.dshl-dubmenu .mt { margin: 4px 6px 2px; color: rgba(255,255,255,.45); font-size: 11.5px; line-height: 1.5; }
|
|
39872
|
+
.dshl-dubmenu .err { margin: 4px 6px; color: #ff8a8d; font-size: 12px; line-height: 1.45; }
|
|
39873
|
+
.dshl-dubmenu .vrow { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
39874
|
+
.dshl-dubmenu .vrow > :first-child { flex: 1; min-width: 0; }
|
|
39875
|
+
.dshl-dubmenu .try { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border: 0; border-radius: 8px; background: var(--l2); color: var(--ink); cursor: pointer; }
|
|
39876
|
+
.dshl-dubmenu .try:hover:not(:disabled) { background: rgba(255,255,255,.18); }
|
|
39877
|
+
.dshl-dubmenu .try svg { width: 16px; height: 16px; }
|
|
39878
|
+
.dshl-dubmenu .spin { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: dshl-spin .8s linear infinite; }
|
|
39879
|
+
@keyframes dshl-spin { to { transform: rotate(360deg); } }
|
|
39880
|
+
.dshl-switchrow { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 8px 6px 10px; border: 0; background: none; color: inherit; cursor: pointer; }
|
|
39881
|
+
.dshl-switchrow:disabled { opacity: .5; cursor: default; }
|
|
39882
|
+
.dshl-switchrow .n { font-size: 13.5px; font-weight: 600; }
|
|
39883
|
+
.dshl-switchrow .sw { position: relative; width: 34px; height: 20px; border-radius: 999px; background: rgba(255,255,255,.22); transition: background .15s; }
|
|
39884
|
+
.dshl-switchrow .sw i { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
|
39885
|
+
.dshl-switchrow.on .sw { background: #3d6df2; }
|
|
39886
|
+
.dshl-switchrow.on .sw i { transform: translateX(14px); }
|
|
39887
|
+
|
|
39888
|
+
/* Making a voice (dialog) */
|
|
39889
|
+
.dshl-voicemaker { width: min(440px, 100%) !important; text-align: left !important; }
|
|
39890
|
+
.dshl-voicemaker h3 { text-align: center; }
|
|
39891
|
+
.dshl-voicemaker .src { display: flex; width: 100%; margin: 6px 0 14px; }
|
|
39892
|
+
.dshl-voicemaker .src button { flex: 1; }
|
|
39893
|
+
.dshl-voicemaker .src button.on { background: var(--bg); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
|
|
39894
|
+
.dshl-voicemaker .lead { margin: 0 0 10px; text-align: left; }
|
|
39895
|
+
.dshl-voicemaker .read { margin: 0 0 14px; padding: 12px 14px; border-radius: 10px; background: var(--l1); color: var(--ink); font-size: 15px; line-height: 1.75; }
|
|
39896
|
+
.dshl-voicemaker .row { display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 10px; margin: 0 0 10px; color: var(--muted); font-size: 13px; }
|
|
39897
|
+
.dshl-voicemaker .row.top { align-items: start; }
|
|
39898
|
+
.dshl-voicemaker .row.top > span { padding-top: 8px; }
|
|
39899
|
+
.dshl-voicemaker .dshl-input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); font: inherit; font-size: 13.5px; resize: vertical; outline: 0; box-sizing: border-box; }
|
|
39900
|
+
.dshl-voicemaker .dshl-input:focus { border-color: var(--accent); }
|
|
39901
|
+
.dshl-voicemaker > .dshl-btn { width: 100%; height: 40px; }
|
|
39902
|
+
.dshl-voicemaker .rec .dot, .dshl-voicemaker .recbar .dot { width: 9px; height: 9px; border-radius: 50%; background: #fff; }
|
|
39903
|
+
.dshl-voicemaker .recbar { display: flex; align-items: center; gap: 10px; height: 40px; }
|
|
39904
|
+
.dshl-voicemaker .recbar .dot { background: var(--live); animation: dshl-pulse 1.4s ease-in-out infinite; }
|
|
39905
|
+
.dshl-voicemaker .recbar .sec { width: 30px; color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
39906
|
+
.dshl-voicemaker .recbar .meter { flex: 1; height: 6px; overflow: hidden; border-radius: 999px; background: var(--l2); }
|
|
39907
|
+
.dshl-voicemaker .recbar .meter i { display: block; height: 100%; border-radius: inherit; background: var(--ok); transition: width .1s; }
|
|
39908
|
+
.dshl-voicemaker .recbar .dshl-btn { height: 36px; }
|
|
39909
|
+
.dshl-voicemaker .got { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-soft); }
|
|
39910
|
+
.dshl-voicemaker .got audio { display: block; width: 100%; height: 36px; margin-bottom: 12px; }
|
|
39911
|
+
.dshl-voicemaker .note { margin: 2px 0 0; color: var(--faint); font-size: 12px; text-align: left; }
|
|
39912
|
+
.dshl-voicemaker .acts { margin-top: 12px; }
|
|
39913
|
+
.dshl-voicemaker .err { margin: 10px 0 0; color: var(--live); font-size: 12.5px; text-align: left; }
|
|
39914
|
+
|
|
39036
39915
|
/* Download (inside the dark menu) */
|
|
39037
39916
|
/* Restream (player menu) */
|
|
39038
39917
|
.dshl-rs { display: grid; gap: 10px; padding: 0 6px; }
|