dsh-vidi 0.2.1 → 0.2.3

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/client.js CHANGED
@@ -36192,6 +36192,7 @@ const en = {
36192
36192
  "chat.p.download": "Download the original of this video.",
36193
36193
  "page.back": "Back to chats",
36194
36194
  "page.open": "Vidi video",
36195
+ "page.failed": "The video page could not open: {e}",
36195
36196
  "side.subs": "Subtitles {p}%",
36196
36197
  "side.subsLive": "Subtitling",
36197
36198
  "side.recording": "Recording",
@@ -36506,6 +36507,7 @@ const zh = {
36506
36507
  "chat.p.download": "下载这个视频的原片。",
36507
36508
  "page.back": "返回对话",
36508
36509
  "page.open": "Vidi 视频",
36510
+ "page.failed": "视频页打不开:{e}",
36509
36511
  "side.subs": "字幕 {p}%",
36510
36512
  "side.subsLive": "生成字幕中",
36511
36513
  "side.recording": "录制中",
@@ -36756,7 +36758,17 @@ const CSS = `
36756
36758
  @media (max-width: 1000px) { .dshl-main { grid-template-columns: minmax(0, 1fr); } }
36757
36759
 
36758
36760
  /* Video page (full window over the app) */
36761
+ .dshl-toast { position: fixed; left: 50%; bottom: 28px; z-index: 2147483001; max-width: min(560px, calc(100vw - 32px)); padding: 10px 16px; border-radius: 10px;
36762
+ background: rgba(28,28,31,.95); color: #f2f2f3; font: 13.5px/1.5 system-ui, -apple-system, sans-serif; box-shadow: 0 10px 30px rgba(0,0,0,.3); transform: translateX(-50%); pointer-events: none; }
36759
36763
  .dshl-pagewrap { position: absolute; inset: 0; z-index: 1; pointer-events: auto; background: var(--dsw-alias-bg-base, #fff); }
36764
+ /* Desktop app: the page covers the whole window. Windows: start under the title bar. macOS: the
36765
+ traffic lights sit over the top-left, so the top bar leaves them room, lines its controls up
36766
+ with them, and its empty space moves the window like the app's own top strip. Full screen and
36767
+ the web page need none of this. */
36768
+ .dshl-pagewrap { top: var(--dsh-frame-chrome-top, 0px); }
36769
+ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top { padding: 4px 16px 8px 84px; -webkit-app-region: drag; app-region: drag; }
36770
+ html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top :is(button, input, label, a, select, .dshl-pick) { -webkit-app-region: no-drag; app-region: no-drag; }
36771
+ @media (max-width: 600px) { html[data-platform='darwin']:not([data-fullscreen]) .dshl.page .dshl-top { padding-left: 80px; } }
36760
36772
  .dshl.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
36761
36773
  .dshl.page .dshl-main { flex: 1; min-height: 0; grid-template-columns: minmax(0, 1fr) clamp(320px, 30vw, 420px); align-items: stretch; padding-bottom: 16px; }
36762
36774
  /* Video page: player on top, then the status row, then subtitles/glossary filling what is left; chat on the right. */
@@ -37196,6 +37208,7 @@ function ensureStyles() {
37196
37208
 
37197
37209
  // src/client/VideoPage.tsx
37198
37210
  const PAGE_SESSION = "dsh-live.pageSession";
37211
+ const samePath = (a, b) => !!a && !!b && a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
37199
37212
  /**
37200
37213
  * The video page's own conversation: reused while it exists, created when not.
37201
37214
  * It lives in a "Video" workspace on the video folder, so DeepSeek's own file
@@ -37207,8 +37220,15 @@ async function pageSession(sessions, workspaces, title) {
37207
37220
  saved = localStorage.getItem(PAGE_SESSION);
37208
37221
  } catch {}
37209
37222
  if (saved && sessions.has(saved)) return saved;
37210
- const deps = await fetch("/live/api/deps").then((r) => r.json()).catch(() => ({}));
37211
- const ws = await workspaces.create({ path: deps.dataDir });
37223
+ const deps = await fetch("/live/api/deps").then((r) => r.json());
37224
+ if (!deps?.dataDir) throw new Error("the video plugin is not running yet");
37225
+ const known = () => workspaces.list?.getSnapshot().items?.find((w) => samePath(w.path, deps.dataDir));
37226
+ let ws = known();
37227
+ if (!ws) ws = await workspaces.create({ path: deps.dataDir }).catch((err) => {
37228
+ const again = known();
37229
+ if (again) return again;
37230
+ throw err;
37231
+ });
37212
37232
  const workspaceId = ws.workspaceId ?? ws.id;
37213
37233
  if (workspaceId && ws.title !== title) await workspaces.rename(workspaceId, title).catch(() => {});
37214
37234
  const id = await sessions.create(workspaceId ? { workspaceId } : { cwd: deps.dataDir });
@@ -37223,6 +37243,17 @@ async function pageSession(sessions, workspaces, title) {
37223
37243
  * The page's session becomes current so its chat is live; closing goes back.
37224
37244
  */
37225
37245
  async function openPage(sessions, opts = {}) {
37246
+ try {
37247
+ await showPage(sessions, opts);
37248
+ } catch (err) {
37249
+ console.error("[dsh-vidi] could not open the video page", err);
37250
+ pageStore.set({ error: err.message || String(err) });
37251
+ setTimeout(() => {
37252
+ if (pageStore.get().error) pageStore.set({ error: void 0 });
37253
+ }, 6e3);
37254
+ }
37255
+ }
37256
+ async function showPage(sessions, opts) {
37226
37257
  const current = sessions.current();
37227
37258
  const sid = opts.sessionId ?? await pageSession(sessions, opts.workspaces, opts.title ?? "Video");
37228
37259
  if (sid !== current) sessions.open(sid);
@@ -37248,6 +37279,11 @@ function VideoPage(props) {
37248
37279
  const page = (0, react.useSyncExternalStore)(pageStore.subscribe, pageStore.get);
37249
37280
  const face = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.face(page.sessionId) : void 0);
37250
37281
  const chat = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.chat(page.sessionId) : void 0);
37282
+ if (page.error && !page.open) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
37283
+ className: "dshl-toast",
37284
+ role: "alert",
37285
+ children: t("page.failed").replace("{e}", page.error)
37286
+ });
37251
37287
  if (!page.open || !page.sessionId) return null;
37252
37288
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
37253
37289
  className: "dshl-pagewrap",
package/lib/index.mjs CHANGED
@@ -2641,6 +2641,92 @@ var LiveJob = class LiveJob extends EventEmitter {
2641
2641
  for (const f of readdirSync(dir)) if (f.startsWith(`.${name}.`)) rmSync(join(dir, f), { force: true });
2642
2642
  return out;
2643
2643
  }
2644
+ /**
2645
+ * Cut [from, to] into an MP4 with the subtitles drawn into the picture,
2646
+ * looking exactly like the player (and the restream): each change of the
2647
+ * lines on screen becomes one transparent picture, shown for its stretch.
2648
+ */
2649
+ async burnClip(from, to, style, opts = {}) {
2650
+ if (!(to > from)) throw new Error("end must be after start");
2651
+ const h = opts.height ?? 1080, w = Math.round(h * 16 / 9);
2652
+ const dir = join(this.deps.dir, "clips");
2653
+ mkdirSync(dir, { recursive: true });
2654
+ const name = `clip_${from.toFixed(1)}-${to.toFixed(1)}_subs`.replace(/\./g, "_");
2655
+ const out = opts.out ?? join(dir, `${name}.mp4`);
2656
+ const tmp = join(dir, `.${name}`);
2657
+ mkdirSync(tmp, { recursive: true });
2658
+ const spans = [];
2659
+ for (let t = from; t < to; t += .1) {
2660
+ const rows = this.rowsAt(t, style);
2661
+ const key = JSON.stringify(rows);
2662
+ const last = spans.at(-1);
2663
+ if (last && last.key === key) last.end = Math.min(to, t + .1);
2664
+ else spans.push({
2665
+ start: t,
2666
+ end: Math.min(to, t + .1),
2667
+ key,
2668
+ rows
2669
+ });
2670
+ }
2671
+ const pictures = /* @__PURE__ */ new Map();
2672
+ const lines = ["ffconcat version 1.0"];
2673
+ for (const s of spans) {
2674
+ let file = pictures.get(s.key);
2675
+ if (!file) {
2676
+ file = join(tmp, `${pictures.size}.png`);
2677
+ const png = await drawSubtitles(s.rows, style, w, h);
2678
+ if (!png) throw new Error("subtitles could not be drawn on this computer");
2679
+ writeFileSync(file, png);
2680
+ pictures.set(s.key, file);
2681
+ }
2682
+ lines.push(`file '${file.replace(/'/g, "'\\''")}'`, `duration ${(s.end - s.start).toFixed(3)}`);
2683
+ }
2684
+ lines.push(`file '${pictures.get(spans.at(-1).key).replace(/'/g, "'\\''")}'`);
2685
+ const list = join(tmp, "subs.ffconcat");
2686
+ writeFileSync(list, lines.join("\n") + "\n");
2687
+ const { args } = this.mediaFor(from, to);
2688
+ try {
2689
+ await run$1("ffmpeg", [
2690
+ "-v",
2691
+ "error",
2692
+ "-y",
2693
+ ...args,
2694
+ "-f",
2695
+ "concat",
2696
+ "-safe",
2697
+ "0",
2698
+ "-i",
2699
+ list,
2700
+ "-t",
2701
+ String(to - from),
2702
+ "-filter_complex",
2703
+ `[0:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS[v];[1:v]format=rgba,setpts=PTS-STARTPTS[s];[v][s]overlay=0:0:eof_action=repeat,format=yuv420p[o]`,
2704
+ "-map",
2705
+ "[o]",
2706
+ "-map",
2707
+ "0:a?",
2708
+ "-c:v",
2709
+ "libx264",
2710
+ "-preset",
2711
+ "veryfast",
2712
+ "-crf",
2713
+ "20",
2714
+ "-c:a",
2715
+ "aac",
2716
+ "-b:a",
2717
+ "160k",
2718
+ "-movflags",
2719
+ "+faststart",
2720
+ out
2721
+ ], { timeout: 9e5 });
2722
+ } finally {
2723
+ rmSync(tmp, {
2724
+ recursive: true,
2725
+ force: true
2726
+ });
2727
+ }
2728
+ return out;
2729
+ }
2644
2730
  /** Language actually spoken (setting, or what recognition detected). */
