@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.
|
|
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",
|