dsh-vidi 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +13 -6
  2. package/lib/index.mjs +551 -126
  3. package/package.json +3 -2
package/lib/client.js CHANGED
@@ -40155,13 +40155,20 @@ body > .dshl-menu { z-index: 2147483002; }
40155
40155
 
40156
40156
  /* ── Outside the plugin view: sidebar entry and the composer's paste prompt (host tokens directly) ── */
40157
40157
  .dshl-sidebtn { position: relative; isolation: isolate; display: flex; align-items: center; gap: 8px; flex: 0 0 auto; overflow: hidden; border: 0; background: none; cursor: pointer;
40158
- color: var(--dsw-alias-label-primary, #1b1b19); font-family: inherit; font-size: 14px; line-height: 22px; box-sizing: border-box; }
40159
- .dshl-sidebtn.wide { width: calc(100% + 4px); height: 42px; margin: 4px -2px 0; padding: 0 10px 0 8px; border-radius: 12px; }
40160
- .dshl-sidebtn.rail { justify-content: center; width: 36px; height: 36px; margin: 8px 0 0; padding: 0; border-radius: 50%; }
40161
- .dshl-sidebtn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.05)); }
40158
+ color: var(--dsw-alias-label-primary, #1b1b19); font-family: inherit; font-size: 14px; line-height: 22px; text-align: left; box-sizing: border-box; border-radius: var(--dsw-radius-md, 12px); }
40159
+ /* Lined up with the account row at the sidebar foot: the same 2px inset and 6px padding, the icon centred in a
40160
+ 24px slot like the avatar, so icon centre and label start match the row below. */
40161
+ .dshl-sidebtn.wide { width: calc(100% - 4px); height: 34px; margin: 4px 2px 0; padding: 6px; }
40162
+ /* The footer row holds every plugin's button: wide buttons take a line each, rail icons stack.
40163
+ DSH wraps each slot entry in a display:contents element, so the row is the button's grandparent. */
40164
+ div:has(> .dshl-sidebtn.wide), div:has(> div > .dshl-sidebtn.wide) { flex-wrap: wrap; }
40165
+ div:has(> .dshl-sidebtn.rail), div:has(> div > .dshl-sidebtn.rail) { flex-direction: column; align-items: center; }
40166
+ .dshl-sidebtn.rail { justify-content: center; width: 36px; height: 36px; margin: 8px 0 0; padding: 0; }
40167
+ .dshl-sidebtn:hover { background-color: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.05)); }
40162
40168
  .dshl-sidebtn svg { flex: none; }
40163
- /* The mark is drawn at 18px; the host's sidebar icons are 16px. Same centre, same text start. */
40164
- .dshl-sidebtn.wide svg { margin: 0 -1px; }
40169
+ /* The host's sidebar icons: 16px in the wide sidebar, 18px in the narrow one. */
40170
+ .dshl-sidebtn.wide svg { width: 16px; height: 16px; margin: 0 4px; }
40171
+ .dshl-sidebtn.rail svg { width: 18px; height: 18px; }
40165
40172
  .dshl-sidebtn .label { position: relative; overflow: hidden; white-space: nowrap; }
40166
40173
  .dshl-sidebtn .meta { margin-left: auto; color: var(--dsw-alias-label-secondary, #66655f); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
40167
40174
  .dshl-sidebtn .bg { position: absolute; inset: 0; z-index: -1; background: var(--thumb) center / cover no-repeat; opacity: .38; filter: saturate(.85);
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 || !line.trim() || targets.length === 0) return void 0;
404
- const system = [
405
- `You translate live speech subtitles. Target languages: ${targets.map((t) => `"${t}" (${LANGS[t] ?? t})`).join(", ")}.`,
406
- "Translate only the final line. Be faithful, natural and concise, like professional subtitles, with normal punctuation.",
407
- "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.",
408
- "If the line is a fragment, translate the fragment as-is; do not complete it.",
409
- `Reply with one JSON object mapping each language code to its translation, e.g. {${targets.map((t) => `"${t}": "..."`).join(", ")}} — nothing else.`
410
- ].join(" ");
411
- const ctx = context.slice(-6).map((c) => `- ${c.src}${c.tr ? `\n => ${JSON.stringify(c.tr)}` : ""}`).join("\n");
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
- const { provider, model } = handle.selection();
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
- messages: [createUserMessage({
428
- content: [{
429
- type: "text",
430
- text
431
- }],
432
- source: {
433
- kind: "plugin",
434
- plugin: "dsh-vidi"
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
- /** Pull the JSON object out of the reply; fall back to plain text for a single target. */
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
- const { provider, model } = handle.selection();
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
- for (const row of JSON.parse(m[0])) {
485
- const id = Number(row.id);
486
- const tr = {};
487
- for (const t of targets) if (typeof row[t] === "string" && row[t].trim()) tr[t] = row[t].trim();
488
- if (Object.keys(tr).length) res.set(id, tr);
489
- }
490
- } catch {}
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 m = out.match(/\{[\s\S]*\}/);
495
- if (m) try {
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
- } catch {}
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
- const { provider, model } = handle.selection();
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
- res = JSON.parse(m[0]);
626
- } catch {
627
- this.deps.log("polish: bad JSON");
628
- this.done.push(...batch);
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
- for (const line of res.lines ?? []) {
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
- this.deps.onLine(cue, line.src.trim(), tr, typeof line.speaker === "string" && line.speaker.trim() ? line.speaker.trim().slice(0, 40) : void 0);
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
- this.done.push(...batch);
640
- if (this.done.length > 40) this.done.splice(0, this.done.length - 40);
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
- for (;;) {
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
- if (!got.size) break;
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
- if (!tr) continue;
3590
- c.tr = {
3591
- ...c.tr,
3592
- ...tr
3593
- };
3594
- this.emit("cue", c);
3595
- this.record(c);
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.deps.log(`[${this.id}] fill translations: ${err.message}`);
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
- await this.translating;
3625
- await this.polisher.flush();
3626
- this.save();
3627
- this.persist();
3628
- this.emit("state", this.summary());
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(), void 0, ref);
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.0",
3
+ "version": "0.10.2",
4
4
  "description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -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": {