@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +376 -374
  3. package/dist/cli/factories/commandFactory.js +2 -1
  4. package/dist/core/baseProvider.js +1 -0
  5. package/dist/core/modules/MessageBuilder.js +8 -0
  6. package/dist/processors/media/VideoProcessor.d.ts +56 -4
  7. package/dist/processors/media/VideoProcessor.js +263 -19
  8. package/dist/providers/catalog/deepseek.json +2 -1
  9. package/dist/providers/catalog/loader.js +3 -0
  10. package/dist/providers/catalog/provider-catalog.schema.json +4 -0
  11. package/dist/providers/catalog/schema.d.ts +1 -0
  12. package/dist/providers/catalog/schema.js +1 -0
  13. package/dist/providers/configuredOpenAICompat.d.ts +1 -0
  14. package/dist/providers/configuredOpenAICompat.js +3 -0
  15. package/dist/providers/openaiChatCompletionsBase.d.ts +8 -0
  16. package/dist/providers/openaiChatCompletionsBase.js +16 -2
  17. package/dist/providers/openaiChatCompletionsClient.d.ts +1 -1
  18. package/dist/providers/openaiChatCompletionsClient.js +13 -2
  19. package/dist/types/file.d.ts +20 -2
  20. package/dist/types/generate.d.ts +20 -26
  21. package/dist/types/openaiCompatible.d.ts +3 -0
  22. package/dist/types/processor.d.ts +43 -0
  23. package/dist/types/providerCatalog.d.ts +5 -0
  24. package/dist/types/providers.d.ts +3 -0
  25. package/dist/types/stream.d.ts +11 -11
  26. package/dist/utils/fileDetector.d.ts +15 -0
  27. package/dist/utils/fileDetector.js +40 -7
  28. package/dist/utils/mediaDuration.d.ts +19 -0
  29. package/dist/utils/mediaDuration.js +23 -0
  30. package/dist/utils/messageBuilder.js +4 -15
  31. package/dist/utils/multimodalOptionsBuilder.d.ts +2 -0
  32. package/dist/utils/multimodalOptionsBuilder.js +7 -0
  33. package/docs-site/static/search-index.json +6 -6
  34. 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: "Extract and transcribe audio from video",
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 Array of encoded frame buffers (JPEG unless png was requested)
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 exact timestamps,
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 frameCount - Number of keyframes extracted
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: Build textContent for LLM
470
- const textContent = this.buildTextContent(metadata, keyframes.length, subtitleText, this.getFilename(fileInfo));
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 8: Return structured result
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 8: Clean up temp files
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 Array of encoded frame buffers (JPEG unless png was requested)
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
- await this.runFfmpegFrameExtraction(videoPath, framesDir, timestamps, intervalSec);
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
- keyframes.push(resized);
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 exact timestamps,
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 frameCount - Number of keyframes extracted
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, frameCount, subtitleText, filename) {
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 (frameCount > 0) {
940
- const intervalSec = this.getFrameInterval(metadata.duration);
941
- lines.push(`## Keyframes (${frameCount} frames extracted every ~${intervalSec}s)`);
942
- lines.push("The following images are keyframes extracted from the video at regular intervals.");
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");
@@ -74,7 +74,8 @@
74
74
  }
75
75
  ],
76
76
  "quirks": {
77
- "responseFormatDowngrade": "json-schema-to-json-object"
77
+ "responseFormatDowngrade": "json-schema-to-json-object",
78
+ "replayReasoningContent": true
78
79
  },
79
80
  "setup": {
80
81
  "url": "https://platform.deepseek.com/api_keys",
@@ -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