@juspay/neurolink 12.29.2 → 12.30.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.
@@ -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");
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * File detection and processing types for unified file handling
3
3
  */
4
+ import type { ImageWithAltText } from "./multimodal.js";
4
5
  /**
5
6
  * Supported file types for multimodal input
6
7
  */
@@ -276,8 +277,15 @@ export type FileProcessingResult = {
276
277
  type: FileType;
277
278
  content: string | Buffer;
278
279
  mimeType: string;
279
- /** Additional images extracted from the file (e.g., video keyframes, audio cover art) */
280
- images?: Array<Buffer | string>;
280
+ /**
281
+ * Additional images extracted from the file (e.g., video keyframes, audio
282
+ * cover art).
283
+ *
284
+ * An entry may carry alt text, which is how a video keyframe states the
285
+ * timestamp it was sampled at. Bare bytes still work — the field was
286
+ * widened, not changed.
287
+ */
288
+ images?: Array<Buffer | string | ImageWithAltText>;
281
289
  metadata: {
282
290
  confidence: number;
283
291
  size?: number;
@@ -549,6 +557,16 @@ export type VideoProcessorOptions = {
549
557
  quality?: number;
550
558
  /** Frame encoding. Defaults to jpeg. */
551
559
  format?: "jpeg" | "png";
560
+ /**
561
+ * Transcribe the clip's spoken audio (#433). Off by default: it costs an
562
+ * ffmpeg pass plus a Whisper call, and most attached video is silent
563
+ * screen capture.
564
+ *
565
+ * Best-effort — a clip with no audio track, no `OPENAI_API_KEY`, or a
566
+ * failed call still processes, and the reason lands in
567
+ * `ProcessedVideo.transcriptionSkippedReason`.
568
+ */
569
+ transcribeAudio?: boolean;
552
570
  };
553
571
  /**
554
572
  * Office processor options for Word, PowerPoint, and Excel documents
@@ -18,7 +18,7 @@ import type { AvatarOptions, AvatarResult } from "./avatar.js";
18
18
  import type { MusicOptions, MusicResult } from "./music.js";
19
19
  import type { StandardRecord, ValidationSchema, ZodUnknownSchema } from "./aliases.js";
20
20
  import type { NeurolinkCredentials } from "./providers.js";
21
- import type { CSVProcessorOptions, FileWithMetadata, MultimodalAudioEntry, MultimodalVideoEntry, VisionImageOutputFormat } from "./file.js";
21
+ import type { CSVProcessorOptions, VideoProcessorOptions, FileWithMetadata, MultimodalAudioEntry, MultimodalVideoEntry, VisionImageOutputFormat } from "./file.js";
22
22
  import type { WorkflowConfig } from "./workflow.js";
23
23
  import type { Schema, Tool, ToolChoice } from "./tools.js";
24
24
  import type { StepResult, LanguageModel } from "./providers.js";
@@ -180,16 +180,15 @@ export type GenerateOptions = {
180
180
  */
181
181
  outputFormat?: VisionImageOutputFormat;
182
182
  };
183
- videoOptions?: {
184
- /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
185
- frames?: number;
186
- /** Frame encoder quality, clamped to 1-100. Default 80. */
187
- quality?: number;
188
- /** Frame encoding. Default jpeg. */
189
- format?: "jpeg" | "png";
190
- /** Not implemented yet (#433) — warns rather than silently doing nothing. */
191
- transcribeAudio?: boolean;
192
- };
183
+ /**
184
+ * Video processing options — keyframe budget, encoder settings, and
185
+ * whether to transcribe the clip's spoken audio.
186
+ *
187
+ * The canonical shape, rather than a third structurally-identical copy of
188
+ * it: `StreamOptions` and `TextGenerationOptions` declare the same field,
189
+ * and the inline copies had already drifted apart in what they documented.
190
+ */
191
+ videoOptions?: VideoProcessorOptions;
193
192
  /**
194
193
  * Text-to-Speech (TTS) configuration
195
194
  *
@@ -1428,6 +1427,16 @@ export type TextGenerationOptions = {
1428
1427
  expectedOutcome?: string;
1429
1428
  evaluationCriteria?: string[];
1430
1429
  csvOptions?: CSVProcessorOptions;
1430
+ /**
1431
+ * Video processing options (#478, #433).
1432
+ *
1433
+ * Reconstructed option objects have to carry this the same way they carry
1434
+ * `csvOptions` and `pdfOptions`: the message builder hands it to the
1435
+ * detector, which hands it to `VideoProcessor`, and anything that rebuilds
1436
+ * the options along the way and omits it restores the defaults without
1437
+ * saying so.
1438
+ */
1439
+ videoOptions?: VideoProcessorOptions;
1431
1440
  /** PDF processing options (#258). */
1432
1441
  pdfOptions?: {
1433
1442
  /** Password for an encrypted PDF (image-conversion fallback path). */
@@ -1453,21 +1462,6 @@ export type TextGenerationOptions = {
1453
1462
  */
1454
1463
  outputFormat?: VisionImageOutputFormat;
1455
1464
  };
1456
- /**
1457
- * Video processing options (mirrors `GenerateOptions.videoOptions`). Never
1458
- * declared here before, so `buildGenerateTextOptions` had nowhere to
1459
- * forward the caller's setting even once the allowlist itself named it.
1460
- */
1461
- videoOptions?: {
1462
- /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
1463
- frames?: number;
1464
- /** Frame encoder quality, clamped to 1-100. Default 80. */
1465
- quality?: number;
1466
- /** Frame encoding. Default jpeg. */
1467
- format?: "jpeg" | "png";
1468
- /** Not implemented yet (#433) — warns rather than silently doing nothing. */
1469
- transcribeAudio?: boolean;
1470
- };
1471
1465
  enableSummarization?: boolean;
1472
1466
  /**
1473
1467
  * Skip injecting tool schemas into the system prompt.
@@ -711,6 +711,20 @@ export type ProcessedAudio = ProcessedFileBase & {
711
711
  transcriptionSkippedReason?: string;
712
712
  coverArt?: Buffer;
713
713
  };
714
+ /**
715
+ * One extracted keyframe and the moment it came from.
716
+ *
717
+ * Internal to extraction: `ProcessedVideo` splits this back into the parallel
718
+ * `keyframes` / `keyframeTimestampsSec` arrays its existing consumers expect.
719
+ * Keeping the pair together while frames are being read and encoded is what
720
+ * makes a dropped frame impossible to mislabel — the alternative is
721
+ * reconstructing timestamps from an interval after the fact, which is wrong
722
+ * for every frame following a failed encode.
723
+ */
724
+ export type VideoKeyframe = {
725
+ readonly buffer: Buffer;
726
+ readonly timestampSec: number;
727
+ };
714
728
  /**
715
729
  * Processed video result.
716
730
  * Extends ProcessedFileBase with video-specific fields including metadata,
@@ -720,6 +734,19 @@ export type ProcessedAudio = ProcessedFileBase & {
720
734
  export type ProcessedVideo = ProcessedFileBase & {
721
735
  textContent: string;
722
736
  keyframes: Buffer[];
737
+ /**
738
+ * When each entry of `keyframes` was sampled, in seconds from the start of
739
+ * the clip. Same length, same order, ascending.
740
+ *
741
+ * A parallel array rather than a richer `keyframes` element type: that
742
+ * field is read by `fileReferenceRegistry` and by the detector as a plain
743
+ * `Buffer[]`, and reshaping it would break them for a value they do not
744
+ * need. Extraction can also drop an individual frame when its encode
745
+ * fails, so the timestamps are recorded as frames are kept — never
746
+ * reconstructed from an interval, which would silently mislabel every
747
+ * frame after a dropped one.
748
+ */
749
+ keyframeTimestampsSec: number[];
723
750
  metadata: {
724
751
  duration: number;
725
752
  durationFormatted: string;
@@ -735,6 +762,22 @@ export type ProcessedVideo = ProcessedFileBase & {
735
762
  fileSize: number;
736
763
  };
737
764
  subtitleText?: string;
765
+ /**
766
+ * Speech transcribed from the clip's audio track, when
767
+ * `VideoProcessorOptions.transcribeAudio` asked for it and it worked.
768
+ *
769
+ * Distinct from `subtitleText`, which is an embedded subtitle stream the
770
+ * file already carried. A recording can have one, both or neither.
771
+ */
772
+ transcript?: string;
773
+ hasTranscript: boolean;
774
+ /**
775
+ * Why no transcript was produced. Follows `ProcessedAudio`'s precedent:
776
+ * "nobody asked", "there is no audio track", "no API key", "the call
777
+ * failed" and "nobody was speaking" are five different situations that
778
+ * otherwise all present as an absent transcript.
779
+ */
780
+ transcriptionSkippedReason?: string;
738
781
  hasKeyframes: boolean;
739
782
  frameCount: number;
740
783
  };
@@ -29,8 +29,10 @@ export type CliProxyClientConfigurator = {
29
29
  /**
30
30
  * Point the CLI at the proxy. `proxyBaseUrl` is the bare proxy origin
31
31
  * (e.g. "http://127.0.0.1:55669"); the configurator appends whatever path
32
- * suffix its CLI needs. Returns false when nothing was written, so callers
33
- * never print a success message for work that did not happen.
32
+ * suffix its CLI needs. Returns false when the CLI is not left pointing at
33
+ * the proxy, so callers never print a success message for work that did
34
+ * not happen. A configurator whose file already matches may return true
35
+ * without rewriting it.
34
36
  */
35
37
  apply: (proxyBaseUrl: string, options?: CliProxyClientApplyOptions) => Promise<boolean>;
36
38
  /**
@@ -55,7 +57,10 @@ export type CliProxyClientConfigurator = {
55
57
  export type CliProxyClientApplyResult = {
56
58
  id: string;
57
59
  displayName: string;
58
- /** True only when the configurator actually wrote configuration. */
60
+ /**
61
+ * True only when the CLI's configuration points at the proxy: written now,
62
+ * or already written and left as it was.
63
+ */
59
64
  applied: boolean;
60
65
  /**
61
66
  * Set when the write landed but is not yet in effect — see
@@ -110,6 +115,26 @@ export type CliGrokSnapshot = {
110
115
  originalExisted: boolean;
111
116
  writtenBaseUrl: string;
112
117
  };
118
+ /**
119
+ * One header-delimited run of a client's TOML config. `path` is the parsed
120
+ * header key (`["model", "gemini-2.5-pro"]` for `[model."gemini-2.5-pro"]`),
121
+ * or null for the keys before the first header. `lines` keep their own
122
+ * terminators, so text the writer does not own round-trips byte for byte.
123
+ */
124
+ export type CliTomlSection = {
125
+ path: readonly string[] | null;
126
+ arrayTable: boolean;
127
+ lines: readonly string[];
128
+ };
129
+ /**
130
+ * What a line of TOML leaves open for the next one: a multi-line string, or
131
+ * brackets of an array or inline table. A `[` that starts a line is a table
132
+ * header only when neither is open.
133
+ */
134
+ export type CliTomlScanState = {
135
+ multiline: '"""' | "'''" | null;
136
+ depth: number;
137
+ };
113
138
  /**
114
139
  * Snapshot of the user's pre-existing Gemini CLI `~/.gemini/.env`.
115
140
  *