@juspay/neurolink 12.40.0 → 12.40.1

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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.40.0",
3
+ "version": "12.40.1",
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",