@juspay/neurolink 12.40.0 → 12.40.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.
@@ -129,6 +129,23 @@ export declare class VideoProcessor extends BaseFileProcessor<ProcessedVideo> {
129
129
  * Falls back to ffprobe if mediabunny fails or doesn't support the format.
130
130
  */
131
131
  private probeVideoWithMediabunny;
132
+ /**
133
+ * Read metadata from the ffmpeg binary itself, for hosts that have ffmpeg
134
+ * but no ffprobe (mediabunny cannot read AVI, FLV or WMV, and ffprobe was the
135
+ * only other reader).
136
+ *
137
+ * ffmpeg prints the input's streams to stderr before it opens the output, so
138
+ * a zero-length run to the null muxer is enough to read them.
139
+ */
140
+ private probeVideoWithFfmpeg;
141
+ /**
142
+ * Parse the input section of an ffmpeg banner: `Duration:`, `bitrate:` and
143
+ * one `Stream #…: Video|Audio|Subtitle:` line per stream. Returns undefined
144
+ * when there is no positive duration, since frames cannot be timed without it.
145
+ */
146
+ private parseFfmpegInputReport;
147
+ /** ffmpeg names a layout ("mono", "stereo", "5.1(side)") or counts ("6 channels"). */
148
+ private channelCountFromLayout;
132
149
  /**
133
150
  * Build a structured metadata object from ffprobe data.
134
151
  *
@@ -445,6 +445,20 @@ export class VideoProcessor extends BaseFileProcessor {
445
445
  metadata = this.buildMetadata(probeResult.data, buffer.length);
446
446
  }
447
447
  }
448
+ if (!metadata) {
449
+ // ffmpeg-static ships ffmpeg only, so a host that relies on it has
450
+ // no ffprobe. Without a duration no frame timestamps can be chosen.
451
+ const ffmpegProbe = await this.probeVideoWithFfmpeg(tempVideoPath);
452
+ if (ffmpegProbe.success && ffmpegProbe.data) {
453
+ metadata = { ...ffmpegProbe.data, fileSize: buffer.length };
454
+ }
455
+ else {
456
+ // Nothing downstream reports this: an empty duration selects no
457
+ // frames, the request still succeeds, and the model is simply
458
+ // told nothing about the video.
459
+ logger.warn(`[NEUROLINK] No metadata could be read for ${filename} (mediabunny, ffprobe and ffmpeg all failed), so no keyframes will be extracted: ${ffmpegProbe.error}`);
460
+ }
461
+ }
448
462
  if (!metadata) {
449
463
  metadata = {
450
464
  duration: 0,
@@ -636,6 +650,111 @@ export class VideoProcessor extends BaseFileProcessor {
636
650
  input?.dispose();
637
651
  }
638
652
  }
653
+ /**
654
+ * Read metadata from the ffmpeg binary itself, for hosts that have ffmpeg
655
+ * but no ffprobe (mediabunny cannot read AVI, FLV or WMV, and ffprobe was the
656
+ * only other reader).
657
+ *
658
+ * ffmpeg prints the input's streams to stderr before it opens the output, so
659
+ * a zero-length run to the null muxer is enough to read them.
660
+ */
661
+ async probeVideoWithFfmpeg(filePath) {
662
+ let report;
663
+ try {
664
+ const { stderr } = await runFfmpeg(["-hide_banner", "-i", filePath, "-t", "0", "-f", "null", "-"], { timeoutMs: VIDEO_CONFIG.FFPROBE_TIMEOUT_MS });
665
+ report = stderr;
666
+ }
667
+ catch (error) {
668
+ // A non-zero exit still carries the stream report when the failure came
669
+ // after the input was opened.
670
+ const stderr = error.stderr;
671
+ if (typeof stderr !== "string") {
672
+ return {
673
+ success: false,
674
+ error: `ffmpeg probe failed: ${error instanceof Error ? error.message : String(error)}`,
675
+ };
676
+ }
677
+ report = stderr;
678
+ }
679
+ const data = this.parseFfmpegInputReport(report);
680
+ return data
681
+ ? { success: true, data }
682
+ : { success: false, error: "ffmpeg reported no duration" };
683
+ }
684
+ /**
685
+ * Parse the input section of an ffmpeg banner: `Duration:`, `bitrate:` and
686
+ * one `Stream #…: Video|Audio|Subtitle:` line per stream. Returns undefined
687
+ * when there is no positive duration, since frames cannot be timed without it.
688
+ */
689
+ parseFfmpegInputReport(report) {
690
+ const lines = report.split(/\r?\n/);
691
+ const outputStart = lines.findIndex((line) => line.startsWith("Output #"));
692
+ const inputLines = outputStart === -1 ? lines : lines.slice(0, outputStart);
693
+ const inputText = inputLines.join("\n");
694
+ const durationMatch = /Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/.exec(inputText);
695
+ if (!durationMatch) {
696
+ return undefined;
697
+ }
698
+ const duration = Number(durationMatch[1]) * 3600 +
699
+ Number(durationMatch[2]) * 60 +
700
+ Number(durationMatch[3]);
701
+ if (!(duration > 0)) {
702
+ return undefined;
703
+ }
704
+ const streams = inputLines.flatMap((line) => {
705
+ const match = /^\s*Stream #\d+:\d+(?:\[[^\]]*\])?(?:\([^)]*\))?:\s*(Video|Audio|Subtitle):\s*(.*)$/.exec(line);
706
+ return match ? [{ kind: match[1], details: match[2] }] : [];
707
+ });
708
+ const video = streams.find((stream) => stream.kind === "Video" && !stream.details.includes("attached pic"));
709
+ const audio = streams.find((stream) => stream.kind === "Audio");
710
+ const size = video
711
+ ? /(?:^|[\s,])(\d{2,5})x(\d{2,5})(?=[\s,[]|$)/.exec(video.details)
712
+ : null;
713
+ const frameRate = video
714
+ ? /,\s*(\d+(?:\.\d+)?)\s*(?:fps|tbr)/.exec(video.details)
715
+ : null;
716
+ const bitrate = /bitrate:\s*(\d+)\s*kb\/s/.exec(inputText);
717
+ const sampleRate = audio ? /(\d+)\s*Hz/.exec(audio.details) : null;
718
+ const layout = audio ? /Hz,\s*([^,]+)/.exec(audio.details) : null;
719
+ return {
720
+ duration,
721
+ durationFormatted: this.formatDuration(duration),
722
+ width: size ? Number(size[1]) : 0,
723
+ height: size ? Number(size[2]) : 0,
724
+ codec: video
725
+ ? (/^([^\s,(]+)/.exec(video.details)?.[1] ?? "unknown")
726
+ : "unknown",
727
+ fps: frameRate ? Number(frameRate[1]) : 0,
728
+ bitrate: bitrate ? Number(bitrate[1]) * 1000 : 0,
729
+ audioCodec: audio ? /^([^\s,(]+)/.exec(audio.details)?.[1] : undefined,
730
+ audioChannels: layout
731
+ ? this.channelCountFromLayout(layout[1])
732
+ : undefined,
733
+ audioSampleRate: sampleRate ? Number(sampleRate[1]) : undefined,
734
+ subtitleTracks: streams.filter((stream) => stream.kind === "Subtitle")
735
+ .length,
736
+ fileSize: 0,
737
+ };
738
+ }
739
+ /** ffmpeg names a layout ("mono", "stereo", "5.1(side)") or counts ("6 channels"). */
740
+ channelCountFromLayout(layout) {
741
+ const named = new Map([
742
+ ["mono", 1],
743
+ ["stereo", 2],
744
+ ["quad", 4],
745
+ ]);
746
+ const trimmed = layout.trim();
747
+ const namedCount = named.get(trimmed);
748
+ if (namedCount !== undefined) {
749
+ return namedCount;
750
+ }
751
+ const counted = /^(\d+) channels?/.exec(trimmed);
752
+ if (counted) {
753
+ return Number(counted[1]);
754
+ }
755
+ const dotted = /^(\d+)\.(\d)/.exec(trimmed);
756
+ return dotted ? Number(dotted[1]) + Number(dotted[2]) : undefined;
757
+ }
639
758
  /**
640
759
  * Build a structured metadata object from ffprobe data.
641
760
  *
@@ -1,8 +1,70 @@
1
- import { zodToJsonSchema } from "zod-to-json-schema";
2
- import { jsonSchemaToZod } from "json-schema-to-zod";
1
+ import { createRequire } from "node:module";
3
2
  import * as zodModule from "zod";
4
3
  import { z } from "zod";
5
4
  import { logger } from "./logger.js";
5
+ /**
6
+ * zod-to-json-schema and json-schema-to-zod are loaded synchronously via
7
+ * require() on first actual use, not via a top-level import, so merely
8
+ * importing this module no longer pulls in either package for the many
9
+ * callers who never convert a schema. Both ship a CJS `require` export
10
+ * condition (checked against their package.json), so require() works on
11
+ * any Node >=22.0.0 — no experimental flag, unlike require(esm) of a local
12
+ * ES module (which only works unflagged from Node >=22.12). Zod itself
13
+ * stays a normal static import: its runtime schemas (`z`, `zodModule`) are
14
+ * public exports used well beyond schema conversion. Follows the pattern in
15
+ * src/lib/proxy/otelLogSink.ts — construction stays one synchronous call,
16
+ * exactly as before; only the *import* is deferred.
17
+ *
18
+ * A require() made through createRequire() is invisible to bundlers, so a
19
+ * consumer that bundles the SDK without these packages next to the output can
20
+ * no longer resolve them, and listing them as external changes nothing for the
21
+ * same reason: the bundler never sees the call. That must not pass for a
22
+ * schema that simply had no properties: the failure is reported once per
23
+ * package at error level (the only level shown without NEUROLINK_DEBUG),
24
+ * remembered so later calls neither retry the lookup nor repeat the report,
25
+ * and the caller degrades exactly as it does when a conversion throws. The
26
+ * browser artifact is the one bundle we build ourselves;
27
+ * scripts/build-browser.mjs bundles both packages into it and serves them from
28
+ * its `module` stub.
29
+ */
30
+ const require = createRequire(import.meta.url);
31
+ function lazyPackageFunction(packageName, exportName) {
32
+ let attempted = false;
33
+ let resolved;
34
+ return () => {
35
+ if (attempted) {
36
+ return resolved;
37
+ }
38
+ attempted = true;
39
+ let failure;
40
+ try {
41
+ const loadedModule = require(packageName);
42
+ const candidate = typeof loadedModule === "object" && loadedModule !== null
43
+ ? Reflect.get(loadedModule, exportName)
44
+ : undefined;
45
+ if (typeof candidate === "function") {
46
+ resolved = candidate;
47
+ }
48
+ else {
49
+ failure = `it resolved without a "${exportName}" function`;
50
+ }
51
+ }
52
+ catch (error) {
53
+ failure = (error instanceof Error ? error.message : String(error)).split("\n")[0];
54
+ }
55
+ if (failure !== undefined) {
56
+ logger.error(`[SCHEMA-CONVERSION] Cannot load "${packageName}" (${failure}). Any schema ` +
57
+ `that needs it is replaced with an empty object schema, so the model ` +
58
+ `sees no parameters for it. The SDK loads this package at run time ` +
59
+ `and a bundler cannot see that load, so bundling the SDK does not ` +
60
+ `provide it: install "${packageName}" where @juspay/neurolink can ` +
61
+ `resolve it, next to your bundle if you bundle the SDK.`);
62
+ }
63
+ return resolved;
64
+ };
65
+ }
66
+ const loadZodToJsonSchema = lazyPackageFunction("zod-to-json-schema", "zodToJsonSchema");
67
+ const loadJsonSchemaToZod = lazyPackageFunction("json-schema-to-zod", "jsonSchemaToZod");
6
68
  // Zod 4 ships a built-in `z.toJSONSchema(...)`. Zod 3 does not — it returned
7
69
  // nothing of the sort and we relied entirely on `zod-to-json-schema`. The
8
70
  // `zod-to-json-schema` package only understands Zod 3's internal `_def`
@@ -360,12 +422,16 @@ target = "jsonSchema7") {
360
422
  }
361
423
  }
362
424
  // Zod 3 fallback path
425
+ const convertWithZodToJsonSchema = loadZodToJsonSchema();
426
+ if (!convertWithZodToJsonSchema) {
427
+ return { type: "object", properties: {} };
428
+ }
363
429
  try {
364
430
  // Zod 4→3 boundary: zodToJsonSchema types reference Zod 3's ZodSchema via zod/v3.
365
431
  // Runtime compatible — cast through unknown at this third-party boundary only.
366
432
  // eslint-disable-next-line no-restricted-syntax -- genuine third-party type-system boundary: zod-to-json-schema's input type is zod/v3's ZodType, structurally incompatible with the Zod 4 schema held here (no overlap, single assertion cannot compile); runtime-compatible fallback contract documented above.
367
433
  const zodV3Schema = zodSchema;
368
- const jsonSchema = zodToJsonSchema(zodV3Schema, {
434
+ const jsonSchema = convertWithZodToJsonSchema(zodV3Schema, {
369
435
  name: "ToolParameters",
370
436
  target,
371
437
  errorMessages: true,
@@ -612,7 +678,11 @@ export function convertJsonSchemaToZod(jsonSchema) {
612
678
  : [],
613
679
  });
614
680
  // Use official library to convert JSON Schema to Zod code
615
- const zodCodeResult = jsonSchemaToZod(jsonSchema, {
681
+ const convertToZodCode = loadJsonSchemaToZod();
682
+ if (!convertToZodCode) {
683
+ return z.object({}).passthrough();
684
+ }
685
+ const zodCodeResult = convertToZodCode(jsonSchema, {
616
686
  module: "esm",
617
687
  name: "schema",
618
688
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.40.0",
3
+ "version": "12.40.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -251,9 +251,10 @@
251
251
  "check:all": "pnpm run lint && pnpm run format --check && pnpm run validate && pnpm run validate:commit",
252
252
  "test:file-formats": "pnpm exec tsx test/continuous-test-suite-file-formats.ts",
253
253
  "test:audio-transcription": "pnpm exec tsx test/continuous-test-suite-audio-transcription.ts",
254
- "test:multimodal": "pnpm run test:file-formats && pnpm run test:multimodal:sdk && pnpm run test:video-native && pnpm run test:video-frames",
254
+ "test:multimodal": "pnpm run test:file-formats && pnpm run test:multimodal:sdk && pnpm run test:video-native && pnpm run test:video-frames && pnpm run test:video-no-ffprobe",
255
255
  "test:multimodal:sdk": "pnpm exec tsx test/continuous-test-suite-multimodal-sdk.ts",
256
256
  "test:video-frames": "pnpm exec tsx test/continuous-test-suite-video-frames.ts",
257
+ "test:video-no-ffprobe": "pnpm exec tsx test/continuous-test-suite-video-no-ffprobe.ts",
257
258
  "test:video-native": "pnpm exec tsx test/continuous-test-suite-video-native.ts",
258
259
  "test:mcp-result-cache": "pnpm exec tsx test/continuous-test-suite-mcp-result-cache.ts",
259
260
  "test:mcp-breaker-resolved-errors": "pnpm exec tsx test/continuous-test-suite-mcp-breaker-resolved-errors.ts",