2645
2731
  spokenLang() {
2646
2732
  if (this.opts.srcLang !== "auto") return this.opts.srcLang;
@@ -4660,6 +4746,7 @@ function apply(ctx, config) {
4660
4746
  jobs.set(id, job);
4661
4747
  if (!waiting) byConversation.set(opts.conversationId, id);
4662
4748
  job.autoSubs = true;
4749
+ bus.emit("job", job);
4663
4750
  job.start();
4664
4751
  return job;
4665
4752
  }
@@ -5201,6 +5288,27 @@ function apply(ctx, config) {
5201
5288
  },
5202
5289
  subsDefaults: lastOptions
5203
5290
  });
5291
+ /**
5292
+ * For companion plugins (a Studio edition and the like): the jobs with their
5293
+ * tools — frames, clips with subtitles drawn in, cues and events — and the
5294
+ * subtitle model, so a companion adds its own workflow without a copy of any
5295
+ * of this. Inject 'vidi' to use it.
5296
+ */
5297
+ const vidiService = {
5298
+ version: 1,
5299
+ dataDir: config.dataDir,
5300
+ job: (conversationId) => jobForConversation(conversationId),
5301
+ jobById: (id) => jobs.get(id),
5302
+ jobs: () => [...jobs.values()],
5303
+ onJob: (fn) => {
5304
+ bus.on("job", fn);
5305
+ return () => {
5306
+ bus.off("job", fn);
5307
+ };
5308
+ },
5309
+ llm: () => llm
5310
+ };
5311
+ ctx.effect(() => ctx.provide("vidi", vidiService), "dsh-vidi: companion service");
5204
5312
  ctx.effect(() => ctx.webServer.register({
5205
5313
  kind: "prefix",
5206
5314
  path: "/live/api",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",