@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.
@@ -16,7 +16,7 @@ import type { AIModelProviderConfig, NeurolinkCredentials } from "./providers.js
16
16
  import type { TTSChunk, TTSOptions, TTSResult } from "./tts.js";
17
17
  import type { STTOptions, STTResult } from "./stt.js";
18
18
  import type { StandardRecord, ValidationSchema } from "./aliases.js";
19
- import type { CSVProcessorOptions, FileWithMetadata, VisionImageOutputFormat } from "./file.js";
19
+ import type { CSVProcessorOptions, FileWithMetadata, VideoProcessorOptions, VisionImageOutputFormat } from "./file.js";
20
20
  import type { WorkflowConfig } from "./workflow.js";
21
21
  import type { LanguageModel, StepResult } from "./providers.js";
22
22
  import type { Tool, ToolChoice } from "./tools.js";
@@ -356,6 +356,16 @@ export type StreamOptions = {
356
356
  };
357
357
  };
358
358
  csvOptions?: CSVProcessorOptions;
359
+ /**
360
+ * Video processing options (#478, #433).
361
+ *
362
+ * Reconstructed option objects have to carry this the same way they carry
363
+ * `csvOptions` and `pdfOptions`: the message builder hands it to the
364
+ * detector, which hands it to `VideoProcessor`, and anything that rebuilds
365
+ * the options along the way and omits it restores the defaults without
366
+ * saying so.
367
+ */
368
+ videoOptions?: VideoProcessorOptions;
359
369
  /** PDF processing options (#258). */
