@bedolla/enrivision 0.1.5 → 0.1.7
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/README.md +43 -9
- package/dist/client/EnriProxyClient.d.ts +296 -248
- package/dist/client/EnriProxyClient.d.ts.map +1 -1
- package/dist/client/EnriProxyClient.js +849 -119
- package/dist/client/EnriProxyClient.js.map +1 -1
- package/dist/client/EnriProxyClientContract.d.ts +478 -0
- package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
- package/dist/client/EnriProxyClientContract.js +136 -0
- package/dist/client/EnriProxyClientContract.js.map +1 -0
- package/dist/index.js +23 -12
- package/dist/index.js.map +1 -1
- package/dist/package-info.d.ts +28 -0
- package/dist/package-info.d.ts.map +1 -1
- package/dist/package-info.js +28 -0
- package/dist/package-info.js.map +1 -1
- package/dist/server/EnriVisionServer.d.ts +186 -0
- package/dist/server/EnriVisionServer.d.ts.map +1 -1
- package/dist/server/EnriVisionServer.js +804 -94
- package/dist/server/EnriVisionServer.js.map +1 -1
- package/dist/shared/codepointTruncation.d.ts +61 -0
- package/dist/shared/codepointTruncation.d.ts.map +1 -0
- package/dist/shared/codepointTruncation.js +73 -0
- package/dist/shared/codepointTruncation.js.map +1 -0
- package/dist/shared/mediaUrlFetcher.d.ts +247 -9
- package/dist/shared/mediaUrlFetcher.d.ts.map +1 -1
- package/dist/shared/mediaUrlFetcher.js +712 -53
- package/dist/shared/mediaUrlFetcher.js.map +1 -1
- package/dist/shared/tar.d.ts +82 -2
- package/dist/shared/tar.d.ts.map +1 -1
- package/dist/shared/tar.js +106 -43
- package/dist/shared/tar.js.map +1 -1
- package/dist/shared/validation.d.ts +96 -2
- package/dist/shared/validation.d.ts.map +1 -1
- package/dist/shared/validation.js +169 -10
- package/dist/shared/validation.js.map +1 -1
- package/dist/tools/AnalyzeMediaContract.d.ts +462 -0
- package/dist/tools/AnalyzeMediaContract.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaContract.js +161 -0
- package/dist/tools/AnalyzeMediaContract.js.map +1 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts +35 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.js +214 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.js.map +1 -0
- package/dist/tools/AnalyzeMediaInputResolver.d.ts +250 -0
- package/dist/tools/AnalyzeMediaInputResolver.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaInputResolver.js +430 -0
- package/dist/tools/AnalyzeMediaInputResolver.js.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts +307 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.js +843 -0
- package/dist/tools/AnalyzeMediaParamParser.js.map +1 -0
- package/dist/tools/AnalyzeMediaResumableUploader.d.ts +244 -0
- package/dist/tools/AnalyzeMediaResumableUploader.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaResumableUploader.js +549 -0
- package/dist/tools/AnalyzeMediaResumableUploader.js.map +1 -0
- package/dist/tools/AnalyzeMediaTarPackager.d.ts +42 -0
- package/dist/tools/AnalyzeMediaTarPackager.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaTarPackager.js +245 -0
- package/dist/tools/AnalyzeMediaTarPackager.js.map +1 -0
- package/dist/tools/AnalyzeMediaTool.d.ts +156 -294
- package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
- package/dist/tools/AnalyzeMediaTool.js +611 -457
- package/dist/tools/AnalyzeMediaTool.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANALYZE MEDIA PARAMETER PARSER
|
|
3
|
+
*
|
|
4
|
+
* Validates raw MCP tool arguments for `analyze_media`, including multipass
|
|
5
|
+
* knob ranges, so out-of-range values fail locally in Spanish instead of
|
|
6
|
+
* being silently clamped by EnriProxy.
|
|
7
|
+
*
|
|
8
|
+
* Enforced knob contract (documented in the MCP tool schema, mirrors EnriProxy `VisionAnalysisHandler`):
|
|
9
|
+
* - max_frames (or maxFrames): integer 1-20 (default 20)
|
|
10
|
+
* - analysis_mode (or analysisMode): auto|single|multipass
|
|
11
|
+
* - video.segment_seconds (or segmentSeconds, flat or nested): 5-600 seconds (default 60)
|
|
12
|
+
* - video.max_segments (or maxSegments, flat or nested): integer 1-60
|
|
13
|
+
* - video.max_frames_per_segment (or maxFramesPerSegment, flat or nested): integer 1-20 (default 8)
|
|
14
|
+
* - video.clip_start_seconds (or clipStartSeconds): 0-86400
|
|
15
|
+
* - video.clip_end_seconds (or clipEndSeconds): 0-86400, > start; duration derives as end - start
|
|
16
|
+
* - video.clip_duration_seconds (or clipDurationSeconds): (0, 86400]
|
|
17
|
+
* - audio.segment_seconds (or segmentSeconds, flat or nested): 5-600 seconds (default 60)
|
|
18
|
+
* - audio.max_segments (or maxSegments, flat or nested): integer 1-60
|
|
19
|
+
* - audio.timestamps (or audioTimestamps flat): strict boolean
|
|
20
|
+
* - document.max_pages_total (or documentMaxPages flat): integer 1-200 (default 20)
|
|
21
|
+
* - document.pages_per_batch: integer 1-200
|
|
22
|
+
* - document.max_images_per_batch: integer 0-20 (0 = no render)
|
|
23
|
+
* - document.scanned_text_threshold_chars: integer 0-5000
|
|
24
|
+
* - images.max_images_total: integer 1-500
|
|
25
|
+
* - images.images_per_batch: integer 1-20
|
|
26
|
+
* - images.max_dimension: integer 256-4096
|
|
27
|
+
* - paths[]: at most 100 entries
|
|
28
|
+
*
|
|
29
|
+
* @module tools/AnalyzeMediaParamParser
|
|
30
|
+
*/
|
|
31
|
+
import { isAbsolute } from "node:path";
|
|
32
|
+
import { MediaUrlFetcher } from "../shared/mediaUrlFetcher.js";
|
|
33
|
+
import { assertObject, assertOptionalBoolean, assertOptionalString, buildClipWindowClampedWarning, optionalFraction, optionalInt, optionalNumber, optionalString, } from "../shared/validation.js";
|
|
34
|
+
import { ANALYZE_MEDIA_LIMITS } from "./AnalyzeMediaContract.js";
|
|
35
|
+
/**
|
|
36
|
+
* Validates raw `analyze_media` tool arguments.
|
|
37
|
+
*/
|
|
38
|
+
export class AnalyzeMediaParamParser {
|
|
39
|
+
/**
|
|
40
|
+
* Honesty notes collected while parsing (reset on every `parseParams` call).
|
|
41
|
+
*/
|
|
42
|
+
parseWarnings = [];
|
|
43
|
+
/**
|
|
44
|
+
* Validates raw MCP tool arguments.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* Accepts `snake_case` and `camelCase` spellings (`max_frames` or
|
|
48
|
+
* `maxFrames`) plus EnriCode-style flat knobs (`segmentSeconds`,
|
|
49
|
+
* `maxSegments`, `maxFramesPerSegment`, `audioTimestamps`,
|
|
50
|
+
* `documentMaxPages`, `clipStartSeconds`, `clipEndSeconds`,
|
|
51
|
+
* `clipDurationSeconds`). Flat knobs win over their nested
|
|
52
|
+
* `video`/`audio`/`document` counterparts, mirroring EnriCode.
|
|
53
|
+
*
|
|
54
|
+
* @param raw - Raw tool arguments.
|
|
55
|
+
* @returns Validated parameters.
|
|
56
|
+
* @throws Error with an Spanish-first bilingual message when arguments are missing or out of range.
|
|
57
|
+
*/
|
|
58
|
+
parseParams(raw) {
|
|
59
|
+
this.parseWarnings = [];
|
|
60
|
+
const obj = assertObject(raw, "arguments");
|
|
61
|
+
const record = obj;
|
|
62
|
+
// EnriCode-only attachment selectors have no meaning here: fail with a
|
|
63
|
+
// redirect instead of ignoring them silently (parity trap for models
|
|
64
|
+
// alternating between surfaces).
|
|
65
|
+
if (typeof record["attachmentIndex"] !== "undefined" ||
|
|
66
|
+
typeof record["attachmentId"] !== "undefined") {
|
|
67
|
+
throw new Error("attachmentIndex/attachmentId sólo existen en EnriCode vision.analyze_media; aquí use 'path' o 'paths'. / attachmentIndex/attachmentId only exist in EnriCode vision.analyze_media; use 'path' or 'paths' here.");
|
|
68
|
+
}
|
|
69
|
+
this.throwOnUnknownKeys(record, TOP_LEVEL_KNOWN_KEYS, "argumentos");
|
|
70
|
+
const path = this.parsePath(record["path"]);
|
|
71
|
+
const paths = this.parsePaths(record["paths"]);
|
|
72
|
+
const cursor = this.parseCursor(record["cursor"]);
|
|
73
|
+
const offset = this.parseOffset(record["offset"]);
|
|
74
|
+
const continuationLimit = this.parseContinuationLimit(record["limit"]);
|
|
75
|
+
// Continuation mode: a cursor reads the next window of a previously
|
|
76
|
+
// truncated list without uploading or analyzing anything. Mixing it
|
|
77
|
+
// with file selectors fails fast so models never pay an upload they
|
|
78
|
+
// did not intend (or silently drop the files they meant to analyze).
|
|
79
|
+
if (cursor !== undefined) {
|
|
80
|
+
if (path !== undefined || (paths !== undefined && paths.length > 0)) {
|
|
81
|
+
throw new Error("cursor no se combina con 'path'/'paths': para continuar una lista truncada mande solo cursor (y offset/limit opcionales). / cursor cannot be combined with 'path'/'paths': to continue a truncated list send only cursor (plus optional offset/limit).");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (!path && (!paths || paths.length === 0)) {
|
|
85
|
+
throw new Error("Proporcione 'path' o 'paths'. / Provide 'path' or 'paths'.");
|
|
86
|
+
}
|
|
87
|
+
const context = AnalyzeMediaParamParser.requireBoundedPromptText(assertOptionalString(record["context"], "context"), "context");
|
|
88
|
+
const question = AnalyzeMediaParamParser.requireBoundedPromptText(assertOptionalString(record["question"], "question"), "question");
|
|
89
|
+
const language = this.parseLanguageHint(assertOptionalString(record["language"], "language"), "language");
|
|
90
|
+
const maxFrames = this.parseBoundedInt(firstDefined(record["max_frames"], record["maxFrames"]), "max_frames", 1, 20);
|
|
91
|
+
const transcribe = assertOptionalBoolean(record["transcribe"], "transcribe");
|
|
92
|
+
const transcriptionLanguage = this.parseLanguageHint(assertOptionalString(firstDefined(record["transcription_language"], record["transcriptionLanguage"]), "transcription_language"), "transcription_language");
|
|
93
|
+
const analysisMode = this.parseAnalysisMode(firstDefined(record["analysis_mode"], record["analysisMode"]));
|
|
94
|
+
const model = this.parseModelHint(record["model"]);
|
|
95
|
+
const region = this.parseRegion(record["region"]);
|
|
96
|
+
// `region` zooms a single image: reject it with multi-entry `paths`
|
|
97
|
+
// locally instead of paying server cost or silently ignoring it. A
|
|
98
|
+
// single-entry `paths` is equivalent to `path` (models that normalize
|
|
99
|
+
// everything to `paths` keep working); longer sets still fail.
|
|
100
|
+
if (region && paths && paths.length > 1) {
|
|
101
|
+
throw new Error("region sólo aplica a una imagen individual (path o paths con un solo elemento), no a conjuntos de varias imágenes. / region only applies to a single image (path or single-entry paths), not to multi-image sets.");
|
|
102
|
+
}
|
|
103
|
+
const hasVideoSection = typeof record["video"] !== "undefined";
|
|
104
|
+
const hasAudioSection = typeof record["audio"] !== "undefined";
|
|
105
|
+
// Shared flats feed the explicit section when exactly one exists (the
|
|
106
|
+
// other section must not shadow them); with zero explicit sections they
|
|
107
|
+
// fan out to both and the server applies the media-matching one; with
|
|
108
|
+
// both explicit they fail below as ambiguous.
|
|
109
|
+
const video = this.parseVideo(record["video"], record, !hasAudioSection || hasVideoSection);
|
|
110
|
+
const document = this.parseDocument(record["document"], record);
|
|
111
|
+
const audio = this.parseAudio(record["audio"], record, !hasVideoSection || hasAudioSection);
|
|
112
|
+
this.rejectConflictingNestedSegmentKnobs(record);
|
|
113
|
+
return {
|
|
114
|
+
path,
|
|
115
|
+
paths,
|
|
116
|
+
cursor,
|
|
117
|
+
offset,
|
|
118
|
+
continuationLimit,
|
|
119
|
+
context,
|
|
120
|
+
question,
|
|
121
|
+
language,
|
|
122
|
+
maxFrames,
|
|
123
|
+
transcribe,
|
|
124
|
+
transcriptionLanguage,
|
|
125
|
+
analysisMode,
|
|
126
|
+
model,
|
|
127
|
+
region,
|
|
128
|
+
video,
|
|
129
|
+
document,
|
|
130
|
+
audio,
|
|
131
|
+
images: this.parseImages(record["images"]),
|
|
132
|
+
...(this.parseWarnings.length > 0 ? { warnings: Object.freeze([...this.parseWarnings]) } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Rejects differing nested video/audio segment knobs without a flat winner.
|
|
137
|
+
*
|
|
138
|
+
* @remarks
|
|
139
|
+
* Flat-over-nested precedence mirrors EnriCode: a flat `segmentSeconds` or
|
|
140
|
+
* `maxSegments` wins over both nested sections (fan-out to both is
|
|
141
|
+
* intentional; the server applies the media-matching one). Without a flat,
|
|
142
|
+
* differing nested `video` vs `audio` values for the same knob fail in
|
|
143
|
+
* Spanish and point at the flat winner, mirroring
|
|
144
|
+
* `VisionAnalyzeMediaToolInputParser` video-beats-audio conflict guards.
|
|
145
|
+
*
|
|
146
|
+
* @param flat - Top-level arguments carrying flat knob aliases.
|
|
147
|
+
* @throws Error with an Spanish-first bilingual message when nested video/audio values differ without a flat.
|
|
148
|
+
*/
|
|
149
|
+
rejectConflictingNestedSegmentKnobs(flat) {
|
|
150
|
+
const flatSegmentSeconds = firstDefined(flat["segmentSeconds"], flat["segment_seconds"]);
|
|
151
|
+
const flatMaxSegments = firstDefined(flat["maxSegments"], flat["max_segments"]);
|
|
152
|
+
const videoRaw = flat["video"];
|
|
153
|
+
const audioRaw = flat["audio"];
|
|
154
|
+
const videoRecord = videoRaw !== undefined && typeof videoRaw === "object" && videoRaw !== null && !Array.isArray(videoRaw)
|
|
155
|
+
? videoRaw
|
|
156
|
+
: {};
|
|
157
|
+
const audioRecord = audioRaw !== undefined && typeof audioRaw === "object" && audioRaw !== null && !Array.isArray(audioRaw)
|
|
158
|
+
? audioRaw
|
|
159
|
+
: {};
|
|
160
|
+
const nestedVideoSegment = firstDefined(videoRecord["segment_seconds"], videoRecord["segmentSeconds"]);
|
|
161
|
+
const nestedAudioSegment = firstDefined(audioRecord["segment_seconds"], audioRecord["segmentSeconds"]);
|
|
162
|
+
if (typeof flatSegmentSeconds === "undefined"
|
|
163
|
+
&& typeof nestedVideoSegment !== "undefined"
|
|
164
|
+
&& typeof nestedAudioSegment !== "undefined"
|
|
165
|
+
&& !numbersEqual(nestedVideoSegment, nestedAudioSegment)) {
|
|
166
|
+
throw new Error("video.segment_seconds y audio.segment_seconds difieren sin un plano segmentSeconds que gane; use el plano segmentSeconds o solo uno de los dos objetos. / video.segment_seconds and audio.segment_seconds differ without a flat segmentSeconds winner; use the flat segmentSeconds or only one of the two objects.");
|
|
167
|
+
}
|
|
168
|
+
const nestedVideoMax = firstDefined(videoRecord["max_segments"], videoRecord["maxSegments"]);
|
|
169
|
+
const nestedAudioMax = firstDefined(audioRecord["max_segments"], audioRecord["maxSegments"]);
|
|
170
|
+
if (typeof flatMaxSegments === "undefined"
|
|
171
|
+
&& typeof nestedVideoMax !== "undefined"
|
|
172
|
+
&& typeof nestedAudioMax !== "undefined"
|
|
173
|
+
&& !numbersEqual(nestedVideoMax, nestedAudioMax)) {
|
|
174
|
+
throw new Error("video.max_segments y audio.max_segments difieren sin un plano maxSegments que gane; use el plano maxSegments o solo uno de los dos objetos. / video.max_segments and audio.max_segments differ without a flat maxSegments winner; use the flat maxSegments or only one of the two objects.");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Parses the `analysis_mode` selector accepting both spellings.
|
|
179
|
+
*
|
|
180
|
+
* @param raw - Raw selector value.
|
|
181
|
+
* @returns Validated selector or undefined when absent.
|
|
182
|
+
* @throws Error with an Spanish-first bilingual message when the value is not auto|single|multipass.
|
|
183
|
+
*/
|
|
184
|
+
parseAnalysisMode(raw) {
|
|
185
|
+
const analysisModeRaw = optionalString(raw);
|
|
186
|
+
if (analysisModeRaw === undefined) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
if (analysisModeRaw === "auto" ||
|
|
190
|
+
analysisModeRaw === "single" ||
|
|
191
|
+
analysisModeRaw === "multipass") {
|
|
192
|
+
return analysisModeRaw;
|
|
193
|
+
}
|
|
194
|
+
throw new Error("analysis_mode debe ser uno de: auto|single|multipass. / analysis_mode must be one of: auto|single|multipass.");
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Parses the single `path` argument.
|
|
198
|
+
*
|
|
199
|
+
* @param raw - Raw `path` argument.
|
|
200
|
+
* @returns Trimmed path, or undefined when absent.
|
|
201
|
+
* @throws Error with an Spanish-first bilingual message when the path is neither absolute nor http(s).
|
|
202
|
+
*/
|
|
203
|
+
parsePath(raw) {
|
|
204
|
+
if (typeof raw === "undefined") {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
if (typeof raw !== "string") {
|
|
208
|
+
throw new Error("path debe ser una ruta de archivo absoluta o una URL http(s). / path must be an absolute file path or an http(s) URL.");
|
|
209
|
+
}
|
|
210
|
+
const path = raw.trim() ? raw.trim() : undefined;
|
|
211
|
+
if (path && !isAbsolute(path) && !MediaUrlFetcher.isHttpUrl(path)) {
|
|
212
|
+
throw new Error("path debe ser una ruta de archivo absoluta o una URL http(s). / path must be an absolute file path or an http(s) URL.");
|
|
213
|
+
}
|
|
214
|
+
if (path && MediaUrlFetcher.isHttpUrl(path) && Array.from(path).length > MAX_SOURCE_URL_CHARS) {
|
|
215
|
+
throw new Error(`La URL de path excede el límite de ingesta del servidor de ${String(MAX_SOURCE_URL_CHARS)} caracteres (EnriProxy VISION_MAX_SOURCE_URL_CHARS); use una URL más corta. / path URL exceeds the ${String(MAX_SOURCE_URL_CHARS)}-char server ingest limit (EnriProxy VISION_MAX_SOURCE_URL_CHARS); use a shorter URL.`);
|
|
216
|
+
}
|
|
217
|
+
return path;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Parses the continuation cursor for truncated-list reads.
|
|
221
|
+
*
|
|
222
|
+
* @param raw - Raw `cursor` argument.
|
|
223
|
+
* @returns Validated cursor, or undefined when absent.
|
|
224
|
+
* @throws Error with a Spanish-first bilingual message when present but malformed.
|
|
225
|
+
*/
|
|
226
|
+
parseCursor(raw) {
|
|
227
|
+
if (typeof raw === "undefined") {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(raw)) {
|
|
231
|
+
throw new Error("cursor debe ser el cursor opaco devuelto en una respuesta truncada (segment_summaries_cursor o transcription_segments_cursor). / cursor must be the opaque cursor from a truncated response (segment_summaries_cursor or transcription_segments_cursor).");
|
|
232
|
+
}
|
|
233
|
+
return raw;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Parses the continuation start index.
|
|
237
|
+
*
|
|
238
|
+
* @param raw - Raw `offset` argument.
|
|
239
|
+
* @returns Validated offset, or undefined when absent.
|
|
240
|
+
* @throws Error with a Spanish-first bilingual message when present but not an integer >= 0.
|
|
241
|
+
*/
|
|
242
|
+
parseOffset(raw) {
|
|
243
|
+
if (typeof raw === "undefined") {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
247
|
+
throw new Error("offset debe ser un entero mayor o igual que 0 (por defecto, el next_offset de la respuesta). / offset must be an integer greater than or equal to 0 (defaults to the response next_offset).");
|
|
248
|
+
}
|
|
249
|
+
return raw;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Parses the continuation window length.
|
|
253
|
+
*
|
|
254
|
+
* @param raw - Raw `limit` argument.
|
|
255
|
+
* @returns Validated limit, or undefined when absent.
|
|
256
|
+
* @throws Error with a Spanish-first bilingual message when present but not an integer in [1, 100].
|
|
257
|
+
*/
|
|
258
|
+
parseContinuationLimit(raw) {
|
|
259
|
+
if (typeof raw === "undefined") {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1 || raw > 100) {
|
|
263
|
+
throw new Error("limit debe ser un entero entre 1 y 100 (por defecto, el tamaño de ventana del servidor). / limit must be an integer between 1 and 100 (defaults to the server window size).");
|
|
264
|
+
}
|
|
265
|
+
return raw;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Parses the multi-image `paths` argument.
|
|
269
|
+
*
|
|
270
|
+
* @remarks
|
|
271
|
+
* Blank entries are discarded (documented in the tool schema); when at
|
|
272
|
+
* least one valid entry remains, `paths` wins over `path` at execution.
|
|
273
|
+
*
|
|
274
|
+
* @param raw - Raw `paths` argument.
|
|
275
|
+
* @returns Validated paths, or undefined when absent/empty.
|
|
276
|
+
* @throws Error with an Spanish-first bilingual message when `paths` is not an array of absolute paths or http(s) URLs.
|
|
277
|
+
*/
|
|
278
|
+
parsePaths(raw) {
|
|
279
|
+
if (typeof raw === "undefined") {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
if (!Array.isArray(raw)) {
|
|
283
|
+
throw new Error("paths debe ser un arreglo de rutas de archivo absolutas o URLs http(s). / paths must be an array of absolute file paths or http(s) URLs.");
|
|
284
|
+
}
|
|
285
|
+
const out = [];
|
|
286
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
287
|
+
const item = raw[index];
|
|
288
|
+
if (typeof item === "string" && !item.trim()) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (typeof item !== "string") {
|
|
292
|
+
throw new Error(`paths[${String(index)}] debe ser una ruta de archivo absoluta o una URL http(s). / paths[${String(index)}] must be an absolute file path or an http(s) URL.`);
|
|
293
|
+
}
|
|
294
|
+
const candidate = item.trim();
|
|
295
|
+
if (!isAbsolute(candidate) && !MediaUrlFetcher.isHttpUrl(candidate)) {
|
|
296
|
+
throw new Error("paths debe contener sólo rutas de archivo absolutas o URLs http(s). / paths must contain only absolute file paths or http(s) URLs.");
|
|
297
|
+
}
|
|
298
|
+
if (MediaUrlFetcher.isHttpUrl(candidate) && Array.from(candidate).length > MAX_SOURCE_URL_CHARS) {
|
|
299
|
+
throw new Error(`La URL de paths[${String(index)}] excede el límite de ingesta del servidor de ${String(MAX_SOURCE_URL_CHARS)} caracteres (EnriProxy VISION_MAX_SOURCE_URL_CHARS); use una URL más corta. / paths[${String(index)}] URL exceeds the ${String(MAX_SOURCE_URL_CHARS)}-char server ingest limit (EnriProxy VISION_MAX_SOURCE_URL_CHARS); use a shorter URL.`);
|
|
300
|
+
}
|
|
301
|
+
out.push(candidate);
|
|
302
|
+
}
|
|
303
|
+
if (out.length > ANALYZE_MEDIA_LIMITS.maxPathsCount) {
|
|
304
|
+
throw new Error(`paths acepta como máximo ${String(ANALYZE_MEDIA_LIMITS.maxPathsCount)} archivos por llamada; divida el conjunto en varias llamadas. / paths accepts at most ${String(ANALYZE_MEDIA_LIMITS.maxPathsCount)} files per call; split the set into several calls.`);
|
|
305
|
+
}
|
|
306
|
+
return out.length > 0 ? out : undefined;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Parses the optional `video` tuning object with range validation.
|
|
310
|
+
*
|
|
311
|
+
* @remarks
|
|
312
|
+
* Accepts `snake_case` and `camelCase` spellings inside `video`, plus
|
|
313
|
+
* flat knobs (`clipStartSeconds`, `clipEndSeconds`, `clipDurationSeconds`,
|
|
314
|
+
* `segmentSeconds`, `maxSegments`, `maxFramesPerSegment`) with
|
|
315
|
+
* flat-over-nested precedence. When `clip_end_seconds` is present the
|
|
316
|
+
* duration derives as `fin = inicio + duración` (`duration = end - start`)
|
|
317
|
+
* and `end <= start` fails (a zero window would be dropped downstream,
|
|
318
|
+
* silently widening the analysis); otherwise an explicit `clip_duration_seconds`
|
|
319
|
+
* is used. Every clip bound lives in `0-86400` segundos (24 h). When any
|
|
320
|
+
* clip knob is present but `clip_start_seconds` is absent, the start is
|
|
321
|
+
* synthesized as `0` so the server never defaults the offset (mirrors
|
|
322
|
+
* EnriCode `VisionAnalyzeMediaRequestRecords`), and
|
|
323
|
+
* `start + duration > 86400` clamps the duration to the 24 h range with
|
|
324
|
+
* a Spanish warning in `warnings` (mirrors the proxy timeline trim);
|
|
325
|
+
* only a start already at the limit fails. A lone `clip_start_seconds`
|
|
326
|
+
* at the 86400 cap with no duration forwards untouched (server decides),
|
|
327
|
+
* mirroring EnriCode `VisionAnalyzeMediaClipWindowReader`, which likewise
|
|
328
|
+
* returns a start-only window with no warning in that case.
|
|
329
|
+
*
|
|
330
|
+
* @param raw - Raw `video` argument.
|
|
331
|
+
* @param flat - Top-level arguments carrying flat knob aliases.
|
|
332
|
+
* @param applySharedFlats - Whether shared `segmentSeconds`/`maxSegments` flats feed this section.
|
|
333
|
+
* @returns Validated video tuning, or undefined when absent.
|
|
334
|
+
* @throws Error with an Spanish-first bilingual message when any knob is present but invalid or out of range.
|
|
335
|
+
*/
|
|
336
|
+
parseVideo(raw, flat, applySharedFlats) {
|
|
337
|
+
const nested = typeof raw === "undefined" ? {} : assertObject(raw, "video");
|
|
338
|
+
this.throwOnUnknownKeys(nested, VIDEO_KNOWN_KEYS, "video");
|
|
339
|
+
const clipStartSeconds = this.parseClipBound(firstDefined(flat["clipStartSeconds"], flat["clip_start_seconds"], nested["clip_start_seconds"], nested["clipStartSeconds"]), "video.clip_start_seconds", 0, ANALYZE_MEDIA_LIMITS.maxClipSeconds);
|
|
340
|
+
const clipEndSeconds = this.parseClipBound(firstDefined(flat["clipEndSeconds"], flat["clip_end_seconds"], nested["clip_end_seconds"], nested["clipEndSeconds"]), "video.clip_end_seconds", 0, ANALYZE_MEDIA_LIMITS.maxClipSeconds);
|
|
341
|
+
let clipDurationSeconds = this.parseClipDuration(firstDefined(flat["clipDurationSeconds"], flat["clip_duration_seconds"], nested["clip_duration_seconds"], nested["clipDurationSeconds"]));
|
|
342
|
+
if (clipEndSeconds !== undefined) {
|
|
343
|
+
const start = clipStartSeconds ?? 0;
|
|
344
|
+
if (clipEndSeconds <= start) {
|
|
345
|
+
throw new Error("video.clip_end_seconds debe ser mayor que video.clip_start_seconds (fin = inicio + duración). / video.clip_end_seconds must be greater than video.clip_start_seconds (end = start + duration).");
|
|
346
|
+
}
|
|
347
|
+
clipDurationSeconds = clipEndSeconds - start;
|
|
348
|
+
}
|
|
349
|
+
// A clip window anchored only by end or duration still starts at 0:
|
|
350
|
+
// synthesize it so the server never defaults the offset. Mirrors
|
|
351
|
+
// EnriCode VisionAnalyzeMediaRequestRecords (clip_start_seconds travels
|
|
352
|
+
// even when 0 while a window exists).
|
|
353
|
+
const hasClipWindow = clipStartSeconds !== undefined
|
|
354
|
+
|| clipEndSeconds !== undefined
|
|
355
|
+
|| clipDurationSeconds !== undefined;
|
|
356
|
+
const effectiveClipStartSeconds = hasClipWindow
|
|
357
|
+
? (clipStartSeconds ?? 0)
|
|
358
|
+
: undefined;
|
|
359
|
+
// Parity clamp (mirrors EnriCode `VisionAnalyzeMediaClipWindowReader` and
|
|
360
|
+
// the proxy timeline trim): an overflowing window is clamped to the 24 h
|
|
361
|
+
// range with a Spanish honesty warning instead of failing, so
|
|
362
|
+
// `start=86300,duration=200` analyzes 100 s loudly on every surface.
|
|
363
|
+
if (effectiveClipStartSeconds !== undefined
|
|
364
|
+
&& clipDurationSeconds !== undefined
|
|
365
|
+
&& effectiveClipStartSeconds + clipDurationSeconds > ANALYZE_MEDIA_LIMITS.maxClipSeconds) {
|
|
366
|
+
if (effectiveClipStartSeconds >= ANALYZE_MEDIA_LIMITS.maxClipSeconds) {
|
|
367
|
+
throw new Error(`video.clip_start_seconds (${String(effectiveClipStartSeconds)}) ya llegó al límite de ${String(ANALYZE_MEDIA_LIMITS.maxClipSeconds)} segundos (24 h): baje el inicio para dejar una ventana analizable. / video.clip_start_seconds (${String(effectiveClipStartSeconds)}) already reached the ${String(ANALYZE_MEDIA_LIMITS.maxClipSeconds)} s limit (24 h): lower the start to leave an analyzable window.`);
|
|
368
|
+
}
|
|
369
|
+
const requestedDuration = clipDurationSeconds;
|
|
370
|
+
clipDurationSeconds = ANALYZE_MEDIA_LIMITS.maxClipSeconds - effectiveClipStartSeconds;
|
|
371
|
+
this.parseWarnings.push(buildClipWindowClampedWarning(effectiveClipStartSeconds, requestedDuration, clipDurationSeconds, ANALYZE_MEDIA_LIMITS.maxClipSeconds));
|
|
372
|
+
}
|
|
373
|
+
const parsed = {
|
|
374
|
+
clipStartSeconds: effectiveClipStartSeconds,
|
|
375
|
+
clipDurationSeconds,
|
|
376
|
+
segmentSeconds: this.parseBoundedNumber(applySharedFlats
|
|
377
|
+
? firstDefined(flat["segmentSeconds"], flat["segment_seconds"], nested["segment_seconds"], nested["segmentSeconds"])
|
|
378
|
+
: firstDefined(nested["segment_seconds"], nested["segmentSeconds"]), "video.segment_seconds", 5, 600),
|
|
379
|
+
maxSegments: this.parseBoundedInt(applySharedFlats
|
|
380
|
+
? firstDefined(flat["maxSegments"], flat["max_segments"], nested["max_segments"], nested["maxSegments"])
|
|
381
|
+
: firstDefined(nested["max_segments"], nested["maxSegments"]), "video.max_segments", 1, 60),
|
|
382
|
+
maxFramesPerSegment: this.parseBoundedInt(firstDefined(flat["maxFramesPerSegment"], flat["max_frames_per_segment"], nested["max_frames_per_segment"], nested["maxFramesPerSegment"]), "video.max_frames_per_segment", 1, 20),
|
|
383
|
+
};
|
|
384
|
+
return hasAnyValue(parsed) ? parsed : undefined;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Parses the optional `document` tuning object with range validation.
|
|
388
|
+
*
|
|
389
|
+
* @remarks
|
|
390
|
+
* Accepts `snake_case` and `camelCase` spellings, plus the flat
|
|
391
|
+
* `documentMaxPages` knob with flat-over-nested precedence.
|
|
392
|
+
*
|
|
393
|
+
* @param raw - Raw `document` argument.
|
|
394
|
+
* @param flat - Top-level arguments carrying flat knob aliases.
|
|
395
|
+
* @returns Validated document tuning, or undefined when absent.
|
|
396
|
+
* @throws Error with an Spanish-first bilingual message when any knob is present but invalid or out of range.
|
|
397
|
+
*/
|
|
398
|
+
parseDocument(raw, flat) {
|
|
399
|
+
const nested = typeof raw === "undefined" ? {} : assertObject(raw, "document");
|
|
400
|
+
this.throwOnUnknownKeys(nested, DOCUMENT_KNOWN_KEYS, "document");
|
|
401
|
+
const parsed = {
|
|
402
|
+
maxPagesTotal: this.parseBoundedInt(firstDefined(flat["documentMaxPages"], flat["document_max_pages"], nested["max_pages_total"], nested["maxPagesTotal"], nested["max_pages"], nested["maxPages"], nested["documentMaxPages"], nested["document_max_pages"]), "document.max_pages_total", 1, 200),
|
|
403
|
+
pagesPerBatch: this.parseBoundedInt(firstDefined(nested["pages_per_batch"], nested["pagesPerBatch"]), "document.pages_per_batch", 1, 200),
|
|
404
|
+
maxImagesPerBatch: this.parseBoundedInt(firstDefined(nested["max_images_per_batch"], nested["maxImagesPerBatch"]), "document.max_images_per_batch", 0, 20),
|
|
405
|
+
scannedTextThresholdChars: this.parseBoundedInt(firstDefined(nested["scanned_text_threshold_chars"], nested["scannedTextThresholdChars"]), "document.scanned_text_threshold_chars", 0, 5000),
|
|
406
|
+
};
|
|
407
|
+
// A batch larger than the total is a certain caller typo: fail locally
|
|
408
|
+
// in Spanish instead of paying multipass map calls the server truncates.
|
|
409
|
+
// Mirrors EnriCode VisionAnalyzeMediaRequestRecords.
|
|
410
|
+
if (parsed.pagesPerBatch !== undefined
|
|
411
|
+
&& parsed.maxPagesTotal !== undefined
|
|
412
|
+
&& parsed.pagesPerBatch > parsed.maxPagesTotal) {
|
|
413
|
+
throw new Error("document.pages_per_batch no puede ser mayor que document.max_pages_total (el lote no puede exceder el total). / document.pages_per_batch cannot be greater than document.max_pages_total (a batch cannot exceed the total).");
|
|
414
|
+
}
|
|
415
|
+
return hasAnyValue(parsed) ? parsed : undefined;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Parses the optional `audio` tuning object with range validation.
|
|
419
|
+
*
|
|
420
|
+
* @remarks
|
|
421
|
+
* Accepts `snake_case` and `camelCase` spellings, plus the flat
|
|
422
|
+
* `audioTimestamps`, `segmentSeconds`, and `maxSegments` knobs with
|
|
423
|
+
* flat-over-nested precedence. Boolean knobs are strict: `"yes"` fails
|
|
424
|
+
* instead of being dropped silently.
|
|
425
|
+
*
|
|
426
|
+
* @param raw - Raw `audio` argument.
|
|
427
|
+
* @param flat - Top-level arguments carrying flat knob aliases.
|
|
428
|
+
* @param applySharedFlats - Whether shared `segmentSeconds`/`maxSegments` flats feed this section.
|
|
429
|
+
* @returns Validated audio tuning, or undefined when absent.
|
|
430
|
+
* @throws Error with an Spanish-first bilingual message when any knob is present but invalid or out of range.
|
|
431
|
+
*/
|
|
432
|
+
parseAudio(raw, flat, applySharedFlats) {
|
|
433
|
+
const nested = typeof raw === "undefined" ? {} : assertObject(raw, "audio");
|
|
434
|
+
this.throwOnUnknownKeys(nested, AUDIO_KNOWN_KEYS, "audio");
|
|
435
|
+
const parsed = {
|
|
436
|
+
timestamps: assertOptionalBoolean(firstDefined(flat["audioTimestamps"], flat["audio_timestamps"], nested["timestamps"], nested["audioTimestamps"], nested["audio_timestamps"]), "audio.timestamps"),
|
|
437
|
+
segmentSeconds: this.parseBoundedNumber(applySharedFlats
|
|
438
|
+
? firstDefined(flat["segmentSeconds"], flat["segment_seconds"], nested["segment_seconds"], nested["segmentSeconds"])
|
|
439
|
+
: firstDefined(nested["segment_seconds"], nested["segmentSeconds"]), "audio.segment_seconds", 5, 600),
|
|
440
|
+
maxSegments: this.parseBoundedInt(applySharedFlats
|
|
441
|
+
? firstDefined(flat["maxSegments"], flat["max_segments"], nested["max_segments"], nested["maxSegments"])
|
|
442
|
+
: firstDefined(nested["max_segments"], nested["maxSegments"]), "audio.max_segments", 1, 60),
|
|
443
|
+
};
|
|
444
|
+
return hasAnyValue(parsed) ? parsed : undefined;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Parses the optional `images` tuning object with sanity validation.
|
|
448
|
+
*
|
|
449
|
+
* @param raw - Raw `images` argument.
|
|
450
|
+
* @returns Validated images tuning, or undefined when absent.
|
|
451
|
+
* @throws Error with an Spanish-first bilingual message when any knob is present but invalid.
|
|
452
|
+
*/
|
|
453
|
+
parseImages(raw) {
|
|
454
|
+
if (typeof raw === "undefined") {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
const value = assertObject(raw, "images");
|
|
458
|
+
const record = value;
|
|
459
|
+
this.throwOnUnknownKeys(record, IMAGES_KNOWN_KEYS, "images");
|
|
460
|
+
const parsed = {
|
|
461
|
+
maxImagesTotal: this.parseBoundedInt(firstDefined(record["max_images_total"], record["maxImagesTotal"]), "images.max_images_total", 1, 500),
|
|
462
|
+
imagesPerBatch: this.parseBoundedInt(firstDefined(record["images_per_batch"], record["imagesPerBatch"]), "images.images_per_batch", 1, 20),
|
|
463
|
+
maxDimension: this.parseBoundedInt(firstDefined(record["max_dimension"], record["maxDimension"]), "images.max_dimension", 256, 4096),
|
|
464
|
+
};
|
|
465
|
+
// Same batch-over-total guard as documents: fail locally in Spanish.
|
|
466
|
+
// Mirrors EnriCode VisionAnalyzeMediaRequestRecords.
|
|
467
|
+
if (parsed.imagesPerBatch !== undefined
|
|
468
|
+
&& parsed.maxImagesTotal !== undefined
|
|
469
|
+
&& parsed.imagesPerBatch > parsed.maxImagesTotal) {
|
|
470
|
+
throw new Error("images.images_per_batch no puede ser mayor que images.max_images_total (el lote no puede exceder el total). / images.images_per_batch cannot be greater than images.max_images_total (a batch cannot exceed the total).");
|
|
471
|
+
}
|
|
472
|
+
return hasAnyValue(parsed) ? parsed : undefined;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Rejects unknown keys inside one nested tuning object with Spanish coaching.
|
|
476
|
+
*
|
|
477
|
+
* @remarks
|
|
478
|
+
* Typos (`max_pages_totall`, `segement_seconds`) must fail locally instead
|
|
479
|
+
* of being ignored silently and analyzing the whole file at full cost.
|
|
480
|
+
*
|
|
481
|
+
* @param actual - Raw nested tuning object.
|
|
482
|
+
* @param known - Accepted key spellings for the section.
|
|
483
|
+
* @param section - Section name for error messages.
|
|
484
|
+
* @throws Error with an Spanish-first bilingual message when the section carries unknown keys.
|
|
485
|
+
*/
|
|
486
|
+
throwOnUnknownKeys(actual, known, section) {
|
|
487
|
+
const unknown = Object.keys(actual).filter((key) => !known.has(key));
|
|
488
|
+
if (unknown.length > 0) {
|
|
489
|
+
throw new Error(`${section} trae claves desconocidas (${unknown.join(", ")}): se rechazan. Claves válidas: ${[...known].join(", ")}. / ${section} has unknown keys (${unknown.join(", ")}): they are rejected. Valid keys: ${[...known].join(", ")}.`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Validates one language hint against the EnriCode pattern.
|
|
494
|
+
*
|
|
495
|
+
* @param raw - Raw language value.
|
|
496
|
+
* @param fieldName - Field name for error messages.
|
|
497
|
+
* @returns Validated hint, or undefined when absent.
|
|
498
|
+
* @throws Error with an Spanish-first bilingual message when the hint has an invalid shape.
|
|
499
|
+
*/
|
|
500
|
+
parseLanguageHint(raw, fieldName) {
|
|
501
|
+
if (raw === undefined) {
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
const trimmed = raw.trim();
|
|
505
|
+
if (trimmed.length === 0) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
if (trimmed.length > 32 || !/^[A-Za-z]{2,8}([-_][A-Za-z0-9]{1,8}){0,2}$/.test(trimmed)) {
|
|
509
|
+
throw new Error(`${fieldName} debe ser un código de idioma como 'es', 'en' o 'auto' (máximo 32 caracteres). / ${fieldName} must be a language code like 'es', 'en', or 'auto' (max 32 chars).`);
|
|
510
|
+
}
|
|
511
|
+
return trimmed;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Parses an optional integer knob that must be absent or within a range.
|
|
515
|
+
*
|
|
516
|
+
* @param raw - Raw knob value.
|
|
517
|
+
* @param fieldName - Dotted field name for error messages.
|
|
518
|
+
* @param min - Inclusive minimum.
|
|
519
|
+
* @param max - Inclusive maximum.
|
|
520
|
+
* @returns Validated integer, or undefined when absent.
|
|
521
|
+
* @throws Error with an Spanish-first bilingual message when present but not an integer within range.
|
|
522
|
+
*/
|
|
523
|
+
parseBoundedInt(raw, fieldName, min, max) {
|
|
524
|
+
if (typeof raw === "undefined") {
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
const parsed = optionalInt(raw);
|
|
528
|
+
if (typeof parsed === "undefined" || !Number.isInteger(parsed)) {
|
|
529
|
+
throw new Error(`${fieldName} debe ser un entero entre ${String(min)} y ${String(max)}. / ${fieldName} must be an integer between ${String(min)} and ${String(max)}.`);
|
|
530
|
+
}
|
|
531
|
+
if (parsed < min || parsed > max) {
|
|
532
|
+
throw new Error(`${fieldName} debe ser un entero entre ${String(min)} y ${String(max)}. / ${fieldName} must be an integer between ${String(min)} and ${String(max)}.`);
|
|
533
|
+
}
|
|
534
|
+
return parsed;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Parses an optional numeric knob that must be absent or within a range.
|
|
538
|
+
*
|
|
539
|
+
* @param raw - Raw knob value.
|
|
540
|
+
* @param fieldName - Dotted field name for error messages.
|
|
541
|
+
* @param min - Inclusive minimum.
|
|
542
|
+
* @param max - Inclusive maximum.
|
|
543
|
+
* @returns Validated number, or undefined when absent.
|
|
544
|
+
* @throws Error with an Spanish-first bilingual message when present but not a number within range.
|
|
545
|
+
*/
|
|
546
|
+
parseBoundedNumber(raw, fieldName, min, max) {
|
|
547
|
+
if (typeof raw === "undefined") {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
const parsed = optionalNumber(raw);
|
|
551
|
+
if (typeof parsed === "undefined" || !Number.isFinite(parsed)) {
|
|
552
|
+
throw new Error(`${fieldName} debe ser un número entre ${String(min)} y ${String(max)}. / ${fieldName} must be a number between ${String(min)} and ${String(max)}.`);
|
|
553
|
+
}
|
|
554
|
+
if (parsed < min || parsed > max) {
|
|
555
|
+
throw new Error(`${fieldName} debe ser un número entre ${String(min)} y ${String(max)}. / ${fieldName} must be a number between ${String(min)} and ${String(max)}.`);
|
|
556
|
+
}
|
|
557
|
+
return parsed;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Parses an optional clip-window bound that must be absent or within 0-86400.
|
|
561
|
+
*
|
|
562
|
+
* @param raw - Raw knob value.
|
|
563
|
+
* @param fieldName - Dotted field name for error messages.
|
|
564
|
+
* @param min - Inclusive minimum.
|
|
565
|
+
* @param max - Inclusive maximum.
|
|
566
|
+
* @returns Validated number, or undefined when absent.
|
|
567
|
+
* @throws Error with an Spanish-first bilingual message when present but not a number within range.
|
|
568
|
+
*/
|
|
569
|
+
parseClipBound(raw, fieldName, min, max) {
|
|
570
|
+
if (typeof raw === "undefined") {
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
const parsed = optionalNumber(raw);
|
|
574
|
+
if (typeof parsed === "undefined" || !Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
575
|
+
throw new Error(`${fieldName} debe ser un número entre ${String(min)} y ${String(max)} (segundos). / ${fieldName} must be a number between ${String(min)} and ${String(max)} (seconds).`);
|
|
576
|
+
}
|
|
577
|
+
return parsed;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Parses an optional clip duration that must be absent or within (0, 86400].
|
|
581
|
+
*
|
|
582
|
+
* @param raw - Raw knob value.
|
|
583
|
+
* @returns Validated duration, or undefined when absent.
|
|
584
|
+
* @throws Error with an Spanish-first bilingual message when present but not a positive number within range.
|
|
585
|
+
*/
|
|
586
|
+
parseClipDuration(raw) {
|
|
587
|
+
if (typeof raw === "undefined") {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
const parsed = optionalNumber(raw);
|
|
591
|
+
if (typeof parsed === "undefined" ||
|
|
592
|
+
!Number.isFinite(parsed) ||
|
|
593
|
+
parsed <= 0 ||
|
|
594
|
+
parsed > ANALYZE_MEDIA_LIMITS.maxClipSeconds) {
|
|
595
|
+
throw new Error(`video.clip_duration_seconds debe ser un número mayor que 0 y menor o igual que ${String(ANALYZE_MEDIA_LIMITS.maxClipSeconds)} (segundos). / video.clip_duration_seconds must be a number greater than 0 and at most ${String(ANALYZE_MEDIA_LIMITS.maxClipSeconds)} (seconds).`);
|
|
596
|
+
}
|
|
597
|
+
return parsed;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Parses the optional requested model id for server-side dispatch affinity.
|
|
601
|
+
*
|
|
602
|
+
* @param raw - Raw `model` argument.
|
|
603
|
+
* @returns Trimmed model id, or undefined when absent/blank.
|
|
604
|
+
* @throws Error with an Spanish-first bilingual message when the model id is not a short string.
|
|
605
|
+
*/
|
|
606
|
+
parseModelHint(raw) {
|
|
607
|
+
if (typeof raw === "undefined" || raw === null) {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
if (typeof raw !== "string" || raw.trim().length === 0 || Array.from(raw.trim()).length > 128) {
|
|
611
|
+
throw new Error("model debe ser el id del modelo activo (texto no vacío, máximo 128 caracteres). Omita para auto-dispatch. / model must be the active model id (non-empty text, max 128 chars). Omit for auto-dispatch.");
|
|
612
|
+
}
|
|
613
|
+
return raw.trim();
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Parses and validates the optional relative image region.
|
|
617
|
+
*
|
|
618
|
+
* @param raw - Raw `region` argument.
|
|
619
|
+
* @returns Validated region, or undefined when absent.
|
|
620
|
+
* @throws Error with an Spanish-first bilingual message when the region is malformed or out of range.
|
|
621
|
+
*/
|
|
622
|
+
parseRegion(raw) {
|
|
623
|
+
if (raw === undefined || raw === null) {
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
627
|
+
throw new Error("region debe ser un objeto {x, y, width, height} con coordenadas relativas entre 0 y 1. Nunca invente coordenadas: use las cajas devueltas en 'elements' de un análisis previo de la misma imagen. / region must be an object {x, y, width, height} with relative coords between 0 and 1. Never invent coordinates: use the boxes returned in 'elements' of a previous analysis of the same image.");
|
|
628
|
+
}
|
|
629
|
+
const record = raw;
|
|
630
|
+
// A typo such as `widh` would otherwise be ignored and zoom the wrong
|
|
631
|
+
// area: reject unknown keys like every other tuning section.
|
|
632
|
+
this.throwOnUnknownKeys(record, REGION_KNOWN_KEYS, "region");
|
|
633
|
+
const readFraction = (fieldName) => {
|
|
634
|
+
const parsed = optionalFraction(record[fieldName]);
|
|
635
|
+
if (typeof parsed === "undefined" || parsed < 0 || parsed > 1) {
|
|
636
|
+
throw new Error(`region.${fieldName} debe ser un número entre 0 y 1 (coordenadas relativas a la imagen original). Use las cajas de 'elements' de un análisis previo. / region.${fieldName} must be a number between 0 and 1 (coords relative to the original image). Use the 'elements' boxes of a previous analysis.`);
|
|
637
|
+
}
|
|
638
|
+
return parsed;
|
|
639
|
+
};
|
|
640
|
+
const region = {
|
|
641
|
+
x: readFraction("x"),
|
|
642
|
+
y: readFraction("y"),
|
|
643
|
+
width: readFraction("width"),
|
|
644
|
+
height: readFraction("height"),
|
|
645
|
+
};
|
|
646
|
+
if (region.width <= 0 || region.height <= 0) {
|
|
647
|
+
throw new Error("region.width y region.height deben ser mayores que 0. / region.width and region.height must be greater than 0.");
|
|
648
|
+
}
|
|
649
|
+
if (region.x + region.width > 1 || region.y + region.height > 1) {
|
|
650
|
+
throw new Error("region debe caber en la imagen original: x+width y y+height no pueden exceder 1. Use las cajas de 'elements' de un análisis previo. / region must fit inside the original image: x+width and y+height cannot exceed 1. Use the 'elements' boxes of a previous analysis.");
|
|
651
|
+
}
|
|
652
|
+
return region;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Rejects oversized prompt text before any byte is uploaded.
|
|
656
|
+
*
|
|
657
|
+
* @remarks
|
|
658
|
+
* Mirrors EnriProxy `VISION_MAX_QUESTION_CHARS`/`VISION_MAX_CONTEXT_CHARS`
|
|
659
|
+
* (2000): the server 400s after upload cost, so the gate lives here.
|
|
660
|
+
*
|
|
661
|
+
* @param value - Optional prompt text.
|
|
662
|
+
* @param fieldName - `question` or `context`.
|
|
663
|
+
* @returns Prompt text, or undefined when absent.
|
|
664
|
+
* @throws Error in Spanish naming the 2000-character cap.
|
|
665
|
+
*/
|
|
666
|
+
static requireBoundedPromptText(value, fieldName) {
|
|
667
|
+
if (typeof value === "undefined") {
|
|
668
|
+
return undefined;
|
|
669
|
+
}
|
|
670
|
+
if (Array.from(value).length > ANALYZE_MEDIA_LIMITS.maxPromptChars) {
|
|
671
|
+
throw new Error(`${fieldName} excede el máximo de ${String(ANALYZE_MEDIA_LIMITS.maxPromptChars)} caracteres (se recibieron ${String(Array.from(value).length)}). Acorte el texto y reintente: el servidor rechaza este mismo tope después de cobrar el upload. / ${fieldName} exceeds the ${String(ANALYZE_MEDIA_LIMITS.maxPromptChars)}-char maximum (got ${String(Array.from(value).length)}). Shorten the text and retry: the server rejects this same cap after charging the upload.`);
|
|
672
|
+
}
|
|
673
|
+
return value;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Accepted top-level argument keys (documented params plus every accepted flat alias).
|
|
678
|
+
*
|
|
679
|
+
* @remarks
|
|
680
|
+
* Unknown top-level keys fail in Spanish listing the valid keys, so typos
|
|
681
|
+
* (`max_frams`, `questoin`) never analyze a whole file at full cost.
|
|
682
|
+
*/
|
|
683
|
+
/**
|
|
684
|
+
* Maximum `source_url` characters the proxy ingests (EnriProxy
|
|
685
|
+
* `VISION_MAX_SOURCE_URL_CHARS`): longer URLs fail here, before any byte
|
|
686
|
+
* travels, instead of failing at the server after cost.
|
|
687
|
+
*/
|
|
688
|
+
const MAX_SOURCE_URL_CHARS = 2048;
|
|
689
|
+
/**
|
|
690
|
+
* Accepted top-level argument keys (exported for the schema-parser
|
|
691
|
+
* agreement test: every key here must exist as an `inputSchema` property).
|
|
692
|
+
*/
|
|
693
|
+
export const TOP_LEVEL_KNOWN_KEYS = new Set([
|
|
694
|
+
"path",
|
|
695
|
+
"paths",
|
|
696
|
+
"context",
|
|
697
|
+
"question",
|
|
698
|
+
"language",
|
|
699
|
+
"max_frames",
|
|
700
|
+
"maxFrames",
|
|
701
|
+
"transcribe",
|
|
702
|
+
"transcription_language",
|
|
703
|
+
"transcriptionLanguage",
|
|
704
|
+
"analysis_mode",
|
|
705
|
+
"analysisMode",
|
|
706
|
+
"model",
|
|
707
|
+
"region",
|
|
708
|
+
"video",
|
|
709
|
+
"audio",
|
|
710
|
+
"document",
|
|
711
|
+
"images",
|
|
712
|
+
"segmentSeconds",
|
|
713
|
+
"segment_seconds",
|
|
714
|
+
"maxSegments",
|
|
715
|
+
"max_segments",
|
|
716
|
+
"maxFramesPerSegment",
|
|
717
|
+
"max_frames_per_segment",
|
|
718
|
+
"audioTimestamps",
|
|
719
|
+
"audio_timestamps",
|
|
720
|
+
"documentMaxPages",
|
|
721
|
+
"document_max_pages",
|
|
722
|
+
"clipStartSeconds",
|
|
723
|
+
"clip_start_seconds",
|
|
724
|
+
"clipEndSeconds",
|
|
725
|
+
"clip_end_seconds",
|
|
726
|
+
"clipDurationSeconds",
|
|
727
|
+
"clip_duration_seconds",
|
|
728
|
+
"cursor",
|
|
729
|
+
"offset",
|
|
730
|
+
"limit",
|
|
731
|
+
]);
|
|
732
|
+
/**
|
|
733
|
+
* Accepted key spellings inside the `video` tuning object (snake_case + camelCase).
|
|
734
|
+
*/
|
|
735
|
+
export const VIDEO_KNOWN_KEYS = new Set([
|
|
736
|
+
"clip_start_seconds",
|
|
737
|
+
"clipStartSeconds",
|
|
738
|
+
"clip_end_seconds",
|
|
739
|
+
"clipEndSeconds",
|
|
740
|
+
"clip_duration_seconds",
|
|
741
|
+
"clipDurationSeconds",
|
|
742
|
+
"segment_seconds",
|
|
743
|
+
"segmentSeconds",
|
|
744
|
+
"max_segments",
|
|
745
|
+
"maxSegments",
|
|
746
|
+
"max_frames_per_segment",
|
|
747
|
+
"maxFramesPerSegment",
|
|
748
|
+
]);
|
|
749
|
+
/**
|
|
750
|
+
* Accepted key spellings inside the `document` tuning object (snake_case + camelCase + legacy aliases).
|
|
751
|
+
*/
|
|
752
|
+
export const DOCUMENT_KNOWN_KEYS = new Set([
|
|
753
|
+
"max_pages_total",
|
|
754
|
+
"maxPagesTotal",
|
|
755
|
+
"max_pages",
|
|
756
|
+
"maxPages",
|
|
757
|
+
"documentMaxPages",
|
|
758
|
+
"document_max_pages",
|
|
759
|
+
"pages_per_batch",
|
|
760
|
+
"pagesPerBatch",
|
|
761
|
+
"max_images_per_batch",
|
|
762
|
+
"maxImagesPerBatch",
|
|
763
|
+
"scanned_text_threshold_chars",
|
|
764
|
+
"scannedTextThresholdChars",
|
|
765
|
+
]);
|
|
766
|
+
/**
|
|
767
|
+
* Accepted key spellings inside the `audio` tuning object (snake_case + camelCase).
|
|
768
|
+
*/
|
|
769
|
+
export const AUDIO_KNOWN_KEYS = new Set([
|
|
770
|
+
"timestamps",
|
|
771
|
+
"audioTimestamps",
|
|
772
|
+
"audio_timestamps",
|
|
773
|
+
"segment_seconds",
|
|
774
|
+
"segmentSeconds",
|
|
775
|
+
"max_segments",
|
|
776
|
+
"maxSegments",
|
|
777
|
+
]);
|
|
778
|
+
/**
|
|
779
|
+
* Accepted key spellings inside the `region` zoom object.
|
|
780
|
+
*/
|
|
781
|
+
export const REGION_KNOWN_KEYS = new Set([
|
|
782
|
+
"x",
|
|
783
|
+
"y",
|
|
784
|
+
"width",
|
|
785
|
+
"height",
|
|
786
|
+
]);
|
|
787
|
+
/**
|
|
788
|
+
* Accepted key spellings inside the `images` tuning object (snake_case + camelCase).
|
|
789
|
+
*/
|
|
790
|
+
export const IMAGES_KNOWN_KEYS = new Set([
|
|
791
|
+
"max_images_total",
|
|
792
|
+
"maxImagesTotal",
|
|
793
|
+
"images_per_batch",
|
|
794
|
+
"imagesPerBatch",
|
|
795
|
+
"max_dimension",
|
|
796
|
+
"maxDimension",
|
|
797
|
+
]);
|
|
798
|
+
/**
|
|
799
|
+
* Returns the first defined candidate, used for snake_case/camelCase and flat/nested aliases.
|
|
800
|
+
*
|
|
801
|
+
* @param candidates - Alias values in precedence order.
|
|
802
|
+
* @returns First defined value or undefined when all are absent.
|
|
803
|
+
*/
|
|
804
|
+
function firstDefined(...candidates) {
|
|
805
|
+
for (const candidate of candidates) {
|
|
806
|
+
if (typeof candidate !== "undefined") {
|
|
807
|
+
return candidate;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Reports whether a parsed tuning object carries at least one defined knob.
|
|
814
|
+
*
|
|
815
|
+
* @param parsed - Parsed tuning object with optional knobs.
|
|
816
|
+
* @returns True when at least one knob is defined.
|
|
817
|
+
*/
|
|
818
|
+
function hasAnyValue(parsed) {
|
|
819
|
+
return Object.values(parsed).some((value) => typeof value !== "undefined");
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Compares two raw knob values for the nested-conflict guard.
|
|
823
|
+
*
|
|
824
|
+
* @remarks
|
|
825
|
+
* Numeric strings (`"60"` vs `60`) count as equal so the guard only fires
|
|
826
|
+
* on genuinely different budgets, mirroring the EnriCode numeric coercion.
|
|
827
|
+
*
|
|
828
|
+
* @param left - First raw value.
|
|
829
|
+
* @param right - Second raw value.
|
|
830
|
+
* @returns True when both describe the same number or identical strings.
|
|
831
|
+
*/
|
|
832
|
+
function numbersEqual(left, right) {
|
|
833
|
+
if (left === right) {
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
const leftNumber = optionalNumber(left);
|
|
837
|
+
const rightNumber = optionalNumber(right);
|
|
838
|
+
if (typeof leftNumber !== "undefined" && typeof rightNumber !== "undefined") {
|
|
839
|
+
return leftNumber === rightNumber;
|
|
840
|
+
}
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
//# sourceMappingURL=AnalyzeMediaParamParser.js.map
|