dsh-vidi 0.10.0 → 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/lib/index.mjs +551 -126
- package/package.json +3 -2
package/lib/index.mjs
CHANGED
|
@@ -360,6 +360,286 @@ var Glossary = class {
|
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
362
|
};
|
|
363
|
+
// src/align.ts
|
|
364
|
+
/**
|
|
365
|
+
* Does each translation sit on its own line? A model translating several lines at once sometimes
|
|
366
|
+
* joins two and writes every later translation one line off. Numbers and Latin-script words
|
|
367
|
+
* (names, products) survive translation as they are, so they tell which line a translation
|
|
368
|
+
* belongs to.
|
|
369
|
+
*/
|
|
370
|
+
const CJK_NUM = /[〇零一二两三四五六七八九十百千万亿萬億]{2}/;
|
|
371
|
+
const LATIN_STOP = /* @__PURE__ */ new Set([
|
|
372
|
+
"the",
|
|
373
|
+
"and",
|
|
374
|
+
"for",
|
|
375
|
+
"that",
|
|
376
|
+
"this",
|
|
377
|
+
"with",
|
|
378
|
+
"you",
|
|
379
|
+
"are",
|
|
380
|
+
"was",
|
|
381
|
+
"not",
|
|
382
|
+
"but",
|
|
383
|
+
"have",
|
|
384
|
+
"from",
|
|
385
|
+
"they",
|
|
386
|
+
"what",
|
|
387
|
+
"ok",
|
|
388
|
+
"okay"
|
|
389
|
+
]);
|
|
390
|
+
/** The numbers in a text ("1,200" = "1200"; "5.5" stays one number). */
|
|
391
|
+
function numbers(text) {
|
|
392
|
+
return new Set((text.match(/\d+(?:[.,]\d+)*/g) ?? []).map((n) => n.replace(/,(?=\d{3}\b)/g, "").replace(/,/g, ".")));
|
|
393
|
+
}
|
|
394
|
+
/** Latin-script words of a text, lowercased (short and everyday ones left out). */
|
|
395
|
+
function words(text) {
|
|
396
|
+
return new Set((text.toLowerCase().match(/[a-z][a-z'’-]*[a-z]/g) ?? []).filter((w) => w.length > 2 && !LATIN_STOP.has(w)));
|
|
397
|
+
}
|
|
398
|
+
const NON_LATIN = /* @__PURE__ */ new Set([
|
|
399
|
+
"zh",
|
|
400
|
+
"zh-Hant",
|
|
401
|
+
"ja",
|
|
402
|
+
"ko",
|
|
403
|
+
"ru",
|
|
404
|
+
"ar",
|
|
405
|
+
"hi",
|
|
406
|
+
"th"
|
|
407
|
+
]);
|
|
408
|
+
/** Is this translation of line `i` more likely a neighbour's? */
|
|
409
|
+
function shifted(lines, i, lang) {
|
|
410
|
+
const me = lines[i], tr = me.tr?.[lang];
|
|
411
|
+
if (!tr) return false;
|
|
412
|
+
const near = [lines[i - 1], lines[i + 1]].filter((x) => !!x);
|
|
413
|
+
const srcN = numbers(me.src), trN = numbers(tr);
|
|
414
|
+
for (const n of trN) if (!srcN.has(n) && near.some((x) => numbers(x.src).has(n))) return true;
|
|
415
|
+
for (const n of srcN) if (!trN.has(n) && near.some((x) => !numbers(x.src).has(n) && numbers(x.tr?.[lang] ?? "").has(n))) return true;
|
|
416
|
+
if (srcN.size && !trN.size && !(NON_LATIN.has(lang) && CJK_NUM.test(tr)) && [...srcN].some((n) => n.length > 1)) return true;
|
|
417
|
+
if (NON_LATIN.has(lang)) {
|
|
418
|
+
const srcW = words(me.src);
|
|
419
|
+
for (const w of words(tr)) if (!srcW.has(w) && near.some((x) => words(x.src).has(w))) return true;
|
|
420
|
+
}
|
|
421
|
+
if (near.some((x) => x.tr?.[lang] === tr && x.src.trim() !== me.src.trim()) && tr.length > 4) return true;
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The lines whose translation cannot be trusted: missing, or looking like a neighbour's. A shift
|
|
426
|
+
* runs on over the following lines, so everything between the first and the last sign of one
|
|
427
|
+
* (and a line either side) is included.
|
|
428
|
+
*/
|
|
429
|
+
function suspects(lines, targets) {
|
|
430
|
+
const bad = /* @__PURE__ */ new Set();
|
|
431
|
+
let first = -1, last = -1;
|
|
432
|
+
lines.forEach((l, i) => {
|
|
433
|
+
if (targets.some((t) => !l.tr?.[t]?.trim())) {
|
|
434
|
+
bad.add(l.id);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (targets.some((t) => shifted(lines, i, t))) {
|
|
438
|
+
if (first < 0) first = i;
|
|
439
|
+
last = i;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
if (first >= 0) for (let i = Math.max(0, first - 1); i <= Math.min(lines.length - 1, last + 1); i++) bad.add(lines[i].id);
|
|
443
|
+
return bad;
|
|
444
|
+
}
|
|
445
|
+
/** A reply's ids against the lines asked for: unknown, repeated or out of order means the numbering cannot be trusted. */
|
|
446
|
+
function misnumbered(asked, got) {
|
|
447
|
+
let at = -1;
|
|
448
|
+
const seen = /* @__PURE__ */ new Set();
|
|
449
|
+
for (const id of got) {
|
|
450
|
+
const i = asked.indexOf(id);
|
|
451
|
+
if (i < 0 || seen.has(id) || i < at) return true;
|
|
452
|
+
seen.add(id);
|
|
453
|
+
at = i;
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
/** How alike two versions of a line are (0–1): shared words, or shared characters where there are no spaces. */
|
|
458
|
+
function alike(a, b) {
|
|
459
|
+
const units = (s) => {
|
|
460
|
+
const w = s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
461
|
+
return new Set(w.length > 2 && !/[-鿿가-]/.test(s) ? w : [...s.replace(/[\s\p{P}]/gu, "")]);
|
|
462
|
+
};
|
|
463
|
+
const x = units(a), y = units(b);
|
|
464
|
+
if (!x.size || !y.size) return x.size === y.size ? 1 : 0;
|
|
465
|
+
let both = 0;
|
|
466
|
+
for (const u of x) if (y.has(u)) both++;
|
|
467
|
+
return both / Math.min(x.size, y.size);
|
|
468
|
+
}
|
|
469
|
+
// src/ask.ts
|
|
470
|
+
/**
|
|
471
|
+
* How long one request may take (ms). A request that sends nothing for `first` (before its first
|
|
472
|
+
* piece) or `idle` (between pieces) hangs: it is given up and asked again. One that keeps sending
|
|
473
|
+
* is alive (a model thinking at length), and only given up after `total`, or `perToken` for each
|
|
474
|
+
* token of room it was given if that is longer.
|
|
475
|
+
*/
|
|
476
|
+
const LIMITS = {
|
|
477
|
+
first: 6e4,
|
|
478
|
+
idle: 4e4,
|
|
479
|
+
total: 18e4,
|
|
480
|
+
perToken: 40,
|
|
481
|
+
pause: 1500
|
|
482
|
+
};
|
|
483
|
+
/** The most a reply may be given, whatever the model. */
|
|
484
|
+
const MAX_BUDGET = 32e3;
|
|
485
|
+
/** Models seen thinking before they answer (their thinking counts against the reply's size). */
|
|
486
|
+
const thinkers = /* @__PURE__ */ new Set();
|
|
487
|
+
/** Models that refused a reply size: the largest they may be asked for. */
|
|
488
|
+
const caps = /* @__PURE__ */ new Map();
|
|
489
|
+
var AskError = class extends Error {
|
|
490
|
+
empty;
|
|
491
|
+
/** `empty`: the model answered, but with nothing usable (smaller pieces may do); otherwise it could not be reached. */
|
|
492
|
+
constructor(message, empty = false) {
|
|
493
|
+
super(message);
|
|
494
|
+
this.empty = empty;
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
/** Room for a reply: the answer, plus the thinking of a model that thinks first. */
|
|
498
|
+
function budgetFor(key, answer, grown = 1) {
|
|
499
|
+
const want = (thinkers.has(key) ? answer * 2 + 6e3 : answer) * grown;
|
|
500
|
+
return Math.round(Math.min(want, caps.get(key) ?? MAX_BUDGET, MAX_BUDGET));
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* One question to the subtitle model, answered as text. A request that hangs is cut off and asked
|
|
504
|
+
* again; one that fails is asked again after a pause; an answer that came back empty or cut short
|
|
505
|
+
* (a thinking model spent the room on its thoughts) is asked again with more room. Throws when no
|
|
506
|
+
* try gave an answer, or at once when `signal` says to stop.
|
|
507
|
+
*/
|
|
508
|
+
async function ask(handle, o) {
|
|
509
|
+
const { provider, model } = handle.selection();
|
|
510
|
+
const key = `${provider}/${model}`;
|
|
511
|
+
const tries = o.tries ?? 3;
|
|
512
|
+
let grown = 1;
|
|
513
|
+
let last = "no answer", empty = false;
|
|
514
|
+
for (let n = 0; n < tries; n++) {
|
|
515
|
+
if (o.signal?.aborted) throw new AskError("stopped");
|
|
516
|
+
const maxTokens = budgetFor(key, o.maxTokens, grown);
|
|
517
|
+
let r;
|
|
518
|
+
try {
|
|
519
|
+
r = await once(handle, {
|
|
520
|
+
...o,
|
|
521
|
+
provider,
|
|
522
|
+
model,
|
|
523
|
+
maxTokens
|
|
524
|
+
});
|
|
525
|
+
} catch (err) {
|
|
526
|
+
if (o.signal?.aborted) throw new AskError("stopped");
|
|
527
|
+
last = err.message || String(err);
|
|
528
|
+
empty = false;
|
|
529
|
+
if (/max[_ ]?(completion[_ ])?tokens|maxTokens/i.test(last) && maxTokens > 1024) caps.set(key, Math.floor(maxTokens / 2));
|
|
530
|
+
else if (/^4\d\d\b/.test(last) && !/^4(08|09|25|29)\b/.test(last)) throw new AskError(last);
|
|
531
|
+
o.log?.(`${o.purpose}: ${last}${n + 1 < tries ? ", asking again" : ""}`);
|
|
532
|
+
if (n + 1 < tries) await sleep(LIMITS.pause * (n + 1), o.signal);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (r.thought) thinkers.add(key);
|
|
536
|
+
if (r.text.trim() && !r.cut) return r.text;
|
|
537
|
+
if (r.cut || r.thought) thinkers.add(key);
|
|
538
|
+
last = r.cut ? `answer cut short at ${maxTokens} tokens` : "empty answer";
|
|
539
|
+
empty = true;
|
|
540
|
+
const more = budgetFor(key, o.maxTokens, grown * 2);
|
|
541
|
+
o.log?.(`${o.purpose}: ${last}${r.thought ? " (the model was thinking)" : ""}${n + 1 < tries ? `, asking again with room for ${more}` : ""}`);
|
|
542
|
+
if (more <= maxTokens && r.text.trim()) return r.text;
|
|
543
|
+
grown *= 2;
|
|
544
|
+
}
|
|
545
|
+
throw new AskError(last, empty);
|
|
546
|
+
}
|
|
547
|
+
async function once(handle, o) {
|
|
548
|
+
const ac = new AbortController();
|
|
549
|
+
const outer = () => ac.abort();
|
|
550
|
+
o.signal?.addEventListener("abort", outer, { once: true });
|
|
551
|
+
let giveUp;
|
|
552
|
+
const gaveUp = new Promise((_, reject) => {
|
|
553
|
+
giveUp = reject;
|
|
554
|
+
});
|
|
555
|
+
gaveUp.catch(() => {});
|
|
556
|
+
ac.signal.addEventListener("abort", () => giveUp(new AskError("stopped")), { once: true });
|
|
557
|
+
const started = Date.now();
|
|
558
|
+
const total = Math.max(LIMITS.total, o.maxTokens * LIMITS.perToken);
|
|
559
|
+
let timer;
|
|
560
|
+
const arm = (ms, what) => {
|
|
561
|
+
clearTimeout(timer);
|
|
562
|
+
const left = Math.min(ms, total - (Date.now() - started));
|
|
563
|
+
timer = setTimeout(() => {
|
|
564
|
+
giveUp(new AskError(left < ms ? `no complete answer in ${Math.round(total / 1e3)} s` : what));
|
|
565
|
+
ac.abort();
|
|
566
|
+
}, Math.max(0, left));
|
|
567
|
+
};
|
|
568
|
+
const it = handle.stream({
|
|
569
|
+
provider: o.provider,
|
|
570
|
+
model: o.model,
|
|
571
|
+
system: o.system,
|
|
572
|
+
maxTokens: o.maxTokens,
|
|
573
|
+
purpose: o.purpose,
|
|
574
|
+
signal: ac.signal,
|
|
575
|
+
messages: [createUserMessage({
|
|
576
|
+
content: [...o.image ? [o.image] : [], {
|
|
577
|
+
type: "text",
|
|
578
|
+
text: o.text
|
|
579
|
+
}],
|
|
580
|
+
source: {
|
|
581
|
+
kind: "plugin",
|
|
582
|
+
plugin: "dsh-vidi"
|
|
583
|
+
}
|
|
584
|
+
})]
|
|
585
|
+
})[Symbol.asyncIterator]();
|
|
586
|
+
const asm = new BlockAssembler();
|
|
587
|
+
let cut = false, thought = false;
|
|
588
|
+
try {
|
|
589
|
+
arm(LIMITS.first, `no answer in ${Math.round(LIMITS.first / 1e3)} s`);
|
|
590
|
+
for (;;) {
|
|
591
|
+
const step = await Promise.race([it.next(), gaveUp]);
|
|
592
|
+
if (step.done) break;
|
|
593
|
+
const c = step.value;
|
|
594
|
+
arm(LIMITS.idle, `answer stalled for ${Math.round(LIMITS.idle / 1e3)} s`);
|
|
595
|
+
if (c.type === "reasoning-delta") thought = true;
|
|
596
|
+
else if (c.type === "usage") {
|
|
597
|
+
if ((c.usage?.reasoningTokens ?? 0) > 0) thought = true;
|
|
598
|
+
} else if (c.type === "finish") {
|
|
599
|
+
const kind = c.reason?.kind;
|
|
600
|
+
if (kind === "max-tokens") cut = true;
|
|
601
|
+
else if (kind === "error" || kind === "aborted") throw new AskError(c.reason?.failure?.message || `request ${kind}`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
asm.push(c);
|
|
605
|
+
}
|
|
606
|
+
} finally {
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
o.signal?.removeEventListener("abort", outer);
|
|
609
|
+
Promise.resolve(it.return?.()).catch(() => {});
|
|
610
|
+
}
|
|
611
|
+
const blocks = asm.blocks();
|
|
612
|
+
if (blocks.some((b) => b.type === "reasoning")) thought = true;
|
|
613
|
+
return {
|
|
614
|
+
text: blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join(""),
|
|
615
|
+
cut,
|
|
616
|
+
thought
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function sleep(ms, signal) {
|
|
620
|
+
return new Promise((resolve) => {
|
|
621
|
+
const t = setTimeout(done, ms);
|
|
622
|
+
function done() {
|
|
623
|
+
clearTimeout(t);
|
|
624
|
+
signal?.removeEventListener("abort", done);
|
|
625
|
+
resolve();
|
|
626
|
+
}
|
|
627
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
/** The first complete JSON value of a kind in a reply (models wrap it in prose or code fences). */
|
|
631
|
+
function jsonIn(out, open) {
|
|
632
|
+
const close = open === "{" ? "}" : "]";
|
|
633
|
+
for (let from = out.indexOf(open); from >= 0; from = out.indexOf(open, from + 1)) {
|
|
634
|
+
for (let to = out.lastIndexOf(close); to > from; to = out.lastIndexOf(close, to - 1)) {
|
|
635
|
+
try {
|
|
636
|
+
return JSON.parse(out.slice(from, to + 1));
|
|
637
|
+
} catch {}
|
|
638
|
+
if (out.length - to > 2e3) break;
|
|
639
|
+
}
|
|
640
|
+
if (from > 2e3) break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
363
643
|
// src/translate.ts
|
|
364
644
|
/** Display names used in prompts (the model understands English names best). */
|
|
365
645
|
const LANGS = {
|
|
@@ -398,54 +678,106 @@ var Translator = class {
|
|
|
398
678
|
get available() {
|
|
399
679
|
return this.llm() !== void 0;
|
|
400
680
|
}
|
|
401
|
-
async translate(targets, line, context, glossary = "", signal, reference = "") {
|
|
681
|
+
async translate(targets, line, context, glossary = "", signal, reference = "", log) {
|
|
402
682
|
const handle = this.llm();
|
|
403
|
-
if (!handle
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
683
|
+
if (!handle) return void 0;
|
|
684
|
+
return translateLine(handle, targets, line, context, glossary, reference, {
|
|
685
|
+
signal,
|
|
686
|
+
log
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
/**
|
|
691
|
+
* One line translated on its own (the lines before only as context). A reply that is really the
|
|
692
|
+
* line before it (its numbers or names, not this line's) is asked for again without the context.
|
|
693
|
+
*/
|
|
694
|
+
async function translateLine(handle, targets, line, context, glossary = "", reference = "", o = {}) {
|
|
695
|
+
if (!line.trim() || targets.length === 0) return void 0;
|
|
696
|
+
const system = [
|
|
697
|
+
`You translate live speech subtitles. Target languages: ${targets.map((t) => `"${t}" (${LANGS[t] ?? t})`).join(", ")}.`,
|
|
698
|
+
"Translate only the final line. Be faithful, natural and concise, like professional subtitles, with normal punctuation.",
|
|
699
|
+
"Follow the glossary when it covers a term. Otherwise keep brand, product and model names in their original form, and give well-known people, places and organisations their standard name in the target language.",
|
|
700
|
+
"If the line is a fragment, translate the fragment as-is; do not complete it.",
|
|
701
|
+
`Reply with one JSON object mapping each language code to its translation, e.g. {${targets.map((t) => `"${t}": "..."`).join(", ")}} — nothing else.`
|
|
702
|
+
].join(" ");
|
|
703
|
+
const once = async (ctxLines) => {
|
|
704
|
+
const ctx = ctxLines.slice(-6).map((c) => `- ${c.src}${c.tr ? `\n => ${JSON.stringify(c.tr)}` : ""}`).join("\n");
|
|
412
705
|
const text = [
|
|
413
706
|
glossary ? `Glossary:\n${glossary}\n` : "",
|
|
414
|
-
ctx ? `Earlier lines (context, already translated):\n${ctx}\n` : "",
|
|
707
|
+
ctx ? `Earlier lines (context, already translated — do not translate these):\n${ctx}\n` : "",
|
|
415
708
|
reference ? `The video's own subtitles around this moment (from the site; may be inaccurate or misaligned — use them to fix names, terms and misheard words, not as the answer):\n${reference}\n` : "",
|
|
416
709
|
`Line to translate:\n${line}`
|
|
417
710
|
].join("\n");
|
|
418
|
-
|
|
419
|
-
const assembler = new BlockAssembler();
|
|
420
|
-
for await (const chunk of handle.stream({
|
|
421
|
-
provider,
|
|
422
|
-
model,
|
|
711
|
+
return parseTranslations((await ask(handle, {
|
|
423
712
|
system,
|
|
713
|
+
text,
|
|
424
714
|
maxTokens: 300 + 300 * targets.length,
|
|
425
715
|
purpose: "live-subtitle-translate",
|
|
426
|
-
signal,
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
})]
|
|
437
|
-
})) assembler.push(chunk);
|
|
438
|
-
return parseTranslations(assembler.blocks().filter((b) => b.type === "text").map((b) => b.text ?? "").join("").trim(), targets);
|
|
716
|
+
signal: o.signal,
|
|
717
|
+
log: o.log
|
|
718
|
+
})).trim(), targets);
|
|
719
|
+
};
|
|
720
|
+
const tr = await once(context);
|
|
721
|
+
const before = context.at(-1);
|
|
722
|
+
if (tr && before && targets.some((t) => borrowed(tr[t] ?? "", line, before.src))) {
|
|
723
|
+
o.log?.("translate: the answer belongs to the line before, asking again without context");
|
|
724
|
+
return await once([]) ?? tr;
|
|
439
725
|
}
|
|
440
|
-
|
|
441
|
-
|
|
726
|
+
return tr;
|
|
727
|
+
}
|
|
728
|
+
/** Does a translation carry a number or name that `other` has and its own line does not? */
|
|
729
|
+
function borrowed(tr, own, other) {
|
|
730
|
+
const n = numbers(own), on = numbers(other);
|
|
731
|
+
for (const x of numbers(tr)) if (!n.has(x) && on.has(x)) return true;
|
|
732
|
+
if (/[^\x00-ɏ]/.test(tr)) {
|
|
733
|
+
const w = words(own), ow = words(other);
|
|
734
|
+
for (const x of words(tr)) if (!w.has(x) && ow.has(x)) return true;
|
|
735
|
+
}
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
442
738
|
/**
|
|
443
739
|
* Translate many finished lines at once (adding a language to a video that
|
|
444
740
|
* already has subtitles). Returns id → { lang: text }.
|
|
741
|
+
*
|
|
742
|
+
* Every line is checked against its id. A reply that cannot be read, or whose numbering is off, is
|
|
743
|
+
* asked for again in two halves (then line by line); lines left out, or whose translation looks like a neighbour's,
|
|
744
|
+
* are translated one at a time. A line no try could translate is simply absent from the result.
|
|
445
745
|
*/
|
|
446
|
-
async function translateBatch(handle, targets, lines, glossary = "") {
|
|
746
|
+
async function translateBatch(handle, targets, lines, glossary = "", o = {}, depth = 0) {
|
|
747
|
+
const res = /* @__PURE__ */ new Map();
|
|
748
|
+
if (!lines.length || !targets.length) return res;
|
|
749
|
+
const alone = async (l) => {
|
|
750
|
+
const i = lines.indexOf(l);
|
|
751
|
+
const context = lines.slice(Math.max(0, i - 3), i).map((c) => ({
|
|
752
|
+
src: c.src,
|
|
753
|
+
tr: res.get(c.id)
|
|
754
|
+
}));
|
|
755
|
+
try {
|
|
756
|
+
const tr = await translateLine(handle, targets, l.src, context, glossary, "", o);
|
|
757
|
+
if (tr) res.set(l.id, tr);
|
|
758
|
+
else res.delete(l.id);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
if (o.signal?.aborted) throw err;
|
|
761
|
+
res.delete(l.id);
|
|
762
|
+
o.log?.(`translate: line ${l.id} failed (${err.message})`);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
if (lines.length === 1) {
|
|
766
|
+
await alone(lines[0]);
|
|
767
|
+
return res;
|
|
768
|
+
}
|
|
769
|
+
const halves = async () => {
|
|
770
|
+
if (depth > 0) {
|
|
771
|
+
for (const l of lines) await alone(l);
|
|
772
|
+
return res;
|
|
773
|
+
}
|
|
774
|
+
const mid = Math.ceil(lines.length / 2);
|
|
775
|
+
for (const part of [lines.slice(0, mid), lines.slice(mid)]) for (const [id, tr] of await translateBatch(handle, targets, part, glossary, o, depth + 1)) res.set(id, tr);
|
|
776
|
+
return res;
|
|
777
|
+
};
|
|
447
778
|
const system = [
|
|
448
779
|
`You translate subtitles into: ${targets.map((t) => `"${t}" (${LANGS[t] ?? t})`).join(", ")}. Keep each line separate (same ids), faithful, natural and concise, with normal punctuation.`,
|
|
780
|
+
"One translation per id, in the same order: never join two lines into one or move part of a line to the next, even when a sentence runs across lines.",
|
|
449
781
|
"Use the earlier and later lines as context. Keep brand, product and model names as they are; follow the glossary.",
|
|
450
782
|
`Reply with JSON only: [{"id":1,${targets.map((t) => `"${t}":"…"`).join(",")}}]`
|
|
451
783
|
].join(" ");
|
|
@@ -457,47 +789,58 @@ async function translateBatch(handle, targets, lines, glossary = "") {
|
|
|
457
789
|
src: l.src
|
|
458
790
|
})))
|
|
459
791
|
].filter(Boolean).join("\n");
|
|
460
|
-
|
|
461
|
-
const asm = new BlockAssembler();
|
|
462
|
-
for await (const chunk of handle.stream({
|
|
463
|
-
provider,
|
|
464
|
-
model,
|
|
465
|
-
system,
|
|
466
|
-
maxTokens: 400 + lines.length * 90 * targets.length,
|
|
467
|
-
purpose: "live-translate-batch",
|
|
468
|
-
messages: [createUserMessage({
|
|
469
|
-
content: [{
|
|
470
|
-
type: "text",
|
|
471
|
-
text
|
|
472
|
-
}],
|
|
473
|
-
source: {
|
|
474
|
-
kind: "plugin",
|
|
475
|
-
plugin: "dsh-vidi"
|
|
476
|
-
}
|
|
477
|
-
})]
|
|
478
|
-
})) asm.push(chunk);
|
|
479
|
-
const out = asm.blocks().filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
480
|
-
const res = /* @__PURE__ */ new Map();
|
|
481
|
-
const m = out.match(/\[[\s\S]*\]/);
|
|
482
|
-
if (!m) return res;
|
|
792
|
+
let out;
|
|
483
793
|
try {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
794
|
+
out = await ask(handle, {
|
|
795
|
+
system,
|
|
796
|
+
text,
|
|
797
|
+
maxTokens: 400 + lines.length * 90 * targets.length,
|
|
798
|
+
purpose: "live-translate-batch",
|
|
799
|
+
signal: o.signal,
|
|
800
|
+
log: o.log
|
|
801
|
+
});
|
|
802
|
+
} catch (err) {
|
|
803
|
+
if (o.signal?.aborted) throw err;
|
|
804
|
+
if (!(err instanceof AskError && err.empty)) {
|
|
805
|
+
o.log?.(`translate: ${lines.length} lines failed (${err.message})`);
|
|
806
|
+
return res;
|
|
807
|
+
}
|
|
808
|
+
o.log?.(`translate: ${lines.length} lines failed (${err.message}), trying smaller`);
|
|
809
|
+
return halves();
|
|
810
|
+
}
|
|
811
|
+
const rows = jsonIn(out, "[");
|
|
812
|
+
if (!Array.isArray(rows)) {
|
|
813
|
+
o.log?.(`translate: unreadable answer for ${lines.length} lines (${out.slice(0, 80)}), trying smaller`);
|
|
814
|
+
return halves();
|
|
815
|
+
}
|
|
816
|
+
const ids = rows.map((r) => Number(r?.id));
|
|
817
|
+
if (misnumbered(lines.map((l) => l.id), ids)) {
|
|
818
|
+
o.log?.(`translate: answer numbered wrongly for ${lines.length} lines, trying smaller`);
|
|
819
|
+
return halves();
|
|
820
|
+
}
|
|
821
|
+
for (const row of rows) {
|
|
822
|
+
const tr = {};
|
|
823
|
+
for (const t of targets) if (typeof row[t] === "string" && row[t].trim()) tr[t] = row[t].trim();
|
|
824
|
+
if (Object.keys(tr).length) res.set(Number(row.id), tr);
|
|
825
|
+
}
|
|
826
|
+
const bad = suspects(lines.map((l) => ({
|
|
827
|
+
...l,
|
|
828
|
+
tr: res.get(l.id)
|
|
829
|
+
})), targets);
|
|
830
|
+
if (bad.size) {
|
|
831
|
+
o.log?.(`translate: ${bad.size} of ${lines.length} lines missing or on the wrong line (ids ${[...bad].join(", ")}), translating them one by one`);
|
|
832
|
+
for (const l of lines) if (bad.has(l.id)) await alone(l);
|
|
833
|
+
}
|
|
491
834
|
return res;
|
|
492
835
|
}
|
|
836
|
+
/** Pull the JSON object out of the reply; fall back to plain text for a single target. */
|
|
493
837
|
function parseTranslations(out, targets) {
|
|
494
|
-
const
|
|
495
|
-
if (
|
|
496
|
-
const obj = JSON.parse(m[0]);
|
|
838
|
+
const obj = jsonIn(out, "{");
|
|
839
|
+
if (obj && typeof obj === "object") {
|
|
497
840
|
const res = {};
|
|
498
841
|
for (const t of targets) if (typeof obj[t] === "string" && obj[t]) res[t] = obj[t].trim();
|
|
499
842
|
if (Object.keys(res).length) return res;
|
|
500
|
-
}
|
|
843
|
+
}
|
|
501
844
|
if (targets.length === 1 && out && !out.startsWith("{")) return { [targets[0]]: out };
|
|
502
845
|
}
|
|
503
846
|
// src/polish.ts
|
|
@@ -529,7 +872,7 @@ var Polisher = class {
|
|
|
529
872
|
return this.pending.length;
|
|
530
873
|
}
|
|
531
874
|
add(cue) {
|
|
532
|
-
if (!cue.src.trim()) return;
|
|
875
|
+
if (!cue.src.trim() || this.deps.signal?.aborted) return;
|
|
533
876
|
this.pending.push(cue);
|
|
534
877
|
if (this.pending.length === 1) this.firstWaitAt = Date.now();
|
|
535
878
|
this.kick();
|
|
@@ -547,7 +890,7 @@ var Polisher = class {
|
|
|
547
890
|
}
|
|
548
891
|
/** Polish whatever is left (end of a run). */
|
|
549
892
|
async flush() {
|
|
550
|
-
while (this.busy || this.pending.length) if (this.busy) await this.busy;
|
|
893
|
+
while ((this.busy || this.pending.length) && !this.deps.signal?.aborted) if (this.busy) await this.busy;
|
|
551
894
|
else await this.runBatch();
|
|
552
895
|
}
|
|
553
896
|
runBatch() {
|
|
@@ -562,13 +905,19 @@ var Polisher = class {
|
|
|
562
905
|
async polish(batch) {
|
|
563
906
|
const handle = this.deps.llm();
|
|
564
907
|
if (!handle || !batch.length) return;
|
|
908
|
+
await this.part(handle, batch, 0);
|
|
909
|
+
this.done.push(...batch);
|
|
910
|
+
if (this.done.length > 40) this.done.splice(0, this.done.length - 40);
|
|
911
|
+
}
|
|
912
|
+
/** Polish some lines in one request; what comes back is checked line by line against its id. */
|
|
913
|
+
async part(handle, batch, depth) {
|
|
565
914
|
const targets = this.deps.targets();
|
|
566
915
|
const names = targets.map((t) => `"${t}" (${LANGS[t] ?? t})`).join(", ");
|
|
567
916
|
const system = [
|
|
568
917
|
"You polish live speech-recognition subtitles and maintain the glossary for this video.",
|
|
569
918
|
"A frame from the video in this stretch may be attached: use what is on screen (names, titles, slides, captions, products) to fix words and to confirm glossary entries.",
|
|
570
919
|
"Fix misheard words (names, terms, homophones) using the video background, the glossary, the site's own subtitles and the surrounding lines. Otherwise keep the speaker's words: do not rewrite, summarise, merge or split lines; keep every id.",
|
|
571
|
-
targets.length ? `Translate each fixed line into: ${names}, as natural, concise subtitles.` : "No translation is needed.",
|
|
920
|
+
targets.length ? `Translate each fixed line into: ${names}, as natural, concise subtitles. Each translation covers exactly the words of its own id: never join two lines into one translation or move part of a line to the next, even when a sentence runs across lines.` : "No translation is needed.",
|
|
572
921
|
"Speaker: say who says each line — a name when the background or context makes it clear (host, guest, a named person), otherwise \"Speaker 1\", \"Speaker 2\"… consistent with the earlier lines; if only one person talks, use the same label throughout.",
|
|
573
922
|
"Glossary: keep names and terms that must stay consistent — people, groups, places, products, titles, jargon, and words recognition keeps getting wrong. Merge spelling variants under one correct form and list the wrong forms as aliases. Only add what the text or background confirms; correct or remove earlier entries when new evidence shows they were wrong. Skip ordinary words, and skip entries whose translation equals the source unless they have aliases. Return only changes.",
|
|
574
923
|
`Reply with JSON only: {"lines":[{"id":1,"speaker":"…","src":"…"${targets.length ? `,"tr":{${targets.map((t) => `"${t}":"…"`).join(",")}}` : ""}}],"glossary":{"set":[{"src":"correct form","aliases":["misheard form"],"tr":{"<lang>":"…"},"note":"who/what, short"}],"remove":["src"]}}`
|
|
@@ -594,50 +943,93 @@ var Polisher = class {
|
|
|
594
943
|
this.deps.log("polish: no picture to show the model");
|
|
595
944
|
}
|
|
596
945
|
const image = jpeg ? await handle.image(jpeg).catch(() => void 0) : void 0;
|
|
597
|
-
|
|
598
|
-
const asm = new BlockAssembler();
|
|
599
|
-
for await (const chunk of handle.stream({
|
|
600
|
-
provider,
|
|
601
|
-
model,
|
|
602
|
-
system,
|
|
603
|
-
maxTokens: 800 + batch.length * 120 * Math.max(1, targets.length + 1),
|
|
604
|
-
purpose: "live-polish",
|
|
605
|
-
messages: [createUserMessage({
|
|
606
|
-
content: [...image ? [image] : [], {
|
|
607
|
-
type: "text",
|
|
608
|
-
text
|
|
609
|
-
}],
|
|
610
|
-
source: {
|
|
611
|
-
kind: "plugin",
|
|
612
|
-
plugin: "dsh-vidi"
|
|
613
|
-
}
|
|
614
|
-
})]
|
|
615
|
-
})) asm.push(chunk);
|
|
616
|
-
const out = asm.blocks().filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
617
|
-
const m = out.match(/\{[\s\S]*\}/);
|
|
618
|
-
if (!m) {
|
|
619
|
-
this.deps.log(`polish: no JSON (${out.slice(0, 120)})`);
|
|
620
|
-
this.done.push(...batch);
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
let res;
|
|
946
|
+
let out;
|
|
624
947
|
try {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
948
|
+
out = await ask(handle, {
|
|
949
|
+
system,
|
|
950
|
+
text,
|
|
951
|
+
image,
|
|
952
|
+
maxTokens: 800 + batch.length * 120 * Math.max(1, targets.length + 1),
|
|
953
|
+
purpose: "live-polish",
|
|
954
|
+
signal: this.deps.signal,
|
|
955
|
+
log: this.deps.log
|
|
956
|
+
});
|
|
957
|
+
} catch (err) {
|
|
958
|
+
if (this.deps.signal?.aborted) return;
|
|
959
|
+
if (err instanceof AskError && err.empty) return this.smaller(handle, batch, depth, err.message);
|
|
960
|
+
this.deps.log(`polish: ${batch.length} lines not polished (${err.message})`);
|
|
629
961
|
return;
|
|
630
962
|
}
|
|
631
|
-
|
|
963
|
+
const res = jsonIn(out, "{");
|
|
964
|
+
if (!res || !Array.isArray(res.lines)) return this.smaller(handle, batch, depth, `unreadable answer (${out.slice(0, 80)})`);
|
|
965
|
+
if (misnumbered(batch.map((c) => c.id), res.lines.map((l) => Number(l?.id)))) return this.smaller(handle, batch, depth, "answer numbered wrongly");
|
|
966
|
+
const got = /* @__PURE__ */ new Map();
|
|
967
|
+
const strangers = /* @__PURE__ */ new Set();
|
|
968
|
+
for (const line of res.lines) {
|
|
632
969
|
const cue = batch.find((c) => c.id === line.id);
|
|
633
970
|
if (!cue || typeof line.src !== "string" || !line.src.trim()) continue;
|
|
971
|
+
const own = alike(line.src, cue.src) >= .4;
|
|
972
|
+
if (!own) strangers.add(cue.id);
|
|
634
973
|
const tr = {};
|
|
635
|
-
for (const t of targets) if (typeof line.tr?.[t] === "string") tr[t] = line.tr[t];
|
|
636
|
-
|
|
974
|
+
for (const t of targets) if (typeof line.tr?.[t] === "string" && line.tr[t].trim()) tr[t] = line.tr[t].trim();
|
|
975
|
+
got.set(cue.id, {
|
|
976
|
+
src: own ? line.src.trim() : cue.src,
|
|
977
|
+
tr,
|
|
978
|
+
speaker: typeof line.speaker === "string" && line.speaker.trim() ? line.speaker.trim().slice(0, 40) : void 0
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
const bad = targets.length ? suspects(batch.map((c) => ({
|
|
982
|
+
id: c.id,
|
|
983
|
+
src: got.get(c.id)?.src ?? c.src,
|
|
984
|
+
tr: got.get(c.id)?.tr
|
|
985
|
+
})), targets) : /* @__PURE__ */ new Set();
|
|
986
|
+
for (const id of strangers) bad.add(id);
|
|
987
|
+
if (bad.size) this.deps.log(`polish: ${bad.size} of ${batch.length} lines missing or on the wrong line (ids ${[...bad].join(", ")}), translating them one by one`);
|
|
988
|
+
for (const cue of batch) {
|
|
989
|
+
const g = got.get(cue.id);
|
|
990
|
+
if (g && !bad.has(cue.id)) {
|
|
991
|
+
this.deps.onLine(cue, g.src, g.tr, g.speaker);
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
if (!targets.length) continue;
|
|
995
|
+
const src = g?.src ?? cue.src;
|
|
996
|
+
const tr = await this.alone(handle, batch, cue, src);
|
|
997
|
+
if (tr || g) this.deps.onLine(cue, src, tr ?? {}, g?.speaker);
|
|
637
998
|
}
|
|
638
999
|
if (res.glossary) this.deps.glossary.apply(res.glossary);
|
|
639
|
-
|
|
640
|
-
|
|
1000
|
+
}
|
|
1001
|
+
/** A request that gave nothing usable: once more in two halves, then each line translated on its own. */
|
|
1002
|
+
async smaller(handle, batch, depth, why) {
|
|
1003
|
+
if (batch.length > 1 && depth === 0) {
|
|
1004
|
+
this.deps.log(`polish: ${why} for ${batch.length} lines, trying smaller`);
|
|
1005
|
+
const mid = Math.ceil(batch.length / 2);
|
|
1006
|
+
await this.part(handle, batch.slice(0, mid), 1);
|
|
1007
|
+
await this.part(handle, batch.slice(mid), 1);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
this.deps.log(`polish: ${why} for ${batch.length} lines, translating them one by one`);
|
|
1011
|
+
if (!this.deps.targets().length) return;
|
|
1012
|
+
for (const cue of batch) {
|
|
1013
|
+
const tr = await this.alone(handle, batch, cue, cue.src);
|
|
1014
|
+
if (tr) this.deps.onLine(cue, cue.src, tr);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/** One line translated on its own, with the lines before it as context. */
|
|
1018
|
+
async alone(handle, batch, cue, src) {
|
|
1019
|
+
if (this.deps.signal?.aborted) return void 0;
|
|
1020
|
+
const before = [...this.done.slice(-3), ...batch.slice(0, batch.indexOf(cue))].slice(-4);
|
|
1021
|
+
try {
|
|
1022
|
+
return await translateLine(handle, this.deps.targets(), src, before.map((c) => ({
|
|
1023
|
+
src: c.src,
|
|
1024
|
+
tr: c.tr
|
|
1025
|
+
})), this.deps.glossary.forPrompt(), "", {
|
|
1026
|
+
signal: this.deps.signal,
|
|
1027
|
+
log: this.deps.log
|
|
1028
|
+
});
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
if (!this.deps.signal?.aborted) this.deps.log(`polish: line ${cue.id} not translated (${err.message})`);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
641
1033
|
}
|
|
642
1034
|
};
|
|
643
1035
|
// src/xiaoyuzhou.ts
|
|
@@ -2772,6 +3164,8 @@ const FILE_LIKE = /\.(mp4|m4v|mov|mkv|webm|mp3|m4a|aac|wav|flac|ogg)(\?|$)/i;
|
|
|
2772
3164
|
/** Pace for subtitles: stop reading ahead once recognition or translation is this far behind (seconds / lines). */
|
|
2773
3165
|
const AHEAD_MAX_SEC = 30;
|
|
2774
3166
|
const AHEAD_MAX_LINES = 10;
|
|
3167
|
+
/** How long pausing waits for lines still being translated or polished before leaving them to finish behind it (ms). */
|
|
3168
|
+
const SETTLE = { wait: 3e3 };
|
|
2775
3169
|
/**
|
|
2776
3170
|
* One video or live stream, in two independent parts:
|
|
2777
3171
|
*
|
|
@@ -2876,6 +3270,8 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2876
3270
|
/** The video's language as the site states it (before any recognition). */
|
|
2877
3271
|
siteLang;
|
|
2878
3272
|
polisher;
|
|
3273
|
+
/** Set off when the video is closed: model requests under way are given up. */
|
|
3274
|
+
closing = new AbortController();
|
|
2879
3275
|
nextId = 1;
|
|
2880
3276
|
drafts = /* @__PURE__ */ new Map();
|
|
2881
3277
|
ingest;
|
|
@@ -2901,6 +3297,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2901
3297
|
siteText: (a, b) => this.siteText(a, b),
|
|
2902
3298
|
targets: () => this.targets(),
|
|
2903
3299
|
still: (at) => this.still(at),
|
|
3300
|
+
signal: this.closing.signal,
|
|
2904
3301
|
onLine: (cue, src, tr, speaker) => {
|
|
2905
3302
|
if (!this.cues.includes(cue) || cue.edited) return;
|
|
2906
3303
|
this.deps.glossary.notice(src);
|
|
@@ -3570,39 +3967,62 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
3570
3967
|
this.deps.glossary.setTargets(tgtLangs);
|
|
3571
3968
|
this.persist();
|
|
3572
3969
|
this.emit("state", this.summary());
|
|
3970
|
+
await this.fill();
|
|
3971
|
+
}
|
|
3972
|
+
filling = false;
|
|
3973
|
+
/**
|
|
3974
|
+
* Give every finished line the translations it lacks (a language just added, or lines a failed
|
|
3975
|
+
* request left behind), in batches. A line that cannot be translated is passed over, so the
|
|
3976
|
+
* lines after it still get theirs; it stops only when the model gives nothing twice in a row.
|
|
3977
|
+
*/
|
|
3978
|
+
async fill() {
|
|
3573
3979
|
if (this.filling) return;
|
|
3574
3980
|
this.filling = true;
|
|
3981
|
+
const log = (m) => this.deps.log(`[${this.id}] ${m}`);
|
|
3982
|
+
const failed = /* @__PURE__ */ new Set();
|
|
3983
|
+
let empty = 0, filled = 0;
|
|
3575
3984
|
try {
|
|
3576
|
-
|
|
3985
|
+
while (!this.closing.signal.aborted) {
|
|
3577
3986
|
const targets = this.targets();
|
|
3578
3987
|
const handle = this.deps.llm();
|
|
3579
3988
|
if (!handle || !targets.length) break;
|
|
3580
|
-
const todo = this.cues.filter((c) => c.final && c.src && targets.some((t) => !c.tr?.[t])).slice(0, 15);
|
|
3989
|
+
const todo = this.cues.filter((c) => c.final && c.src.trim() && !failed.has(c.id) && targets.some((t) => !c.tr?.[t])).slice(0, 15);
|
|
3581
3990
|
if (!todo.length) break;
|
|
3582
3991
|
const got = await translateBatch(handle, targets.filter((t) => todo.some((c) => !c.tr?.[t])), todo.map((c) => ({
|
|
3583
3992
|
id: c.id,
|
|
3584
3993
|
src: c.src
|
|
3585
|
-
})), this.deps.glossary.forPrompt()
|
|
3586
|
-
|
|
3994
|
+
})), this.deps.glossary.forPrompt(), {
|
|
3995
|
+
signal: this.closing.signal,
|
|
3996
|
+
log
|
|
3997
|
+
});
|
|
3587
3998
|
for (const c of todo) {
|
|
3588
3999
|
const tr = got.get(c.id);
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
4000
|
+
const add = Object.fromEntries(Object.entries(tr ?? {}).filter(([t]) => !c.tr?.[t]));
|
|
4001
|
+
if (Object.keys(add).length) {
|
|
4002
|
+
c.tr = {
|
|
4003
|
+
...c.tr,
|
|
4004
|
+
...add
|
|
4005
|
+
};
|
|
4006
|
+
filled++;
|
|
4007
|
+
this.emit("cue", c);
|
|
4008
|
+
this.record(c);
|
|
4009
|
+
}
|
|
4010
|
+
if (targets.some((t) => !c.tr?.[t])) failed.add(c.id);
|
|
4011
|
+
}
|
|
4012
|
+
empty = got.size ? 0 : empty + 1;
|
|
4013
|
+
if (empty >= 2) {
|
|
4014
|
+
log(`fill translations: the model gives nothing, stopping (${this.cues.filter((c) => c.final && c.src.trim() && targets.some((t) => !c.tr?.[t])).length} lines left)`);
|
|
4015
|
+
break;
|
|
3596
4016
|
}
|
|
3597
4017
|
}
|
|
4018
|
+
if (failed.size) log(`fill translations: ${failed.size} lines still without a translation (ids ${[...failed].slice(0, 20).join(", ")}${failed.size > 20 ? "…" : ""})`);
|
|
3598
4019
|
} catch (err) {
|
|
3599
|
-
this.
|
|
4020
|
+
if (!this.closing.signal.aborted) log(`fill translations: ${err.message}`);
|
|
3600
4021
|
} finally {
|
|
3601
4022
|
this.filling = false;
|
|
3602
|
-
this.save();
|
|
4023
|
+
if (filled) this.save();
|
|
3603
4024
|
}
|
|
3604
4025
|
}
|
|
3605
|
-
filling = false;
|
|
3606
4026
|
/** Pause subtitles; the next start resumes from here. */
|
|
3607
4027
|
async pauseSubs() {
|
|
3608
4028
|
const rd = this.reader;
|
|
@@ -3621,11 +4041,15 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
3621
4041
|
};
|
|
3622
4042
|
this.persist();
|
|
3623
4043
|
this.emit("state", this.summary());
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
4044
|
+
const settled = (async () => {
|
|
4045
|
+
await this.translating;
|
|
4046
|
+
await this.polisher.flush();
|
|
4047
|
+
if (!this.reader) await this.fill();
|
|
4048
|
+
this.save();
|
|
4049
|
+
this.persist();
|
|
4050
|
+
this.emit("state", this.summary());
|
|
4051
|
+
})().catch((err) => this.deps.log(`[${this.id}] finishing subtitles: ${err.message}`));
|
|
4052
|
+
await Promise.race([settled, new Promise((r) => setTimeout(r, SETTLE.wait))]);
|
|
3629
4053
|
}
|
|
3630
4054
|
/**
|
|
3631
4055
|
* How far the subtitles are complete (media s): audio read minus what the
|
|
@@ -3714,7 +4138,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
3714
4138
|
this.translating = this.translating.then(async () => {
|
|
3715
4139
|
try {
|
|
3716
4140
|
const ref = this.siteText(cue.start, cue.end).map((x) => `[${x.lang}${x.auto ? ", auto" : ""}] ${x.text}`).join("\n");
|
|
3717
|
-
const tr = await this.deps.translator.translate(this.targets(), text, context, this.deps.glossary.forPrompt(),
|
|
4141
|
+
const tr = await this.deps.translator.translate(this.targets(), text, context, this.deps.glossary.forPrompt(), this.closing.signal, ref, (m) => this.deps.log(`[${this.id}] ${m}`));
|
|
3718
4142
|
if (tr) {
|
|
3719
4143
|
cue.tr = {
|
|
3720
4144
|
...cue.tr,
|
|
@@ -3723,7 +4147,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
3723
4147
|
this.emit("cue", cue);
|
|
3724
4148
|
}
|
|
3725
4149
|
} catch (err) {
|
|
3726
|
-
this.deps.log(`[${this.id}] translate: ${err.message}`);
|
|
4150
|
+
if (!this.closing.signal.aborted) this.deps.log(`[${this.id}] translate: ${err.message}`);
|
|
3727
4151
|
}
|
|
3728
4152
|
this.pendingTranslations--;
|
|
3729
4153
|
this.record(cue);
|
|
@@ -3867,6 +4291,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
3867
4291
|
await this.restreamer?.stop();
|
|
3868
4292
|
await this.stop(reason);
|
|
3869
4293
|
await this.pauseSubs();
|
|
4294
|
+
this.closing.abort();
|
|
3870
4295
|
}
|
|
3871
4296
|
dubber;
|
|
3872
4297
|
/** The voiceover (made on first use; its clips stay in the video's folder). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vidi",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsdown",
|
|
39
39
|
"dev": "tsdown --watch",
|
|
40
|
-
"typecheck": "tsc --noEmit"
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"check": "node --experimental-transform-types --no-warnings checks/subtitles.check.ts"
|
|
41
42
|
},
|
|
42
43
|
"dsh": {
|
|
43
44
|
"bundle": {
|