@juspay/neurolink 12.29.1 → 12.30.0
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/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +376 -374
- package/dist/cli/factories/commandFactory.js +2 -1
- package/dist/core/baseProvider.js +1 -0
- package/dist/core/modules/MessageBuilder.js +8 -0
- package/dist/processors/media/VideoProcessor.d.ts +56 -4
- package/dist/processors/media/VideoProcessor.js +263 -19
- package/dist/providers/catalog/deepseek.json +2 -1
- package/dist/providers/catalog/loader.js +3 -0
- package/dist/providers/catalog/provider-catalog.schema.json +4 -0
- package/dist/providers/catalog/schema.d.ts +1 -0
- package/dist/providers/catalog/schema.js +1 -0
- package/dist/providers/configuredOpenAICompat.d.ts +1 -0
- package/dist/providers/configuredOpenAICompat.js +3 -0
- package/dist/providers/openaiChatCompletionsBase.d.ts +8 -0
- package/dist/providers/openaiChatCompletionsBase.js +16 -2
- package/dist/providers/openaiChatCompletionsClient.d.ts +1 -1
- package/dist/providers/openaiChatCompletionsClient.js +13 -2
- package/dist/types/file.d.ts +20 -2
- package/dist/types/generate.d.ts +20 -26
- package/dist/types/openaiCompatible.d.ts +3 -0
- package/dist/types/processor.d.ts +43 -0
- package/dist/types/providerCatalog.d.ts +5 -0
- package/dist/types/providers.d.ts +3 -0
- package/dist/types/stream.d.ts +11 -11
- package/dist/utils/fileDetector.d.ts +15 -0
- package/dist/utils/fileDetector.js +40 -7
- package/dist/utils/mediaDuration.d.ts +19 -0
- package/dist/utils/mediaDuration.js +23 -0
- package/dist/utils/messageBuilder.js +4 -15
- package/dist/utils/multimodalOptionsBuilder.d.ts +2 -0
- package/dist/utils/multimodalOptionsBuilder.js +7 -0
- package/docs-site/static/search-index.json +6 -6
- package/package.json +3 -2
|
@@ -171,7 +171,8 @@ export class CLICommandFactory {
|
|
|
171
171
|
"transcribe-audio": {
|
|
172
172
|
type: "boolean",
|
|
173
173
|
default: false,
|
|
174
|
-
description: "
|
|
174
|
+
description: "Transcribe the video's spoken audio (needs ffmpeg and OPENAI_API_KEY). " +
|
|
175
|
+
"Unnecessary for Gemini, which hears the clip directly",
|
|
175
176
|
},
|
|
176
177
|
file: {
|
|
177
178
|
type: "string",
|
|
@@ -936,6 +936,7 @@ export class BaseProvider {
|
|
|
936
936
|
context: options.context,
|
|
937
937
|
csvOptions: options.csvOptions,
|
|
938
938
|
pdfOptions: options.pdfOptions,
|
|
939
|
+
videoOptions: options.videoOptions,
|
|
939
940
|
// Forward abort, tool filtering, and timeout options to prevent
|
|
940
941
|
// silent bypass when falling back from real streaming to fake streaming
|
|
941
942
|
abortSignal: options.abortSignal,
|
|
@@ -105,6 +105,10 @@ export class MessageBuilder {
|
|
|
105
105
|
csvOptions: options.csvOptions,
|
|
106
106
|
pdfOptions: options.pdfOptions,
|
|
107
107
|
imageOptions: options.imageOptions,
|
|
108
|
+
// Same reason as csvOptions: the keyframe budget and the
|
|
109
|
+
// transcription flag are read downstream by the detector, and
|
|
110
|
+
// a reconstruction that omits them silently restores defaults.
|
|
111
|
+
videoOptions: options.videoOptions,
|
|
108
112
|
provider: options.provider,
|
|
109
113
|
model: options.model,
|
|
110
114
|
temperature: options.temperature,
|
|
@@ -236,6 +240,10 @@ export class MessageBuilder {
|
|
|
236
240
|
csvOptions: options.csvOptions,
|
|
237
241
|
pdfOptions: options.pdfOptions,
|
|
238
242
|
imageOptions: options.imageOptions,
|
|
243
|
+
// Same reason as csvOptions: the keyframe budget and the
|
|
244
|
+
// transcription flag are read downstream by the detector, and
|
|
245
|
+
// a reconstruction that omits them silently restores defaults.
|
|
246
|
+
videoOptions: options.videoOptions,
|
|
239
247
|
provider: options.provider,
|
|
240
248
|
model: options.model,
|
|
241
249
|
temperature: options.temperature,
|
|
@@ -142,6 +142,47 @@ export declare class VideoProcessor extends BaseFileProcessor<ProcessedVideo> {
|
|
|
142
142
|
* falling back to the default when absent or non-numeric (#478).
|
|
143
143
|
*/
|
|
144
144
|
private static resolveFrameQuality;
|
|
145
|
+
/**
|
|
146
|
+
* Extract the clip's audio track and transcribe the speech in it (#433).
|
|
147
|
+
*
|
|
148
|
+
* ## Why this exists at all, now that Gemini hears video directly
|
|
149
|
+
*
|
|
150
|
+
* It does not help Gemini — a native provider receives the clip and its
|
|
151
|
+
* audio together. It is for everyone else. A provider on the frame path
|
|
152
|
+
* gets stills and nothing else, so a recorded standup, a support call or a
|
|
153
|
+
* narrated demo arrives with its entire spoken content missing, and
|
|
154
|
+
* "summarise this meeting" is answered from four screenshots.
|
|
155
|
+
*
|
|
156
|
+
* ## Why it is not a stub
|
|
157
|
+
*
|
|
158
|
+
* The issue asked for one, on the grounds that no transcription backend
|
|
159
|
+
* existed yet. One does: `AudioProcessor` has shipped Whisper transcription
|
|
160
|
+
* for standalone audio files for some time. A method that logged "not yet
|
|
161
|
+
* implemented" and returned undefined would be dead code sitting next to a
|
|
162
|
+
* working implementation of the same thing, and `--transcribe-audio` would
|
|
163
|
+
* still do nothing.
|
|
164
|
+
*
|
|
165
|
+
* The Whisper call is reproduced here rather than shared with
|
|
166
|
+
* `AudioProcessor`: extracting it into a common module is the better
|
|
167
|
+
* long-term shape, but that file is being edited concurrently, and a
|
|
168
|
+
* merge conflict in the audio pipeline is a worse outcome than fifty
|
|
169
|
+
* duplicated lines. The duplication is worth removing once both land.
|
|
170
|
+
*
|
|
171
|
+
* ## Failure behaviour
|
|
172
|
+
*
|
|
173
|
+
* Best-effort throughout, and never throws: a clip with no audio track, a
|
|
174
|
+
* machine without ffmpeg, a missing key, an oversized track or a failed
|
|
175
|
+
* request all return a *reason* and leave the rest of the pipeline intact.
|
|
176
|
+
* Each reason is distinct, because from the outside every one of them
|
|
177
|
+
* looks the same — no transcript — while the remedies differ completely.
|
|
178
|
+
*
|
|
179
|
+
* @param videoPath - Temp path to the video, already written by the caller
|
|
180
|
+
* @param tempDir - Caller-owned temp directory; removed in its `finally`
|
|
181
|
+
* @param metadata - Probed metadata, consulted for an audio track
|
|
182
|
+
* @param filename - Display name, for logs
|
|
183
|
+
* @param options - Caller settings; transcription runs only when asked
|
|
184
|
+
*/
|
|
185
|
+
private extractAndTranscribeAudio;
|
|
145
186
|
/**
|
|
146
187
|
* Extract keyframes from a video at calculated intervals.
|
|
147
188
|
*
|
|
@@ -166,18 +207,28 @@ export declare class VideoProcessor extends BaseFileProcessor<ProcessedVideo> {
|
|
|
166
207
|
* @param tempDir - Temp directory for frame output
|
|
167
208
|
* @param durationSec - Video duration in seconds
|
|
168
209
|
* @param options - Caller frame budget / encoder settings
|
|
169
|
-
* @returns
|
|
210
|
+
* @returns Each kept frame with the second it was sampled at, ascending
|
|
170
211
|
*/
|
|
171
212
|
private extractKeyframes;
|
|
172
213
|
/**
|
|
173
214
|
* Run ffmpeg to extract frames at specified timestamps.
|
|
174
215
|
*
|
|
175
|
-
* Uses the `-vf select` filter to pick frames at
|
|
176
|
-
* which is more efficient than seeking for each frame individually.
|
|
216
|
+
* Uses the `-vf select` filter to pick frames at least `intervalSec` apart,
|
|
217
|
+
* which is more efficient than seeking for each frame individually. This is
|
|
218
|
+
* a minimum-gap filter, not a seek to an exact time: on a source whose real
|
|
219
|
+
* frame spacing exceeds the requested interval, the frame actually selected
|
|
220
|
+
* can land well after the ideal schedule. A `showinfo` stage between
|
|
221
|
+
* `select` and `scale` reports each selected frame's true `pts_time` to
|
|
222
|
+
* stderr so the caller can label frames with when they were really
|
|
223
|
+
* sampled, not the idealized request.
|
|
177
224
|
*
|
|
178
225
|
* @param videoPath - Path to the video file
|
|
179
226
|
* @param outputDir - Directory to write frame files
|
|
180
227
|
* @param timestamps - Array of timestamps in seconds
|
|
228
|
+
* @param intervalSec - Minimum gap enforced between selected frames
|
|
229
|
+
* @returns The real `pts_time` (seconds) ffmpeg reported for each selected
|
|
230
|
+
* frame, in output-file order; may be shorter than `timestamps` if fewer
|
|
231
|
+
* real frames were available to select
|
|
181
232
|
*/
|
|
182
233
|
private runFfmpegFrameExtraction;
|
|
183
234
|
/**
|
|
@@ -217,9 +268,10 @@ export declare class VideoProcessor extends BaseFileProcessor<ProcessedVideo> {
|
|
|
217
268
|
* - Subtitle text (if available)
|
|
218
269
|
*
|
|
219
270
|
* @param metadata - Extracted video metadata
|
|
220
|
-
* @param
|
|
271
|
+
* @param keyframeTimestampsSec - The real second each kept keyframe was sampled at
|
|
221
272
|
* @param subtitleText - Extracted subtitle text (if any)
|
|
222
273
|
* @param filename - Original filename
|
|
274
|
+
* @param transcript - Machine transcription of the spoken audio (if requested and available)
|
|
223
275
|
* @returns Formatted text content for the LLM
|
|
224
276
|
*/
|
|
225
277
|
private buildTextContent;
|
|
@@ -50,13 +50,15 @@ import { join } from "path";
|
|
|
50
50
|
import { Readable } from "stream";
|
|
51
51
|
import { pipeline } from "stream/promises";
|
|
52
52
|
import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
|
|
53
|
-
import { formatMediaDuration } from "../../utils/mediaDuration.js";
|
|
53
|
+
import { formatKeyframeTimestamp, formatMediaDuration, } from "../../utils/mediaDuration.js";
|
|
54
54
|
import { SIZE_LIMITS_MB } from "../config/index.js";
|
|
55
55
|
import { extensionsForModality, mimeTypesForModality, } from "../config/fileTypeRegistry.js";
|
|
56
56
|
import { FileErrorCode } from "../errors/index.js";
|
|
57
57
|
import { tracers, ATTR, withSpan } from "../../telemetry/index.js";
|
|
58
58
|
import { logger } from "../../utils/logger.js";
|
|
59
59
|
import { tryImport } from "../../utils/tryImport.js";
|
|
60
|
+
import { withTimeout } from "../../utils/errorHandling.js";
|
|
61
|
+
import { runFfmpeg } from "../../adapters/video/ffmpegAdapter.js";
|
|
60
62
|
/**
|
|
61
63
|
* Narrow a loaded `fluent-ffmpeg` export to the shape this file actually uses:
|
|
62
64
|
* a callable carrying the `ffprobe` and `setFfmpegPath` statics.
|
|
@@ -202,6 +204,21 @@ const VIDEO_CONFIG = {
|
|
|
202
204
|
FFMPEG_TIMEOUT_MS: 120_000,
|
|
203
205
|
/** Timeout for ffprobe metadata extraction in milliseconds */
|
|
204
206
|
FFPROBE_TIMEOUT_MS: 10_000,
|
|
207
|
+
/**
|
|
208
|
+
* Timeout for demuxing the audio track out of a video (#433).
|
|
209
|
+
*
|
|
210
|
+
* Generous next to frame extraction: this reads the whole file rather than
|
|
211
|
+
* seeking to a handful of offsets, and a meeting recording is long.
|
|
212
|
+
*/
|
|
213
|
+
AUDIO_EXTRACT_TIMEOUT_MS: 180_000,
|
|
214
|
+
/** Timeout for one Whisper transcription request in milliseconds */
|
|
215
|
+
TRANSCRIPTION_TIMEOUT_MS: 120_000,
|
|
216
|
+
/**
|
|
217
|
+
* Whisper's upload ceiling in MB. The extracted track is mono 16 kHz MP3,
|
|
218
|
+
* so this is roughly six hours of speech — a clip that breaches it is
|
|
219
|
+
* unusual enough to be worth saying so rather than silently truncating.
|
|
220
|
+
*/
|
|
221
|
+
WHISPER_MAX_SIZE_MB: 25,
|
|
205
222
|
};
|
|
206
223
|
/**
|
|
207
224
|
* Supported video MIME types — derived from the canonical registry so this
|
|
@@ -285,6 +302,8 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
285
302
|
filename: this.getFilename(fileInfo),
|
|
286
303
|
textContent: "",
|
|
287
304
|
keyframes: [],
|
|
305
|
+
keyframeTimestampsSec: [],
|
|
306
|
+
hasTranscript: false,
|
|
288
307
|
metadata: {
|
|
289
308
|
duration: 0,
|
|
290
309
|
durationFormatted: formatMediaDuration(0),
|
|
@@ -456,6 +475,14 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
456
475
|
logger.warn(`[NEUROLINK] Video keyframe extraction failed for ${filename}, continuing without keyframes`);
|
|
457
476
|
}
|
|
458
477
|
span.setAttribute(ATTR.VIDEO_KEYFRAMES_EXTRACTED, keyframes.length);
|
|
478
|
+
// Debug-only: the real per-frame sample times, so a caller running
|
|
479
|
+
// with --debug can see what the model was actually told without
|
|
480
|
+
// re-deriving it from the request body.
|
|
481
|
+
if (logger.shouldLog("debug") && keyframes.length > 0) {
|
|
482
|
+
logger.debug(`[NEUROLINK] Video keyframe timestamps: ${keyframes
|
|
483
|
+
.map((frame) => frame.timestampSec.toFixed(3))
|
|
484
|
+
.join(",")}`);
|
|
485
|
+
}
|
|
459
486
|
// Step 6: Extract subtitles
|
|
460
487
|
let subtitleText;
|
|
461
488
|
if (metadata.subtitleTracks > 0) {
|
|
@@ -466,14 +493,16 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
466
493
|
// Non-fatal: continue without subtitles if extraction fails
|
|
467
494
|
}
|
|
468
495
|
}
|
|
469
|
-
// Step 7:
|
|
470
|
-
const
|
|
496
|
+
// Step 7: Transcribe spoken audio, if asked
|
|
497
|
+
const transcription = await this.extractAndTranscribeAudio(tempVideoPath, tempDir, metadata, filename, options);
|
|
498
|
+
// Step 8: Build textContent for LLM
|
|
499
|
+
const textContent = this.buildTextContent(metadata, keyframes.map((frame) => frame.timestampSec), subtitleText, this.getFilename(fileInfo), transcription.transcript);
|
|
471
500
|
span.setAttribute(ATTR.VIDEO_TEXT_CONTENT_LENGTH, textContent.length);
|
|
472
501
|
span.setAttribute(ATTR.FILE_OUTPUT_LENGTH, textContent.length);
|
|
473
502
|
span.setAttribute(ATTR.FILE_SUCCESS, true);
|
|
474
503
|
logger.info(`[NEUROLINK] Video processed: ${filename} → ${textContent.length} bytes text + ${keyframes.length} keyframes ` +
|
|
475
504
|
`(${metadata.durationFormatted}, ${metadata.width}x${metadata.height}, ${metadata.codec})`);
|
|
476
|
-
// Step
|
|
505
|
+
// Step 9: Return structured result
|
|
477
506
|
return {
|
|
478
507
|
success: true,
|
|
479
508
|
data: {
|
|
@@ -482,9 +511,15 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
482
511
|
size: fileInfo.size,
|
|
483
512
|
filename: this.getFilename(fileInfo),
|
|
484
513
|
textContent,
|
|
485
|
-
keyframes,
|
|
514
|
+
keyframes: keyframes.map((frame) => frame.buffer),
|
|
515
|
+
keyframeTimestampsSec: keyframes.map((frame) => frame.timestampSec),
|
|
486
516
|
metadata,
|
|
487
517
|
subtitleText,
|
|
518
|
+
transcript: transcription.transcript,
|
|
519
|
+
hasTranscript: !!transcription.transcript,
|
|
520
|
+
...(transcription.skippedReason
|
|
521
|
+
? { transcriptionSkippedReason: transcription.skippedReason }
|
|
522
|
+
: {}),
|
|
488
523
|
hasKeyframes: keyframes.length > 0,
|
|
489
524
|
frameCount: keyframes.length,
|
|
490
525
|
},
|
|
@@ -504,7 +539,7 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
504
539
|
};
|
|
505
540
|
}
|
|
506
541
|
finally {
|
|
507
|
-
// Step
|
|
542
|
+
// Step 10: Clean up temp files
|
|
508
543
|
if (tempCreated) {
|
|
509
544
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
510
545
|
// Ignore cleanup errors - temp files will be cleaned by OS eventually
|
|
@@ -662,6 +697,154 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
662
697
|
}
|
|
663
698
|
return Math.min(100, Math.max(1, Math.round(quality)));
|
|
664
699
|
}
|
|
700
|
+
/**
|
|
701
|
+
* Extract the clip's audio track and transcribe the speech in it (#433).
|
|
702
|
+
*
|
|
703
|
+
* ## Why this exists at all, now that Gemini hears video directly
|
|
704
|
+
*
|
|
705
|
+
* It does not help Gemini — a native provider receives the clip and its
|
|
706
|
+
* audio together. It is for everyone else. A provider on the frame path
|
|
707
|
+
* gets stills and nothing else, so a recorded standup, a support call or a
|
|
708
|
+
* narrated demo arrives with its entire spoken content missing, and
|
|
709
|
+
* "summarise this meeting" is answered from four screenshots.
|
|
710
|
+
*
|
|
711
|
+
* ## Why it is not a stub
|
|
712
|
+
*
|
|
713
|
+
* The issue asked for one, on the grounds that no transcription backend
|
|
714
|
+
* existed yet. One does: `AudioProcessor` has shipped Whisper transcription
|
|
715
|
+
* for standalone audio files for some time. A method that logged "not yet
|
|
716
|
+
* implemented" and returned undefined would be dead code sitting next to a
|
|
717
|
+
* working implementation of the same thing, and `--transcribe-audio` would
|
|
718
|
+
* still do nothing.
|
|
719
|
+
*
|
|
720
|
+
* The Whisper call is reproduced here rather than shared with
|
|
721
|
+
* `AudioProcessor`: extracting it into a common module is the better
|
|
722
|
+
* long-term shape, but that file is being edited concurrently, and a
|
|
723
|
+
* merge conflict in the audio pipeline is a worse outcome than fifty
|
|
724
|
+
* duplicated lines. The duplication is worth removing once both land.
|
|
725
|
+
*
|
|
726
|
+
* ## Failure behaviour
|
|
727
|
+
*
|
|
728
|
+
* Best-effort throughout, and never throws: a clip with no audio track, a
|
|
729
|
+
* machine without ffmpeg, a missing key, an oversized track or a failed
|
|
730
|
+
* request all return a *reason* and leave the rest of the pipeline intact.
|
|
731
|
+
* Each reason is distinct, because from the outside every one of them
|
|
732
|
+
* looks the same — no transcript — while the remedies differ completely.
|
|
733
|
+
*
|
|
734
|
+
* @param videoPath - Temp path to the video, already written by the caller
|
|
735
|
+
* @param tempDir - Caller-owned temp directory; removed in its `finally`
|
|
736
|
+
* @param metadata - Probed metadata, consulted for an audio track
|
|
737
|
+
* @param filename - Display name, for logs
|
|
738
|
+
* @param options - Caller settings; transcription runs only when asked
|
|
739
|
+
*/
|
|
740
|
+
async extractAndTranscribeAudio(videoPath, tempDir, metadata, filename, options) {
|
|
741
|
+
if (!options?.transcribeAudio) {
|
|
742
|
+
// Not a skip worth reporting: nobody asked. Reporting it would put a
|
|
743
|
+
// "no transcript because..." line in every video result.
|
|
744
|
+
return {};
|
|
745
|
+
}
|
|
746
|
+
const skipped = (reason) => {
|
|
747
|
+
logger.warn(`[VideoProcessor] No transcript for ${filename}: ${reason}. ` +
|
|
748
|
+
`The model will receive the keyframes and metadata only.`);
|
|
749
|
+
return { skippedReason: reason };
|
|
750
|
+
};
|
|
751
|
+
// Probing reports no audio codec both for a genuinely silent clip and
|
|
752
|
+
// for one nothing could open. Either way there is nothing to send, and
|
|
753
|
+
// the ffmpeg pass below would only fail more slowly.
|
|
754
|
+
if (!metadata.audioCodec) {
|
|
755
|
+
return skipped("the video has no audio track");
|
|
756
|
+
}
|
|
757
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
758
|
+
if (!apiKey) {
|
|
759
|
+
return skipped("OPENAI_API_KEY is not set, and Whisper is the only transcription backend wired up");
|
|
760
|
+
}
|
|
761
|
+
const audioPath = join(tempDir, "audio.mp3");
|
|
762
|
+
try {
|
|
763
|
+
await runFfmpeg([
|
|
764
|
+
"-y",
|
|
765
|
+
"-v",
|
|
766
|
+
"error",
|
|
767
|
+
"-i",
|
|
768
|
+
videoPath,
|
|
769
|
+
// Drop the video stream outright. Without -vn ffmpeg tries to carry
|
|
770
|
+
// it into an MP3 container as cover art and fails on most inputs.
|
|
771
|
+
"-vn",
|
|
772
|
+
// Mono at 16 kHz is what Whisper resamples to anyway, and it keeps
|
|
773
|
+
// an hour-long recording comfortably under the upload ceiling.
|
|
774
|
+
"-ac",
|
|
775
|
+
"1",
|
|
776
|
+
"-ar",
|
|
777
|
+
"16000",
|
|
778
|
+
"-c:a",
|
|
779
|
+
"libmp3lame",
|
|
780
|
+
"-q:a",
|
|
781
|
+
"4",
|
|
782
|
+
audioPath,
|
|
783
|
+
], { timeoutMs: VIDEO_CONFIG.AUDIO_EXTRACT_TIMEOUT_MS });
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
787
|
+
return skipped(`the audio track could not be extracted — ${detail}`);
|
|
788
|
+
}
|
|
789
|
+
let audioBuffer;
|
|
790
|
+
try {
|
|
791
|
+
audioBuffer = await fs.readFile(audioPath);
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
return skipped("ffmpeg reported success but wrote no audio file");
|
|
795
|
+
}
|
|
796
|
+
if (audioBuffer.length === 0) {
|
|
797
|
+
return skipped("the extracted audio track was empty");
|
|
798
|
+
}
|
|
799
|
+
const sizeMB = audioBuffer.length / (1024 * 1024);
|
|
800
|
+
if (sizeMB > VIDEO_CONFIG.WHISPER_MAX_SIZE_MB) {
|
|
801
|
+
return skipped(`the extracted audio is ${sizeMB.toFixed(1)}MB, over Whisper's ` +
|
|
802
|
+
`${VIDEO_CONFIG.WHISPER_MAX_SIZE_MB}MB limit — split the recording`);
|
|
803
|
+
}
|
|
804
|
+
const baseUrl = (process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
|
|
805
|
+
const form = new FormData();
|
|
806
|
+
form.append("file", new Blob([new Uint8Array(audioBuffer)], { type: "audio/mpeg" }), "audio.mp3");
|
|
807
|
+
form.append("model", "whisper-1");
|
|
808
|
+
form.append("response_format", "verbose_json");
|
|
809
|
+
// `withTimeout` only races a promise against a timer — it cannot cancel
|
|
810
|
+
// the request. Without the abort, a timed-out upload keeps its socket and
|
|
811
|
+
// its in-flight body alive after the caller has already moved on.
|
|
812
|
+
const abort = new AbortController();
|
|
813
|
+
const timer = setTimeout(() => abort.abort(), VIDEO_CONFIG.TRANSCRIPTION_TIMEOUT_MS);
|
|
814
|
+
try {
|
|
815
|
+
const response = await withTimeout(fetch(`${baseUrl}/audio/transcriptions`, {
|
|
816
|
+
method: "POST",
|
|
817
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
818
|
+
body: form,
|
|
819
|
+
signal: abort.signal,
|
|
820
|
+
}), VIDEO_CONFIG.TRANSCRIPTION_TIMEOUT_MS);
|
|
821
|
+
if (!response.ok) {
|
|
822
|
+
const detail = await response.text().catch(() => "");
|
|
823
|
+
return skipped(`the transcription request failed — HTTP ${response.status}` +
|
|
824
|
+
`${detail ? `: ${detail.slice(0, 200)}` : ""}`);
|
|
825
|
+
}
|
|
826
|
+
const payload = await response.json();
|
|
827
|
+
const text = typeof payload === "object" &&
|
|
828
|
+
payload !== null &&
|
|
829
|
+
typeof payload.text === "string"
|
|
830
|
+
? payload.text.trim()
|
|
831
|
+
: "";
|
|
832
|
+
if (text.length === 0) {
|
|
833
|
+
// A successful call returning nothing is a real outcome — silence,
|
|
834
|
+
// music, no speech — and not the same as a failure.
|
|
835
|
+
return skipped("Whisper returned an empty transcript for this audio");
|
|
836
|
+
}
|
|
837
|
+
logger.debug(`[VideoProcessor] Transcribed ${filename} via openai-whisper (${text.length} chars)`);
|
|
838
|
+
return { transcript: text };
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
842
|
+
return skipped(`the transcription request failed — ${detail}`);
|
|
843
|
+
}
|
|
844
|
+
finally {
|
|
845
|
+
clearTimeout(timer);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
665
848
|
/**
|
|
666
849
|
* Extract keyframes from a video at calculated intervals.
|
|
667
850
|
*
|
|
@@ -686,7 +869,7 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
686
869
|
* @param tempDir - Temp directory for frame output
|
|
687
870
|
* @param durationSec - Video duration in seconds
|
|
688
871
|
* @param options - Caller frame budget / encoder settings
|
|
689
|
-
* @returns
|
|
872
|
+
* @returns Each kept frame with the second it was sampled at, ascending
|
|
690
873
|
*/
|
|
691
874
|
async extractKeyframes(videoPath, tempDir, durationSec, options) {
|
|
692
875
|
if (durationSec <= 0) {
|
|
@@ -726,7 +909,14 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
726
909
|
// Extract frames using ffmpeg
|
|
727
910
|
const framesDir = join(tempDir, "frames");
|
|
728
911
|
await fs.mkdir(framesDir, { recursive: true });
|
|
729
|
-
|
|
912
|
+
// The requested `timestamps` are an idealized, evenly-spaced schedule.
|
|
913
|
+
// ffmpeg's `-vf select` only guarantees a MINIMUM gap since the last
|
|
914
|
+
// frame it actually selected — on a source whose real frame spacing
|
|
915
|
+
// exceeds that gap, the selected frames land later than the schedule
|
|
916
|
+
// and the gap compounds across every subsequent frame. `showinfo`
|
|
917
|
+
// reports each selected frame's true `pts_time`, which is what gets
|
|
918
|
+
// paired with the encoded file below instead of the idealized value.
|
|
919
|
+
const realTimestamps = await this.runFfmpegFrameExtraction(videoPath, framesDir, timestamps, intervalSec);
|
|
730
920
|
// Read extracted frames and resize with sharp
|
|
731
921
|
const keyframes = [];
|
|
732
922
|
for (let i = 0; i < timestamps.length; i++) {
|
|
@@ -746,7 +936,14 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
746
936
|
const resized = await (options?.format === "png"
|
|
747
937
|
? pipeline.png({ quality })
|
|
748
938
|
: pipeline.jpeg({ quality })).toBuffer();
|
|
749
|
-
|
|
939
|
+
// Paired as the frame is kept, not derived afterwards from the
|
|
940
|
+
// interval: the catch below drops a frame whose encode failed, and a
|
|
941
|
+
// reconstructed schedule would then mislabel every frame after it.
|
|
942
|
+
// Prefer the real PTS ffmpeg reported for this output file; only
|
|
943
|
+
// fall back to the idealized schedule value if `showinfo` didn't
|
|
944
|
+
// report one (e.g. an older/unexpected ffmpeg build).
|
|
945
|
+
const timestampSec = realTimestamps[i] ?? timestamps[i];
|
|
946
|
+
keyframes.push({ buffer: resized, timestampSec });
|
|
750
947
|
}
|
|
751
948
|
catch {
|
|
752
949
|
// Skip individual frame on resize/encode failure
|
|
@@ -757,12 +954,22 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
757
954
|
/**
|
|
758
955
|
* Run ffmpeg to extract frames at specified timestamps.
|
|
759
956
|
*
|
|
760
|
-
* Uses the `-vf select` filter to pick frames at
|
|
761
|
-
* which is more efficient than seeking for each frame individually.
|
|
957
|
+
* Uses the `-vf select` filter to pick frames at least `intervalSec` apart,
|
|
958
|
+
* which is more efficient than seeking for each frame individually. This is
|
|
959
|
+
* a minimum-gap filter, not a seek to an exact time: on a source whose real
|
|
960
|
+
* frame spacing exceeds the requested interval, the frame actually selected
|
|
961
|
+
* can land well after the ideal schedule. A `showinfo` stage between
|
|
962
|
+
* `select` and `scale` reports each selected frame's true `pts_time` to
|
|
963
|
+
* stderr so the caller can label frames with when they were really
|
|
964
|
+
* sampled, not the idealized request.
|
|
762
965
|
*
|
|
763
966
|
* @param videoPath - Path to the video file
|
|
764
967
|
* @param outputDir - Directory to write frame files
|
|
765
968
|
* @param timestamps - Array of timestamps in seconds
|
|
969
|
+
* @param intervalSec - Minimum gap enforced between selected frames
|
|
970
|
+
* @returns The real `pts_time` (seconds) ffmpeg reported for each selected
|
|
971
|
+
* frame, in output-file order; may be shorter than `timestamps` if fewer
|
|
972
|
+
* real frames were available to select
|
|
766
973
|
*/
|
|
767
974
|
async runFfmpegFrameExtraction(videoPath, outputDir, timestamps, intervalSec) {
|
|
768
975
|
const ff = await loadFluentFfmpeg();
|
|
@@ -770,13 +977,15 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
770
977
|
// Improved select expression to pick exactly one frame per interval
|
|
771
978
|
// instead of multiple frames within a 0.5s window.
|
|
772
979
|
const selectExpr = `isnan(prev_selected_t)+gte(t-prev_selected_t,${intervalSec}-0.001)`;
|
|
980
|
+
const PTS_TIME_RE = /pts_time:\s*(-?\d+(?:\.\d+)?)/;
|
|
981
|
+
const realTimestamps = [];
|
|
773
982
|
const timeoutId = setTimeout(() => {
|
|
774
983
|
reject(new Error(`ffmpeg frame extraction timed out after ${VIDEO_CONFIG.FFMPEG_TIMEOUT_MS}ms`));
|
|
775
984
|
}, VIDEO_CONFIG.FFMPEG_TIMEOUT_MS);
|
|
776
985
|
ff(videoPath)
|
|
777
986
|
.outputOptions([
|
|
778
987
|
"-vf",
|
|
779
|
-
`select='${selectExpr}',scale='min(${VIDEO_CONFIG.FRAME_MAX_DIMENSION}\\,iw):-2'`,
|
|
988
|
+
`select='${selectExpr}',showinfo,scale='min(${VIDEO_CONFIG.FRAME_MAX_DIMENSION}\\,iw):-2'`,
|
|
780
989
|
"-vsync",
|
|
781
990
|
"vfr",
|
|
782
991
|
"-q:v",
|
|
@@ -785,9 +994,17 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
785
994
|
String(timestamps.length),
|
|
786
995
|
])
|
|
787
996
|
.output(join(outputDir, "frame_%04d.jpg"))
|
|
997
|
+
.on("stderr", (line) => {
|
|
998
|
+
// showinfo writes one log line per selected frame, in the same
|
|
999
|
+
// ascending order ffmpeg encodes frame_0001, frame_0002, ...
|
|
1000
|
+
const match = PTS_TIME_RE.exec(line);
|
|
1001
|
+
if (match) {
|
|
1002
|
+
realTimestamps.push(Number.parseFloat(match[1]));
|
|
1003
|
+
}
|
|
1004
|
+
})
|
|
788
1005
|
.on("end", () => {
|
|
789
1006
|
clearTimeout(timeoutId);
|
|
790
|
-
resolve();
|
|
1007
|
+
resolve(realTimestamps);
|
|
791
1008
|
})
|
|
792
1009
|
.on("error", (err) => {
|
|
793
1010
|
clearTimeout(timeoutId);
|
|
@@ -906,12 +1123,13 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
906
1123
|
* - Subtitle text (if available)
|
|
907
1124
|
*
|
|
908
1125
|
* @param metadata - Extracted video metadata
|
|
909
|
-
* @param
|
|
1126
|
+
* @param keyframeTimestampsSec - The real second each kept keyframe was sampled at
|
|
910
1127
|
* @param subtitleText - Extracted subtitle text (if any)
|
|
911
1128
|
* @param filename - Original filename
|
|
1129
|
+
* @param transcript - Machine transcription of the spoken audio (if requested and available)
|
|
912
1130
|
* @returns Formatted text content for the LLM
|
|
913
1131
|
*/
|
|
914
|
-
buildTextContent(metadata,
|
|
1132
|
+
buildTextContent(metadata, keyframeTimestampsSec, subtitleText, filename, transcript) {
|
|
915
1133
|
const lines = [];
|
|
916
1134
|
lines.push(`[Video File: ${filename}]`);
|
|
917
1135
|
lines.push("");
|
|
@@ -936,15 +1154,41 @@ export class VideoProcessor extends BaseFileProcessor {
|
|
|
936
1154
|
}
|
|
937
1155
|
lines.push(`- File Size: ${(metadata.fileSize / (1024 * 1024)).toFixed(1)} MB`);
|
|
938
1156
|
lines.push("");
|
|
939
|
-
if (
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1157
|
+
if (keyframeTimestampsSec.length > 0) {
|
|
1158
|
+
// The real sample times, not the duration tier's nominal interval. Those
|
|
1159
|
+
// two disagree whenever the caller passed `videoOptions.frames`, because
|
|
1160
|
+
// an explicit budget spreads frames evenly across the clip instead of
|
|
1161
|
+
// following the tier — so a 3s clip asked for 16 frames used to be
|
|
1162
|
+
// described as "every ~1s" while the frames were 0.19s apart. Listing
|
|
1163
|
+
// what was actually sampled cannot drift from what was sent, and it is
|
|
1164
|
+
// also what lets the model answer a question about a specific moment.
|
|
1165
|
+
lines.push(`## Keyframes (${keyframeTimestampsSec.length} extracted)`);
|
|
1166
|
+
lines.push("The images attached below are keyframes from this video, in order. " +
|
|
1167
|
+
"Each was sampled at the timestamp listed here:");
|
|
1168
|
+
for (const [index, timestampSec] of keyframeTimestampsSec.entries()) {
|
|
1169
|
+
lines.push(`- Frame ${index + 1}: ${formatKeyframeTimestamp(timestampSec)}`);
|
|
1170
|
+
}
|
|
943
1171
|
}
|
|
944
1172
|
else {
|
|
945
1173
|
lines.push("## Keyframes");
|
|
946
1174
|
lines.push("No keyframes could be extracted from this video (it may be audio-only or use an unsupported codec).");
|
|
947
1175
|
}
|
|
1176
|
+
if (transcript) {
|
|
1177
|
+
lines.push("");
|
|
1178
|
+
// Labelled as transcribed speech, not as captions: the two can both be
|
|
1179
|
+
// present and disagree, and a model told which is which can say so
|
|
1180
|
+
// rather than averaging them into one confident answer.
|
|
1181
|
+
lines.push("## Spoken Audio (transcribed)");
|
|
1182
|
+
lines.push("The following is a machine transcription of the speech in this video:");
|
|
1183
|
+
const maxTranscriptChars = 20_000;
|
|
1184
|
+
if (transcript.length > maxTranscriptChars) {
|
|
1185
|
+
lines.push(transcript.substring(0, maxTranscriptChars) +
|
|
1186
|
+
`\n... [truncated, ${transcript.length - maxTranscriptChars} more characters]`);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
lines.push(transcript);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
948
1192
|
if (subtitleText) {
|
|
949
1193
|
lines.push("");
|
|
950
1194
|
lines.push("## Subtitles / Captions");
|
|
@@ -158,6 +158,9 @@ export function buildCatalogEntries() {
|
|
|
158
158
|
if (entry.quirks?.responseFormatDowngrade) {
|
|
159
159
|
base.responseFormatDowngrade = entry.quirks.responseFormatDowngrade;
|
|
160
160
|
}
|
|
161
|
+
if (entry.quirks?.replayReasoningContent) {
|
|
162
|
+
base.replayReasoningContent = true;
|
|
163
|
+
}
|
|
161
164
|
return base;
|
|
162
165
|
});
|
|
163
166
|
}
|
|
@@ -280,6 +280,10 @@
|
|
|
280
280
|
"type": "string",
|
|
281
281
|
"enum": ["json-schema-to-json-object"],
|
|
282
282
|
"description": "Vendor rejects response_format: { type: \"json_schema\" } outright but accepts { type: \"json_object\" }. ConfiguredOpenAICompatProvider downgrades the request so generate({ schema }) keeps working."
|
|
283
|
+
},
|
|
284
|
+
"replayReasoningContent": {
|
|
285
|
+
"type": "boolean",
|
|
286
|
+
"description": "Vendor wants each assistant turn's reasoning_content sent back on every later request of the conversation (DeepSeek documents a 400 without it once tools are in play). Off for every other provider, since strict OpenAI-compatible backends reject the unknown field."
|
|
283
287
|
}
|
|
284
288
|
}
|
|
285
289
|
},
|
|
@@ -85,6 +85,7 @@ export declare const providerCatalogJsonSchema: z.ZodObject<{
|
|
|
85
85
|
messageContentFormat: z.ZodOptional<z.ZodLiteral<"string">>;
|
|
86
86
|
registryDefaultIgnoresModelEnvVar: z.ZodOptional<z.ZodBoolean>;
|
|
87
87
|
responseFormatDowngrade: z.ZodOptional<z.ZodLiteral<"json-schema-to-json-object">>;
|
|
88
|
+
replayReasoningContent: z.ZodOptional<z.ZodBoolean>;
|
|
88
89
|
}, z.core.$strict>>;
|
|
89
90
|
timeouts: z.ZodOptional<z.ZodObject<{
|
|
90
91
|
generateMs: z.ZodOptional<z.ZodNumber>;
|
|
@@ -140,6 +140,7 @@ const catalogQuirksSchema = z.strictObject({
|
|
|
140
140
|
messageContentFormat: z.literal("string").optional(),
|
|
141
141
|
registryDefaultIgnoresModelEnvVar: z.boolean().optional(),
|
|
142
142
|
responseFormatDowngrade: z.literal("json-schema-to-json-object").optional(),
|
|
143
|
+
replayReasoningContent: z.boolean().optional(),
|
|
143
144
|
});
|
|
144
145
|
const catalogTimeoutsSchema = z.strictObject({
|
|
145
146
|
generateMs: z.number().optional(),
|
|
@@ -57,5 +57,6 @@ export declare class ConfiguredOpenAICompatProvider extends OpenAIChatCompletion
|
|
|
57
57
|
supportsTools(): boolean;
|
|
58
58
|
protected adjustRequestBody(body: OpenAICompatChatRequest, modelId: string): OpenAICompatChatRequest;
|
|
59
59
|
protected adjustResponseFormat(rf: OpenAICompatResponseFormat | undefined, modelId: string): OpenAICompatResponseFormat | undefined;
|
|
60
|
+
protected replayReasoningContent(): boolean;
|
|
60
61
|
protected formatProviderError(error: unknown): Error;
|
|
61
62
|
}
|
|
@@ -130,6 +130,9 @@ export class ConfiguredOpenAICompatProvider extends OpenAIChatCompletionsProvide
|
|
|
130
130
|
}
|
|
131
131
|
return super.adjustResponseFormat(rf, modelId);
|
|
132
132
|
}
|
|
133
|
+
replayReasoningContent() {
|
|
134
|
+
return this.entry.replayReasoningContent === true;
|
|
135
|
+
}
|
|
133
136
|
formatProviderError(error) {
|
|
134
137
|
// classifyProviderError hard-codes TimeoutError -> NetworkError ahead
|
|
135
138
|
// of any rule table and does not allow a per-provider override. An
|
|
@@ -102,6 +102,14 @@ export declare abstract class OpenAIChatCompletionsProvider extends BaseProvider
|
|
|
102
102
|
* deliberately. LiteLLM overrides this to true.
|
|
103
103
|
*/
|
|
104
104
|
protected useStreamingWireForGenerate(): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* When true, an assistant turn's reasoning goes back to the vendor as
|
|
107
|
+
* `reasoning_content` on every later request of the conversation. DeepSeek
|
|
108
|
+
* documents this as required once tools are in play and answers 400
|
|
109
|
+
* without it. Default false: strict OpenAI-compatible backends reject a
|
|
110
|
+
* field they don't know. Read per request, never during construction.
|
|
111
|
+
*/
|
|
112
|
+
protected replayReasoningContent(): boolean;
|
|
105
113
|
/**
|
|
106
114
|
* Hook to adjust the fully-built wire request body before it is sent, on
|
|
107
115
|
* both the streaming and non-streaming paths. Default identity. Override for
|