dsh-vidi 0.2.1 → 0.2.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.
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,6 +36758,8 @@ 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); }
36760
36764
  .dshl.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
36761
36765
  .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; }
@@ -37196,6 +37200,7 @@ function ensureStyles() {
37196
37200
 
37197
37201
  // src/client/VideoPage.tsx
37198
37202
  const PAGE_SESSION = "dsh-live.pageSession";
37203
+ const samePath = (a, b) => !!a && !!b && a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
37199
37204
  /**
37200
37205
  * The video page's own conversation: reused while it exists, created when not.
37201
37206
  * It lives in a "Video" workspace on the video folder, so DeepSeek's own file
@@ -37207,8 +37212,15 @@ async function pageSession(sessions, workspaces, title) {
37207
37212
  saved = localStorage.getItem(PAGE_SESSION);
37208
37213
  } catch {}
37209
37214
  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 });
37215
+ const deps = await fetch("/live/api/deps").then((r) => r.json());
37216
+ if (!deps?.dataDir) throw new Error("the video plugin is not running yet");
37217
+ const known = () => workspaces.list?.getSnapshot().items?.find((w) => samePath(w.path, deps.dataDir));
37218
+ let ws = known();
37219
+ if (!ws) ws = await workspaces.create({ path: deps.dataDir }).catch((err) => {
37220
+ const again = known();
37221
+ if (again) return again;
37222
+ throw err;
37223
+ });
37212
37224
  const workspaceId = ws.workspaceId ?? ws.id;
37213
37225
  if (workspaceId && ws.title !== title) await workspaces.rename(workspaceId, title).catch(() => {});
37214
37226
  const id = await sessions.create(workspaceId ? { workspaceId } : { cwd: deps.dataDir });
@@ -37223,6 +37235,17 @@ async function pageSession(sessions, workspaces, title) {
37223
37235
  * The page's session becomes current so its chat is live; closing goes back.
37224
37236
  */
37225
37237
  async function openPage(sessions, opts = {}) {
37238
+ try {
37239
+ await showPage(sessions, opts);
37240
+ } catch (err) {
37241
+ console.error("[dsh-vidi] could not open the video page", err);
37242
+ pageStore.set({ error: err.message || String(err) });
37243
+ setTimeout(() => {
37244
+ if (pageStore.get().error) pageStore.set({ error: void 0 });
37245
+ }, 6e3);
37246
+ }
37247
+ }
37248
+ async function showPage(sessions, opts) {
37226
37249
  const current = sessions.current();
37227
37250
  const sid = opts.sessionId ?? await pageSession(sessions, opts.workspaces, opts.title ?? "Video");
37228
37251
  if (sid !== current) sessions.open(sid);
@@ -37248,6 +37271,11 @@ function VideoPage(props) {
37248
37271
  const page = (0, react.useSyncExternalStore)(pageStore.subscribe, pageStore.get);
37249
37272
  const face = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.face(page.sessionId) : void 0);
37250
37273
  const chat = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.chat(page.sessionId) : void 0);
37274
+ if (page.error && !page.open) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
37275
+ className: "dshl-toast",
37276
+ role: "alert",
37277
+ children: t("page.failed").replace("{e}", page.error)
37278
+ });
37251
37279
  if (!page.open || !page.sessionId) return null;
37252
37280
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
37253
37281
  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.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",