@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.
- package/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +357 -355
- package/dist/cli/factories/commandFactory.js +2 -1
- package/dist/cli/proxy-clients/codex.js +112 -42
- package/dist/cli/proxy-clients/grok.d.ts +15 -1
- package/dist/cli/proxy-clients/grok.js +186 -28
- package/dist/cli/proxy-clients/snapshot.d.ts +12 -0
- package/dist/cli/proxy-clients/snapshot.js +37 -4
- package/dist/cli/proxy-clients/tomlSections.d.ts +74 -0
- package/dist/cli/proxy-clients/tomlSections.js +365 -0
- 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/types/file.d.ts +20 -2
- package/dist/types/generate.d.ts +20 -26
- package/dist/types/processor.d.ts +43 -0
- package/dist/types/proxyClient.d.ts +28 -3
- 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 +2 -2
- package/package.json +3 -2
|
@@ -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");
|
package/dist/types/file.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
280
|
-
|
|
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
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
33
|
-
* never print a success message for work that did
|
|
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
|
-
/**
|
|
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
|
*
|