360
370
  pdfOptions?: {
361
371
  /** Password for an encrypted PDF (image-conversion fallback path). */
@@ -386,16 +396,6 @@ export type StreamOptions = {
386
396
  */
387
397
  outputFormat?: VisionImageOutputFormat;
388
398
  };
389
- videoOptions?: {
390
- /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
391
- frames?: number;
392
- /** Frame encoder quality, clamped to 1-100. Default 80. */
393
- quality?: number;
394
- /** Frame encoding. Default jpeg. */
395
- format?: "jpeg" | "png";
396
- /** Not implemented yet (#433) — warns rather than silently doing nothing. */
397
- transcribeAudio?: boolean;
398
- };
399
399
  /**
400
400
  * Text-to-Speech (TTS) configuration for streaming
401
401
  *
@@ -114,6 +114,21 @@ export declare class FileDetector {
114
114
  /**
115
115
  * SDK-8: Format an informative placeholder when a file processor fails.
116
116
  * Instead of bare "[Video file: name]" strings, include size, format, and
117
+ * Describe one keyframe well enough for a model to place it in time.
118
+ *
119
+ * The clip's total length rides along because the eighth frame of a
120
+ * recording means nothing without knowing how long the recording is.
121
+ *
122
+ * Explicit units rather than a clock — see `formatKeyframeTimestamp`. An
123
+ * earlier draft used "0:03" here and the model reported the frame as
124
+ * appearing at "3:00".
125
+ *
126
+ * A timestamp can be missing if extraction and its schedule ever disagree
127
+ * in length. That should not happen, but an unlabelled frame is better
128
+ * than one labelled "NaN".
129
+ */
130
+ private static describeKeyframe;
131
+ /**
117
132
  * the reason for failure so the LLM can acknowledge the attachment.
118
133
  */
119
134
  private static formatInformativePlaceholder;
@@ -37,6 +37,7 @@ import { withTimeout } from "./errorHandling.js";
37
37
  import { normalizeUrlForCache, redactUrlForError, sanitizeErrorCause, } from "./logSanitize.js";
38
38
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
39
39
  import { PDFProcessor } from "./pdfProcessor.js";
40
+ import { formatKeyframeTimestamp } from "./mediaDuration.js";
40
41
  /**
41
42
  * Short-TTL cache of URL → Content-Type (#323). A URL is commonly detected more
42
43
  * than once (repeated multimodal prompts reuse the same asset URL); caching the
@@ -797,6 +798,26 @@ export class FileDetector {
797
798
  /**
798
799
  * SDK-8: Format an informative placeholder when a file processor fails.
799
800
  * Instead of bare "[Video file: name]" strings, include size, format, and
801
+ * Describe one keyframe well enough for a model to place it in time.
802
+ *
803
+ * The clip's total length rides along because the eighth frame of a
804
+ * recording means nothing without knowing how long the recording is.
805
+ *
806
+ * Explicit units rather than a clock — see `formatKeyframeTimestamp`. An
807
+ * earlier draft used "0:03" here and the model reported the frame as
808
+ * appearing at "3:00".
809
+ *
810
+ * A timestamp can be missing if extraction and its schedule ever disagree
811
+ * in length. That should not happen, but an unlabelled frame is better
812
+ * than one labelled "NaN".
813
+ */
814
+ static describeKeyframe(timestampSec, durationFormatted) {
815
+ if (typeof timestampSec !== "number" || !Number.isFinite(timestampSec)) {
816
+ return undefined;
817
+ }
818
+ return `Video keyframe at ${formatKeyframeTimestamp(timestampSec)} into a ${durationFormatted} video`;
819
+ }
820
+ /**
800
821
  * the reason for failure so the LLM can acknowledge the attachment.
801
822
  */
802
823
  static formatInformativePlaceholder(typeName, filename, content, detection, error) {
@@ -1120,25 +1141,37 @@ export class FileDetector {
1120
1141
  // the processor; previously these stopped at the CLI layer.
1121
1142
  videoOptions);
1122
1143
  if (videoResult.success && videoResult.data) {
1144
+ // Bound to a local because the keyframe map below is a closure, and
1145
+ // TypeScript drops the `videoResult.data` narrowing across one.
1146
+ const video = videoResult.data;
1123
1147
  return {
1124
1148
  type: "video",
1125
- content: videoResult.data.textContent ||
1149
+ content: video.textContent ||
1126
1150
  FileDetector.formatInformativePlaceholder("Video", videoFilename, content, detection),
1127
1151
  mimeType: detection.mimeType,
1128
- images: videoResult.data.keyframes && videoResult.data.keyframes.length > 0
1129
- ? videoResult.data.keyframes
1152
+ // Each keyframe carries the moment it was sampled at. Alt text is
1153
+ // the only per-image channel that survives to the model — the
1154
+ // message builder folds it into the prompt as
1155
+ // "[Image N: <alt>]" — so without it the frames arrive as an
1156
+ // unlabelled pile and "what happens at 0:30?" has no answer even
1157
+ // though the frame at 0:30 is right there.
1158
+ images: video.keyframes && video.keyframes.length > 0
1159
+ ? video.keyframes.map((frame, index) => ({
1160
+ data: frame,
1161
+ altText: FileDetector.describeKeyframe(video.keyframeTimestampsSec[index], video.metadata.durationFormatted),
1162
+ }))
1130
1163
  : undefined,
1131
1164
  metadata: {
1132
1165
  ...detection.metadata,
1133
- frameCount: videoResult.data.frameCount,
1134
- hasKeyframes: videoResult.data.hasKeyframes,
1166
+ frameCount: video.frameCount,
1167
+ hasKeyframes: video.hasKeyframes,
1135
1168
  // Carried so native delivery can apply its duration ceiling
1136
1169
  // without opening the container a second time. The processor
1137
1170
  // reports 0 when nothing could probe it; that is "unknown", not a
1138
1171
  // zero-length clip, so it is left off rather than passed through
1139
1172
  // as a number a ceiling check would happily accept.
1140
- ...(videoResult.data.metadata.duration > 0
1141
- ? { durationSec: videoResult.data.metadata.duration }
1173
+ ...(video.metadata.duration > 0
1174
+ ? { durationSec: video.metadata.duration }
1142
1175
  : {}),
1143
1176
  },
1144
1177
  };
@@ -21,3 +21,22 @@
21
21
  * fabricated number would be worse than an obvious zero.
22
22
  */
23
23
  export declare function formatMediaDuration(seconds: number): string;
24
+ /**
25
+ * Format the moment a video keyframe was sampled at — "3.00s",
26
+ * "1234.00s (20m 34s)".
27
+ *
28
+ * Deliberately not the clock form, for the reason in this module's header:
29
+ * the string is read by a language model, and "0:03" is ambiguous. That is
30
+ * not theoretical here — an early draft labelled frames "0:03" and the model
31
+ * reported the green frame as appearing at "3:00".
32
+ *
33
+ * Deliberately not `formatMediaDuration` alone either. That rounds to whole
34
+ * seconds, and an explicit `videoOptions.frames` spreads frames evenly rather
35
+ * than on the duration tier: sixteen frames across a 3.6s clip are 0.225s
36
+ * apart, and every one of them would render as "0s". The precise value is
37
+ * therefore always present, with the readable form added only past a minute,
38
+ * where "1234.00s" stops meaning anything to a reader.
39
+ *
40
+ * Non-finite and negative inputs clamp to zero rather than emitting "NaNs".
41
+ */
42
+ export declare function formatKeyframeTimestamp(seconds: number): string;
@@ -42,3 +42,26 @@ export function formatMediaDuration(seconds) {
42
42
  }
43
43
  return parts.join(" ");
44
44
  }
45
+ /**
46
+ * Format the moment a video keyframe was sampled at — "3.00s",
47
+ * "1234.00s (20m 34s)".
48
+ *
49
+ * Deliberately not the clock form, for the reason in this module's header:
50
+ * the string is read by a language model, and "0:03" is ambiguous. That is
51
+ * not theoretical here — an early draft labelled frames "0:03" and the model
52
+ * reported the green frame as appearing at "3:00".
53
+ *
54
+ * Deliberately not `formatMediaDuration` alone either. That rounds to whole
55
+ * seconds, and an explicit `videoOptions.frames` spreads frames evenly rather
56
+ * than on the duration tier: sixteen frames across a 3.6s clip are 0.225s
57
+ * apart, and every one of them would render as "0s". The precise value is
58
+ * therefore always present, with the readable form added only past a minute,
59
+ * where "1234.00s" stops meaning anything to a reader.
60
+ *
61
+ * Non-finite and negative inputs clamp to zero rather than emitting "NaNs".
62
+ */
63
+ export function formatKeyframeTimestamp(seconds) {
64
+ const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
65
+ const precise = `${safe.toFixed(2)}s`;
66
+ return safe >= 60 ? `${precise} (${formatMediaDuration(safe)})` : precise;
67
+ }
@@ -832,20 +832,6 @@ export function mergeMediaFileAliases(input) {
832
832
  input.audioFiles = undefined;
833
833
  input.videoFiles = undefined;
834
834
  }
835
- /**
836
- * #478: `transcribeAudio` (CLI `--transcribe-audio`) is accepted by the options
837
- * surface but no video-audio transcription exists yet — VideoProcessor extracts
838
- * keyframes and embedded subtitle tracks only, and the transcription step is
839
- * still open as #433. Say so once per request rather than letting the caller
840
- * believe a transcript was produced and silently omitted.
841
- */
842
- function warnIfVideoTranscriptionRequested(videoOptions) {
843
- if (videoOptions?.transcribeAudio) {
844
- logger.warn("[NEUROLINK] Video audio transcription was requested but is not implemented yet " +
845
- "(tracked as #433). Keyframes and any embedded subtitle tracks are still extracted; " +
846
- "spoken audio will not be transcribed.");
847
- }
848
- }
849
835
  /**
850
836
  * Process the unified files array with auto-detection.
851
837
  * Handles lazy file registration, full processing, and preview injection.
@@ -916,6 +902,10 @@ async function detectFileForUnifiedArray(file, options, provider, genericFileMax
916
902
  frames: options.videoOptions.frames,
917
903
  quality: options.videoOptions.quality,
918
904
  format: options.videoOptions.format,
905
+ // #433: `--transcribe-audio` stopped here until now — the flag was
906
+ // accepted, warned about, and never handed to the processor that
907
+ // would have to act on it.
908
+ transcribeAudio: options.videoOptions.transcribeAudio,
919
909
  }
920
910
  : undefined,
921
911
  provider: provider,
@@ -952,7 +942,6 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
952
942
  }
953
943
  const totalFiles = pending.length;
954
944
  const files = pending;
955
- warnIfVideoTranscriptionRequested(options.videoOptions);
956
945
  return withSpan({
957
946
  name: "neurolink.file.process_all",
958
947
  tracer: tracers.file,
@@ -15,6 +15,7 @@ import type { StreamOptions } from "../types/index.js";
15
15
  * - input.audioFiles: Audio files (Buffer | string paths)
16
16
  * - input.videoFiles: Video files (Buffer | string paths)
17
17
  * - csvOptions: CSV parsing options
18
+ * - videoOptions: Video frame/quality/format/transcription options
18
19
  * - systemPrompt: System-level instructions
19
20
  * - conversationMessages: Chat history
20
21
  * - temperature: Model temperature (0-1)
@@ -64,6 +65,7 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
64
65
  imageOptions: {
65
66
  outputFormat?: import("../index.js").VisionImageOutputFormat;
66
67
  } | undefined;
68
+ videoOptions: import("../index.js").VideoProcessorOptions | undefined;
67
69
  systemPrompt: string | undefined;
68
70
  conversationHistory: import("../index.js").ChatMessage[] | undefined;
69
71
  provider: string;
@@ -14,6 +14,7 @@
14
14
  * - input.audioFiles: Audio files (Buffer | string paths)
15
15
  * - input.videoFiles: Video files (Buffer | string paths)
16
16
  * - csvOptions: CSV parsing options
17
+ * - videoOptions: Video frame/quality/format/transcription options
17
18
  * - systemPrompt: System-level instructions
18
19
  * - conversationMessages: Chat history
19
20
  * - temperature: Model temperature (0-1)
@@ -60,6 +61,12 @@ export function buildMultimodalOptions(options, providerName, modelName) {
60
61
  csvOptions: options.csvOptions,
61
62
  pdfOptions: options.pdfOptions,
62
63
  imageOptions: options.imageOptions,
64
+ // Same whitelist hazard as above, one level up: `videoFiles` reaching
65
+ // the processor is not enough on its own — without `videoOptions` too,
66
+ // the frame/quality/format/transcribeAudio knobs a caller set on
67
+ // `StreamOptions` never reach VideoProcessor on the Bedrock branch, and
68
+ // it silently falls back to the duration-tier default.
69
+ videoOptions: options.videoOptions,
63
70
  systemPrompt: options.systemPrompt,
64
71
  conversationHistory: options.conversationMessages,
65
72
  provider: providerName,
@@ -3899,10 +3899,10 @@
3899
3899
  {"objectID":"2c4c2bb10b6fea62d177994e6df1345d05606b65f8122b3c969cf214df3363f1","title":"CLI usage","url":"/docs/features/multimodal#cli-usage","content":"`bash","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"CLI usage","lvl3":""}},
3900
3900
  {"objectID":"915a9e9701a9e6b02890e744550ed66754549288fa044c90bd2dd873e6b48c2a","title":"Auto-detect","url":"/docs/features/multimodal#auto-detect","content":"neurolink generate \"Describe this video\" --file demo.mp4 --provider google-ai","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Auto-detect","lvl3":""}},
3901
3901
  {"objectID":"12ce623ba884b34886674b24daff6ce9e58465c68dc25f4f66c01229c6855513","title":"Explicit flag","url":"/docs/features/multimodal#explicit-flag","content":"neurolink generate \"Summarise this\" --video standup.mp4 --provider vertex","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Explicit flag","lvl3":""}},
3902
- {"objectID":"08774b3c1d8fd456cb7d64ad98281449b9271854310a9a713048ae2112572d5c","title":"Frame-extraction knobs","url":"/docs/features/multimodal#frame-extraction-knobs","content":"neurolink generate \"Describe each scene\" --file demo.mp4 \\\n --provider openai --video-frames 16 --video-quality 90 --video-format jpeg\n--transcribe-audiovideoOptions.transcribeAudiotranscribeAudio`.","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Frame-extraction knobs","lvl3":""}},
3902
+ {"objectID":"08774b3c1d8fd456cb7d64ad98281449b9271854310a9a713048ae2112572d5c","title":"Frame-extraction knobs","url":"/docs/features/multimodal#frame-extraction-knobs","content":"neurolink generate \"Describe each scene\" --file demo.mp4 \\\n --provider openai --video-frames 16 --video-quality 90 --video-format jpeg\n--transcribe-audiovideoOptions.transcribeAudiowhisper-1OPENAIAPIKEYOPENAIBASEURLSpoken Audio (transcribed)OPENAIAPIKEYNo transcript for : --transcribe-audiotranscribeAudio`.","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Frame-extraction knobs","lvl3":""}},
3903
3903
  {"objectID":"d0faa964a225271bc41d6c279e3e925829ac8e8828518387c39ba60d97cd363f","title":"Asking which mechanism applies","url":"/docs/features/multimodal#asking-which-mechanism-applies","content":"The capability table is exported, so a caller can ask before sending — useful\nfor choosing a provider, or for budgeting.","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Asking which mechanism applies","lvl3":""}},
3904
3904
  {"objectID":"10a8f5bf0ea5129e242eb09d5db285a4d91aac091c171050471ebd7eb528cb72","title":"Best practices","url":"/docs/features/multimodal#best-practices","content":"Ask an audio question of a Gemini provider only. Nothing on the frame\n path can hear the clip, so \"what was said\" has no answer there.\nKeep clips under 14 MB when you want native delivery. Re-encoding a\n screen recording at a lower bitrate usually gets a long clip under the\n ceiling without losing anything a model would read.\nRaise rather than for detail over time. Frames cost\n roughly 250 tokens each regardless of quality; more of them is what buys\n coverage of a clip where things change.\nLower for local runtimes. Ollama and llama.cpp hold every\n frame in the same memory as the model; four is the practical default.","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Best practices","lvl3":""}},
3905
- {"objectID":"7bd6ebb59bff22b19f26c8c358eb7012f8a73d4f90eed575ca136f632c6f9bc6","title":"Troubleshooting","url":"/docs/features/multimodal#troubleshooting","content":"| Symptom | Cause | Fix |\n| --------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| Model describes the file but not its content | No keyframes extracted — ffmpeg missing — and the provider takes frames | Install ffmpeg, or use a Gemini provider, which needs none |\n| in the logs | The clip failed the inline gate; the reason is on the same line | Shorten or re-encode it, or accept the frames |\n| Model cannot hear speech on a Gemini provider | The clip exceeded the inline ceiling and fell back to frames | Get the source under 14 MB |\n| produced no transcript | Not implemented yet (#433); the flag is a no-op that only warns | None yet — track #1757 |\n| Only the first seconds of a long clip are described | The frame budget was hit before the end | Raise ; the interval then spreads evenly across the whole clip |","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Troubleshooting","lvl3":""}},
3905
+ {"objectID":"7bd6ebb59bff22b19f26c8c358eb7012f8a73d4f90eed575ca136f632c6f9bc6","title":"Troubleshooting","url":"/docs/features/multimodal#troubleshooting","content":"| Symptom | Cause | Fix |\n| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |\n| Model describes the file but not its content | No keyframes extracted — ffmpeg missing — and the provider takes frames | Install ffmpeg, or use a Gemini provider, which needs none |\n| in the logs | The clip failed the inline gate; the reason is on the same line | Shorten or re-encode it, or accept the frames |\n| Model cannot hear speech on a Gemini provider | The clip exceeded the inline ceiling and fell back to frames | Get the source under 14 MB, or pass |\n| produced no transcript | The warning names the reason: no audio track, unset, or audio over Whisper's 25 MB limit | Set , or split the recording |\n| Only the first seconds of a long clip are described | The frame budget was hit before the end | Raise ; the interval then spreads evenly across the whole clip |","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Troubleshooting","lvl3":""}},
3906
3906
  {"objectID":"79318378a7df7cc816465eb447fa4bd2ba9e6344f17b0a9cf6661377bb03e7e2","title":"Combining Multiple Input Types","url":"/docs/features/multimodal#combining-multiple-input-types","content":"NeuroLink excels at combining different media types in a single request.","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Combining Multiple Input Types","lvl3":""}},
3907
3907
  {"objectID":"412f09080a5801a35250df393e2e0694602d5312e2b3d1b017fe469bf9dc0474","title":"Batch with Multimodal (CLI)","url":"/docs/features/multimodal#batch-with-multimodal-cli","content":"The command supports , , , and . The file(s) are attached identically to every prompt in the batch (a one-line notice is printed to stderr, unconditionally — it is not suppressed by , so it never corrupts output written to stdout):\n\n(auto-detect) is not available in , because it collides with the positional (the prompts-list path). Use the explicit / / / flags instead.","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"Batch with Multimodal (CLI)","lvl3":""}},
3908
3908
  {"objectID":"66d907236327d676dbe6e05193c2793f7c5990dce2ad113cf078eb1ff4c73216","title":"File validation & troubleshooting (CLI)","url":"/docs/features/multimodal#file-validation-troubleshooting-cli","content":"Before any provider call, the CLI validates local file inputs across , , and :\nA path that points at a directory, doesn't exist, or can't be read (e.g. a permissions error) is rejected up front with a clear error and troubleshooting hints — no cryptic / deep in processing. This also applies to the prompts-list positional itself.\nA large file (images > 10 MB, CSV/PDF > 50 MB) prints a non-blocking warning to stderr. Like the batch attachment notice, this is unconditional — visible without and regardless of — and never mixes into stdout, so output stays valid JSON even when large-file warnings fire.\n\nThis runs even under and without API keys configured.","hierarchy":{"lvl0":"Features","lvl1":"Multimodal Capabilities Guide","lvl2":"File validation & troubleshooting (CLI)","lvl3":""}},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.29.2",
3
+ "version": "12.30.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -248,8 +248,9 @@
248
248
  "pre-push": "pnpm run check:deps && pnpm run build && pnpm run test:provider-structure && pnpm run test:model-manifests",
249
249
  "check:all": "pnpm run lint && pnpm run format --check && pnpm run validate && pnpm run validate:commit",
250
250
  "test:file-formats": "pnpm exec tsx test/continuous-test-suite-file-formats.ts",
251
- "test:multimodal": "pnpm run test:file-formats && pnpm run test:multimodal:sdk && pnpm run test:video-native",
251
+ "test:multimodal": "pnpm run test:file-formats && pnpm run test:multimodal:sdk && pnpm run test:video-native && pnpm run test:video-frames",
252
252
  "test:multimodal:sdk": "pnpm exec tsx test/continuous-test-suite-multimodal-sdk.ts",
253
+ "test:video-frames": "pnpm exec tsx test/continuous-test-suite-video-frames.ts",
253
254
  "test:video-native": "pnpm exec tsx test/continuous-test-suite-video-native.ts",
254
255
  "test:mcp-result-cache": "pnpm exec tsx test/continuous-test-suite-mcp-result-cache.ts",
255
256
  "test:mcp-breaker-resolved-errors": "pnpm exec tsx test/continuous-test-suite-mcp-breaker-resolved-errors.ts",