dsh-vidi 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +527 -144
  2. package/lib/index.mjs +74 -30
  3. package/package.json +1 -1
package/lib/index.mjs CHANGED
@@ -1693,6 +1693,8 @@ const AHEAD_MAX_LINES = 10;
1693
1693
  *
1694
1694
  * Events: 'state' (JobSummary), 'cue' (Cue — new or revised).
1695
1695
  */
1696
+ /** Drop the first `skip` seconds after decoding (an output -ss; empty when there is nothing to drop). */
1697
+ const seekAfter = (skip) => skip > .01 ? ["-ss", skip.toFixed(3)] : [];
1696
1698
  var LiveJob = class LiveJob extends EventEmitter {
1697
1699
  opts;
1698
1700
  deps;
@@ -2354,12 +2356,14 @@ var LiveJob = class LiveJob extends EventEmitter {
2354
2356
  const p = this.provider;
2355
2357
  this.provider = void 0;
2356
2358
  await p?.stop().catch(() => {});
2357
- await this.translating;
2358
- await this.polisher.flush();
2359
2359
  this.subs = {
2360
2360
  state,
2361
2361
  at: this.subs.at
2362
2362
  };
2363
+ this.persist();
2364
+ this.emit("state", this.summary());
2365
+ await this.translating;
2366
+ await this.polisher.flush();
2363
2367
  this.save();
2364
2368
  this.persist();
2365
2369
  this.emit("state", this.summary());
@@ -2393,7 +2397,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2393
2397
  this.emit("cue", cue);
2394
2398
  if (cue.final) {
2395
2399
  this.deps.glossary.notice(cue.src);
2396
- if (this.opts.mode === "realtime" && !this.provider?.translates && cue.tr === void 0 && this.targets().length) this.translate(cue);
2400
+ if (this.opts.mode !== "polished" && !this.provider?.translates && cue.tr === void 0 && this.targets().length) this.translate(cue);
2397
2401
  else {
2398
2402
  this.record(cue);
2399
2403
  this.polisher.add(cue);
@@ -2594,7 +2598,14 @@ var LiveJob = class LiveJob extends EventEmitter {
2594
2598
  exportText(langs, format) {
2595
2599
  return exportCues(this.cues, langs, format, this.title);
2596
2600
  }
2597
- /** Best available picture source for time range [from, to]: original download, finished recording, or recorded segments. */
2601
+ /**
2602
+ * Best available picture source for time range [from, to]: original
2603
+ * download, finished recording, or recorded segments. `args` are the input
2604
+ * arguments; `skip` is how much to drop after decoding (output -ss). Files
2605
+ * seek exactly on input; live segments do not (a seek lands between
2606
+ * keyframes — YouTube live has one every 5 s — and decodes nothing), so they
2607
+ * are read from the start of the first segment and trimmed after decoding.
2608
+ */
2598
2609
  mediaFor(from, to) {
2599
2610
  const original = readdirSync(this.deps.dir).find((n) => /^original\.(mp4|mkv|webm)$/.test(n));
2600
2611
  if (original) return {
@@ -2604,7 +2615,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2604
2615
  "-i",
2605
2616
  join(this.deps.dir, original)
2606
2617
  ],
2607
- offset: 0
2618
+ skip: 0
2608
2619
  };
2609
2620
  if (this.captureDone && existsSync(join(this.deps.dir, "recording.mp4"))) return {
2610
2621
  args: [
@@ -2613,7 +2624,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2613
2624
  "-i",
2614
2625
  join(this.deps.dir, "recording.mp4")
2615
2626
  ],
2616
- offset: 0
2627
+ skip: 0
2617
2628
  };
2618
2629
  const segs = this.segments();
2619
2630
  const files = readFileSync(this.m3u8, "utf8").split("\n").filter((l) => l && !l.startsWith("#"));
@@ -2621,29 +2632,26 @@ var LiveJob = class LiveJob extends EventEmitter {
2621
2632
  let last = segs.findIndex((sg) => sg.start + sg.dur >= to);
2622
2633
  if (last < 0) last = segs.length - 1;
2623
2634
  if (!segs.length || first > last) throw new Error("that part has not been recorded yet");
2624
- const list = files.slice(first, last + 1).map((f) => join(this.deps.dir, this.hlsName, f)).join("|");
2625
2635
  return {
2626
- args: [
2627
- "-ss",
2628
- String(from - segs[first].start),
2629
- "-i",
2630
- `concat:${list}`
2631
- ],
2632
- offset: 0
2636
+ args: ["-i", `concat:${files.slice(first, last + 1).map((f) => join(this.deps.dir, this.hlsName, f)).join("|")}`],
2637
+ skip: Math.max(0, from - segs[first].start)
2633
2638
  };
2634
2639
  }
2635
2640
  /** One small JPEG of the picture at media time `at`, for the model to look at (undefined for audio-only or on failure). */
2636
2641
  async still(at) {
2637
2642
  try {
2638
- const { args } = this.mediaFor(at, at + 1);
2643
+ const { args, skip } = this.mediaFor(at, at + 1);
2639
2644
  const { stdout } = await run$1("ffmpeg", [
2640
2645
  "-v",
2641
2646
  "error",
2642
2647
  ...args,
2648
+ ...seekAfter(skip),
2643
2649
  "-frames:v",
2644
2650
  "1",
2645
2651
  "-vf",
2646
2652
  "scale=768:-2",
2653
+ "-pix_fmt",
2654
+ "yuvj420p",
2647
2655
  "-q:v",
2648
2656
  "5",
2649
2657
  "-f",
@@ -2666,18 +2674,22 @@ var LiveJob = class LiveJob extends EventEmitter {
2666
2674
  const dir = join(this.deps.dir, "frames");
2667
2675
  mkdirSync(dir, { recursive: true });
2668
2676
  const out = join(dir, `frame_${at.toFixed(1).replace(".", "_")}.jpg`);
2669
- const { args } = this.mediaFor(at, at + 1);
2677
+ const { args, skip } = this.mediaFor(at, at + 1);
2670
2678
  await run$1("ffmpeg", [
2671
2679
  "-v",
2672
2680
  "error",
2673
2681
  "-y",
2674
2682
  ...args,
2683
+ ...seekAfter(skip),
2675
2684
  "-frames:v",
2676
2685
  "1",
2686
+ "-pix_fmt",
2687
+ "yuvj420p",
2677
2688
  "-q:v",
2678
2689
  "2",
2679
2690
  out
2680
2691
  ], { timeout: 6e4 });
2692
+ if (!existsSync(out)) throw new Error("no picture at that moment");
2681
2693
  return out;
2682
2694
  }
2683
2695
  /**
@@ -2690,14 +2702,14 @@ var LiveJob = class LiveJob extends EventEmitter {
2690
2702
  mkdirSync(dir, { recursive: true });
2691
2703
  const name = `clip_${from.toFixed(1)}-${to.toFixed(1)}`.replace(/\./g, "_");
2692
2704
  const out = join(dir, `${name}.mp4`);
2693
- const { args } = this.mediaFor(from, to);
2705
+ const { args, skip } = this.mediaFor(from, to);
2694
2706
  const subArgs = [];
2695
2707
  const maps = [];
2696
2708
  if (langs?.length) {
2697
2709
  const inRange = this.cues.filter((c) => c.final && c.end > from && c.start < to).map((c) => ({
2698
2710
  ...c,
2699
- start: Math.max(0, c.start - from),
2700
- end: Math.min(to, c.end) - from
2711
+ start: Math.max(0, c.start - from) + skip,
2712
+ end: Math.min(to, c.end) - from + skip
2701
2713
  }));
2702
2714
  (langs.length > 1 ? [langs, ...langs.map((l) => [l])] : [langs]).forEach((ls, i) => {
2703
2715
  const f = join(dir, `.${name}.${i}.srt`);
@@ -2712,6 +2724,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2712
2724
  "-y",
2713
2725
  ...args,
2714
2726
  ...subArgs,
2727
+ ...seekAfter(skip),
2715
2728
  "-t",
2716
2729
  String(to - from),
2717
2730
  "-map",
@@ -2764,8 +2777,16 @@ var LiveJob = class LiveJob extends EventEmitter {
2764
2777
  rows
2765
2778
  });
2766
2779
  }
2780
+ const { args, skip } = this.mediaFor(from, to);
2767
2781
  const pictures = /* @__PURE__ */ new Map();
2768
2782
  const lines = ["ffconcat version 1.0"];
2783
+ if (skip > 0) {
2784
+ const blank = await drawSubtitles([], style, w, h);
2785
+ if (!blank) throw new Error("subtitles could not be drawn on this computer");
2786
+ const file = join(tmp, "blank.png");
2787
+ writeFileSync(file, blank);
2788
+ lines.push(`file '${file.replace(/'/g, "'\\''")}'`, `duration ${skip.toFixed(3)}`);
2789
+ }
2769
2790
  for (const s of spans) {
2770
2791
  let file = pictures.get(s.key);
2771
2792
  if (!file) {
@@ -2780,7 +2801,6 @@ var LiveJob = class LiveJob extends EventEmitter {
2780
2801
  lines.push(`file '${pictures.get(spans.at(-1).key).replace(/'/g, "'\\''")}'`);
2781
2802
  const list = join(tmp, "subs.ffconcat");
2782
2803
  writeFileSync(list, lines.join("\n") + "\n");
2783
- const { args } = this.mediaFor(from, to);
2784
2804
  try {
2785
2805
  await run$1("ffmpeg", [
2786
2806
  "-v",
@@ -2793,6 +2813,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2793
2813
  "0",
2794
2814
  "-i",
2795
2815
  list,
2816
+ ...seekAfter(skip),
2796
2817
  "-t",
2797
2818
  String(to - from),
2798
2819
  "-filter_complex",
@@ -3400,13 +3421,21 @@ const ISO = {
3400
3421
  var WhisperServer = class {
3401
3422
  model;
3402
3423
  log;
3424
+ audioCtx;
3403
3425
  proc;
3404
3426
  port;
3405
3427
  starting;
3406
3428
  users = 0;
3407
- constructor(model, log) {
3429
+ /**
3430
+ * @param audioCtx Whisper's encoder window in frames (1500 = 30 s). Chunks
3431
+ * here are a few seconds long, and the encoder costs the same for 5 s as for
3432
+ * 30 s unless the window is cut down: a window just over the longest chunk
3433
+ * made recognition about 2.5× faster (Apple M5, large-v3-turbo).
3434
+ */
3435
+ constructor(model, log, audioCtx = 0) {
3408
3436
  this.model = model;
3409
3437
  this.log = log;
3438
+ this.audioCtx = audioCtx;
3410
3439
  }
3411
3440
  async acquire() {
3412
3441
  this.users++;
@@ -3429,7 +3458,8 @@ var WhisperServer = class {
3429
3458
  String(port),
3430
3459
  "-t",
3431
3460
  "8",
3432
- "-nt"
3461
+ "-nt",
3462
+ ...this.audioCtx ? ["-ac", String(this.audioCtx)] : []
3433
3463
  ], { stdio: [
3434
3464
  "ignore",
3435
3465
  "ignore",
@@ -4596,7 +4626,7 @@ function apply(ctx, config) {
4596
4626
  appendFileSync(logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}\n`);
4597
4627
  } catch {}
4598
4628
  };
4599
- const whisper = new WhisperServer(config.whisperModel, log);
4629
+ const whisper = new WhisperServer(config.whisperModel, log, Math.min(1500, Math.max(256, Math.ceil((config.maxChunkSec + 5) * 50 / 64) * 64)));
4600
4630
  const jobs = /* @__PURE__ */ new Map();
4601
4631
  const byConversation = /* @__PURE__ */ new Map();
4602
4632
  let llm;
@@ -4753,7 +4783,7 @@ function apply(ctx, config) {
4753
4783
  tgtLangs: [config.defaultTargetLang],
4754
4784
  srcLang: "auto",
4755
4785
  provider: defaultEngine(),
4756
- mode: "realtime"
4786
+ mode: "polished"
4757
4787
  };
4758
4788
  /** Browser notifications: "show this video" for a session (tools, /video). */
4759
4789
  const bus = new EventEmitter();
@@ -4891,13 +4921,14 @@ function apply(ctx, config) {
4891
4921
  /** The conversation's video: in memory, or the latest one on disk (after a restart). */
4892
4922
  function jobForConversation(conversationId) {
4893
4923
  const id = byConversation.get(conversationId);
4924
+ if (id === "") return void 0;
4894
4925
  if (id && jobs.has(id)) return jobs.get(id);
4895
4926
  const root = join(config.dataDir, "jobs");
4896
4927
  if (!existsSync(root)) return void 0;
4897
4928
  const dirs = readdirSync(root).filter((n) => /^[\w-]+$/.test(n)).sort().reverse();
4898
4929
  for (const name of dirs) {
4899
4930
  const file = join(root, name, "job.json");
4900
- if (!existsSync(file)) continue;
4931
+ if (!existsSync(file) || existsSync(join(root, name, "closed"))) continue;
4901
4932
  try {
4902
4933
  if (JSON.parse(readFileSync(file, "utf8")).opts?.conversationId !== conversationId) continue;
4903
4934
  } catch {
@@ -4912,6 +4943,7 @@ function apply(ctx, config) {
4912
4943
  return job;
4913
4944
  }
4914
4945
  }
4946
+ const modeOf = (m) => m === "polished" || m === "site" ? m : "realtime";
4915
4947
  async function startJob(opts, waiting = false) {
4916
4948
  const prev = waiting ? void 0 : byConversation.get(opts.conversationId);
4917
4949
  if (prev) await jobs.get(prev)?.close("replaced");
@@ -4922,7 +4954,7 @@ function apply(ctx, config) {
4922
4954
  job = new LiveJob(id, opts, jobDeps(dir, opts, () => job, waiting));
4923
4955
  jobs.set(id, job);
4924
4956
  if (!waiting) byConversation.set(opts.conversationId, id);
4925
- job.autoSubs = true;
4957
+ job.autoSubs = opts.mode !== "site";
4926
4958
  bus.emit("job", job);
4927
4959
  job.start();
4928
4960
  return job;
@@ -4986,7 +5018,7 @@ function apply(ctx, config) {
4986
5018
  tgtLangs: b.tgtLangs.map(String).slice(0, 4),
4987
5019
  srcLang: typeof b.srcLang === "string" ? b.srcLang : "auto",
4988
5020
  provider: typeof b.provider === "string" ? b.provider : "local",
4989
- mode: b.mode === "polished" ? "polished" : "realtime"
5021
+ mode: modeOf(b.mode)
4990
5022
  } });
4991
5023
  const source = b.source === "push" || b.source === "file" ? b.source : "url";
4992
5024
  await disarm();
@@ -4994,13 +5026,25 @@ function apply(ctx, config) {
4994
5026
  return send(res, 200, (await startJob({
4995
5027
  source,
4996
5028
  url: source === "file" && raw.startsWith("~") ? join(homedir(), raw.slice(1)) : raw,
4997
- mode: b.mode === "polished" ? "polished" : "realtime",
5029
+ mode: modeOf(b.mode),
4998
5030
  srcLang: typeof b.srcLang === "string" ? b.srcLang : "auto",
4999
5031
  tgtLangs: Array.isArray(b.tgtLangs) ? b.tgtLangs.filter((x) => typeof x === "string").slice(0, 4) : [config.defaultTargetLang],
5000
5032
  provider: typeof b.provider === "string" ? b.provider : "local",
5001
5033
  conversationId: String(b.conversationId ?? "default")
5002
5034
  })).summary());
5003
5035
  }
5036
+ if (route === "close" && req.method === "POST") {
5037
+ const b = await readJson(req);
5038
+ const job = jobs.get(String(b.jobId));
5039
+ if (!job) return send(res, 404, { error: "no such job" });
5040
+ await job.close("closed");
5041
+ const conv = job.summary().conversationId;
5042
+ if (byConversation.get(conv) === job.id) byConversation.set(conv, "");
5043
+ try {
5044
+ writeFileSync(join(job.summary().dir, "closed"), (/* @__PURE__ */ new Date()).toISOString());
5045
+ } catch {}
5046
+ return send(res, 200, { ok: true });
5047
+ }
5004
5048
  if (route === "stop" && req.method === "POST") {
5005
5049
  const b = await readJson(req);
5006
5050
  const job = jobs.get(String(b.jobId));
@@ -5075,7 +5119,7 @@ function apply(ctx, config) {
5075
5119
  if (Array.isArray(b.tgtLangs)) opts.tgtLangs = b.tgtLangs.map(String).slice(0, 4);
5076
5120
  if (typeof b.srcLang === "string") opts.srcLang = b.srcLang;
5077
5121
  if (typeof b.provider === "string") opts.provider = b.provider;
5078
- if (b.mode === "realtime" || b.mode === "polished") opts.mode = b.mode;
5122
+ if (b.mode === "realtime" || b.mode === "polished" || b.mode === "site") opts.mode = b.mode;
5079
5123
  const svc = opts.provider ? service(opts.provider) : void 0;
5080
5124
  if (svc) {
5081
5125
  await keys.refresh();
@@ -5094,7 +5138,7 @@ function apply(ctx, config) {
5094
5138
  return send(res, 200, await arm({
5095
5139
  source: "push",
5096
5140
  url: "rtmp",
5097
- mode: b.mode === "polished" ? "polished" : "realtime",
5141
+ mode: modeOf(b.mode),
5098
5142
  srcLang: typeof b.srcLang === "string" ? b.srcLang : "auto",
5099
5143
  tgtLangs: Array.isArray(b.tgtLangs) ? b.tgtLangs.map(String).slice(0, 4) : [config.defaultTargetLang],
5100
5144
  provider: typeof b.provider === "string" ? b.provider : "local",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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",