@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.
Files changed (64) hide show
  1. package/README.md +43 -9
  2. package/dist/client/EnriProxyClient.d.ts +296 -248
  3. package/dist/client/EnriProxyClient.d.ts.map +1 -1
  4. package/dist/client/EnriProxyClient.js +849 -119
  5. package/dist/client/EnriProxyClient.js.map +1 -1
  6. package/dist/client/EnriProxyClientContract.d.ts +478 -0
  7. package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
  8. package/dist/client/EnriProxyClientContract.js +136 -0
  9. package/dist/client/EnriProxyClientContract.js.map +1 -0
  10. package/dist/index.js +23 -12
  11. package/dist/index.js.map +1 -1
  12. package/dist/package-info.d.ts +28 -0
  13. package/dist/package-info.d.ts.map +1 -1
  14. package/dist/package-info.js +28 -0
  15. package/dist/package-info.js.map +1 -1
  16. package/dist/server/EnriVisionServer.d.ts +186 -0
  17. package/dist/server/EnriVisionServer.d.ts.map +1 -1
  18. package/dist/server/EnriVisionServer.js +804 -94
  19. package/dist/server/EnriVisionServer.js.map +1 -1
  20. package/dist/shared/codepointTruncation.d.ts +61 -0
  21. package/dist/shared/codepointTruncation.d.ts.map +1 -0
  22. package/dist/shared/codepointTruncation.js +73 -0
  23. package/dist/shared/codepointTruncation.js.map +1 -0
  24. package/dist/shared/mediaUrlFetcher.d.ts +247 -9
  25. package/dist/shared/mediaUrlFetcher.d.ts.map +1 -1
  26. package/dist/shared/mediaUrlFetcher.js +712 -53
  27. package/dist/shared/mediaUrlFetcher.js.map +1 -1
  28. package/dist/shared/tar.d.ts +82 -2
  29. package/dist/shared/tar.d.ts.map +1 -1
  30. package/dist/shared/tar.js +106 -43
  31. package/dist/shared/tar.js.map +1 -1
  32. package/dist/shared/validation.d.ts +96 -2
  33. package/dist/shared/validation.d.ts.map +1 -1
  34. package/dist/shared/validation.js +169 -10
  35. package/dist/shared/validation.js.map +1 -1
  36. package/dist/tools/AnalyzeMediaContract.d.ts +462 -0
  37. package/dist/tools/AnalyzeMediaContract.d.ts.map +1 -0
  38. package/dist/tools/AnalyzeMediaContract.js +161 -0
  39. package/dist/tools/AnalyzeMediaContract.js.map +1 -0
  40. package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts +35 -0
  41. package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts.map +1 -0
  42. package/dist/tools/AnalyzeMediaExtractionSanitizer.js +214 -0
  43. package/dist/tools/AnalyzeMediaExtractionSanitizer.js.map +1 -0
  44. package/dist/tools/AnalyzeMediaInputResolver.d.ts +250 -0
  45. package/dist/tools/AnalyzeMediaInputResolver.d.ts.map +1 -0
  46. package/dist/tools/AnalyzeMediaInputResolver.js +430 -0
  47. package/dist/tools/AnalyzeMediaInputResolver.js.map +1 -0
  48. package/dist/tools/AnalyzeMediaParamParser.d.ts +307 -0
  49. package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
  50. package/dist/tools/AnalyzeMediaParamParser.js +843 -0
  51. package/dist/tools/AnalyzeMediaParamParser.js.map +1 -0
  52. package/dist/tools/AnalyzeMediaResumableUploader.d.ts +244 -0
  53. package/dist/tools/AnalyzeMediaResumableUploader.d.ts.map +1 -0
  54. package/dist/tools/AnalyzeMediaResumableUploader.js +549 -0
  55. package/dist/tools/AnalyzeMediaResumableUploader.js.map +1 -0
  56. package/dist/tools/AnalyzeMediaTarPackager.d.ts +42 -0
  57. package/dist/tools/AnalyzeMediaTarPackager.d.ts.map +1 -0
  58. package/dist/tools/AnalyzeMediaTarPackager.js +245 -0
  59. package/dist/tools/AnalyzeMediaTarPackager.js.map +1 -0
  60. package/dist/tools/AnalyzeMediaTool.d.ts +156 -294
  61. package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
  62. package/dist/tools/AnalyzeMediaTool.js +611 -457
  63. package/dist/tools/AnalyzeMediaTool.js.map +1 -1
  64. package/package.json +2 -1
@@ -8,10 +8,52 @@
8
8
  */
9
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
10
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ import { ANALYZE_MEDIA_ERROR_CODES, ANALYZE_MEDIA_LIMITS, } from "../tools/AnalyzeMediaContract.js";
12
+ import { truncateCodePointsHeadTail } from "../shared/codepointTruncation.js";
13
+ /**
14
+ * Bilingual fragments marking caller-side input errors for {@link EnriVisionServer.mapToolError}.
15
+ *
16
+ * @remarks
17
+ * Every fragment below appears in the Spanish-first half, the English half,
18
+ * or both of this repo's own validation messages, so locally-thrown argument
19
+ * and tuning errors map to `ENRICODE_ERR_TOOL_INPUT_INVALID` without listing
20
+ * each message. Execution failures (upload/analysis/transport) match none of
21
+ * these and stay `ENRICODE_ERR_TOOL_EXECUTION_FAILED`. Protocol-shape faults
22
+ * whose wording happens to contain a fragment (`Server response is invalid`,
23
+ * `Upload-Offset` header faults, `Invalid server offset`, and the 50 MiB
24
+ * response-overflow notice whose English half says "exceeded") are exempted
25
+ * first in {@link EnriVisionServer.mapToolError} so they never misclassify
26
+ * as input.
27
+ */
28
+ const INPUT_ERROR_PATTERN = /must be|debe ser|unknown|desconocid|reject|rechaz|does not apply|no aplica|only appl|solo aplica|provide|proporcione|not allowed|no se permite|not a file|no es un archivo|not an image|no es imagen|must contain|debe contener|cannot|no puede|exceed|excede|differ|difieren|greater than|mayor que|must fit|caber|must start with|debe comenzar|invalid|inválid|missing|falta|only exist|sólo existen|without advancing|sin avanzar|no vision capability|no tiene capacidad de visión/iu;
11
29
  /**
12
30
  * MCP server exposing EnriVision tools.
13
31
  */
14
32
  export class EnriVisionServer {
33
+ /**
34
+ * Maximum warnings carried by either envelope (server-controlled input).
35
+ */
36
+ static MAX_ENVELOPE_WARNINGS = 20;
37
+ /**
38
+ * Maximum code points kept per warning line.
39
+ */
40
+ static MAX_ENVELOPE_WARNING_CHARS = 1000;
41
+ /**
42
+ * Maximum grounded elements carried by either envelope.
43
+ */
44
+ static MAX_ENVELOPE_ELEMENTS = 100;
45
+ /**
46
+ * Bounds server-controlled warnings for model-facing envelopes.
47
+ *
48
+ * @param warnings - Raw warnings, when present.
49
+ * @returns Capped warnings with code-point-safe lines.
50
+ */
51
+ static boundEnvelopeWarnings(warnings) {
52
+ if (!Array.isArray(warnings) || warnings.length === 0) {
53
+ return [];
54
+ }
55
+ return warnings.slice(0, EnriVisionServer.MAX_ENVELOPE_WARNINGS).map((warning) => truncateCodePointsHeadTail(String(warning ?? ""), EnriVisionServer.MAX_ENVELOPE_WARNING_CHARS, 0).text);
56
+ }
15
57
  /**
16
58
  * Underlying MCP server implementation.
17
59
  */
@@ -52,42 +94,483 @@ export class EnriVisionServer {
52
94
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
53
95
  return { tools: [analyzeMediaDefinition] };
54
96
  });
55
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
97
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
56
98
  if (request.params.name !== "analyze_media") {
99
+ const mapped = EnriVisionServer.mapToolError(new Error(`Herramienta desconocida: ${request.params.name} / Unknown tool: ${request.params.name}.`));
57
100
  return {
58
101
  isError: true,
59
- content: [{ type: "text", text: `Herramienta desconocida: ${request.params.name}` }]
102
+ content: [{ type: "text", text: mapped.text }],
103
+ structuredContent: mapped.structuredContent,
60
104
  };
61
105
  }
62
106
  try {
63
107
  const args = request.params.arguments ?? {};
64
108
  const params = this.analyzeMediaTool.parseParams(args);
65
- const result = await this.analyzeMediaTool.execute(params);
109
+ const result = await this.analyzeMediaTool.execute(params, { signal: extra.signal });
66
110
  return {
67
111
  isError: false,
68
112
  content: [
69
113
  {
70
114
  type: "text",
71
- text: `ANALISIS (${result.media_type}):\n${result.analysis}` +
72
- (Array.isArray(result.elements) && result.elements.length > 0
73
- ? `\n\nelements (cajas relativas a la imagen original, coordenadas normalizadas 0-1 —no píxeles—; (0,0) es la esquina superior izquierda; reutilizables directamente como 'region' para zoom; NUNCA invente coordenadas):\n${result.elements
74
- .map((element) => `- ${element.label} [${element.box.x}, ${element.box.y}, ${element.box.width}, ${element.box.height}]`)
75
- .join("\n")}`
76
- : "")
77
- }
115
+ text: EnriVisionServer.formatAnalysisText(result.analysis, result.media_type, result.elements, result.warnings),
116
+ },
78
117
  ],
79
- structuredContent: result
118
+ structuredContent: EnriVisionServer.boundStructuredContent(result),
80
119
  };
81
120
  }
82
121
  catch (error) {
83
- const message = error instanceof Error ? error.message : String(error);
122
+ const mapped = EnriVisionServer.mapToolError(error);
84
123
  return {
85
124
  isError: true,
86
- content: [{ type: "text", text: message }]
125
+ content: [{ type: "text", text: mapped.text }],
126
+ structuredContent: mapped.structuredContent,
87
127
  };
88
128
  }
89
129
  });
90
130
  }
131
+ /**
132
+ * Maps one tool failure to bilingual text plus a machine-readable error shape.
133
+ *
134
+ * @remarks
135
+ * OpenAI-compatible third-party clients cannot branch on a human string:
136
+ * every MCP error carries `structuredContent: {code, retryable, httpStatus?}`
137
+ * reusing the EnriCode `VisionAnalyzeMediaErrorMapper` vocabulary
138
+ * (`ENRICODE_ERR_TOOL_INPUT_INVALID` for argument/tuning errors including
139
+ * proxy 400/422, `ENRICODE_ERR_TOOL_EXECUTION_FAILED` for server/transport
140
+ * failures, `ENRICODE_ERR_TOOL_EXECUTION_TIMEOUT` for expired budgets,
141
+ * `ENRICODE_ERR_TOOL_EXECUTION_ABORTED` for caller cancels). `retryable` is
142
+ * true only for 408/429/5xx proxy statuses and expired budgets; terminal
143
+ * input/auth errors and cancels never retry unchanged. `httpStatus` is
144
+ * present only when the failure carries a proxy HTTP status.
145
+ *
146
+ * @param error - Unknown caught failure.
147
+ * @returns Bilingual text plus the machine-readable error shape.
148
+ */
149
+ static mapToolError(error) {
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ const status = EnriVisionServer.readHttpStatus(error);
152
+ if ((error instanceof Error
153
+ && (error.name === "AbortError" || error.name === "TimeoutError"))
154
+ || /cancelled by the client|cancelada por el cliente/u.test(message)) {
155
+ return {
156
+ text: message,
157
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.executionAborted, retryable: false },
158
+ };
159
+ }
160
+ if (typeof status === "number") {
161
+ if (status === 400 || status === 422) {
162
+ return {
163
+ text: message,
164
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.inputInvalid, retryable: false, httpStatus: status },
165
+ };
166
+ }
167
+ if (status === 408 || status === 429 || (status >= 500 && status <= 599)) {
168
+ return {
169
+ text: message,
170
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.executionFailed, retryable: true, httpStatus: status },
171
+ };
172
+ }
173
+ return {
174
+ text: message,
175
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.executionFailed, retryable: false, httpStatus: status },
176
+ };
177
+ }
178
+ // Stable-code mapping (proxy knob-validation errors carry `invalid_*`
179
+ // codes + dotted fields): matches on the machine vocabulary instead of
180
+ // Spanish prose, even when the HTTP status was lost in transport.
181
+ const serverCode = typeof error === "object" && error !== null ? error["serverCode"] : undefined;
182
+ if (typeof serverCode === "string" && serverCode.startsWith("invalid_")) {
183
+ return {
184
+ text: message,
185
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.inputInvalid, retryable: false },
186
+ };
187
+ }
188
+ if (/timed out|expiró|agotó el tiempo límite|exceeded the maximum time/u.test(message)) {
189
+ return {
190
+ text: message,
191
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.executionTimeout, retryable: true },
192
+ };
193
+ }
194
+ if (/Server response is invalid|Missing Upload-Offset|Invalid Upload-Offset|Invalid server offset/u.test(message)
195
+ || /tamaño máximo permitido|maximum allowed size/iu.test(message)) {
196
+ return {
197
+ text: message,
198
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.executionFailed, retryable: false },
199
+ };
200
+ }
201
+ if (INPUT_ERROR_PATTERN.test(message)) {
202
+ return {
203
+ text: message,
204
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.inputInvalid, retryable: false },
205
+ };
206
+ }
207
+ return {
208
+ text: message,
209
+ structuredContent: { code: ANALYZE_MEDIA_ERROR_CODES.executionFailed, retryable: false },
210
+ };
211
+ }
212
+ /**
213
+ * Reads a proxy HTTP status from an unknown failure.
214
+ *
215
+ * @param error - Unknown caught failure.
216
+ * @returns HTTP status when the failure carries one, otherwise undefined.
217
+ */
218
+ static readHttpStatus(error) {
219
+ if (typeof error !== "object" || error === null) {
220
+ return undefined;
221
+ }
222
+ const status = error["status"];
223
+ if (typeof status !== "number" || !Number.isFinite(status)) {
224
+ return undefined;
225
+ }
226
+ const floored = Math.floor(status);
227
+ // HTTP statuses live in 100-599; 0 is the transport-level "no response"
228
+ // marker (socket errors) and must not mask the stable-code mapping.
229
+ return floored >= 100 && floored <= 599 ? floored : undefined;
230
+ }
231
+ /**
232
+ * Bounds the `structuredContent` payload for small MCP clients.
233
+ *
234
+ * @remarks
235
+ * MCP delivers the whole result in one JSON frame, so the full `analysis`
236
+ * is truncated to `maxStructuredContentAnalysisChars` keeping
237
+ * `maxStructuredContentAnalysisHeadChars` at the start and the remainder
238
+ * at the end (conclusions survive) with an inline Spanish-first bilingual
239
+ * seam stating
240
+ * both ends plus the total (`analysis_truncated: true` plus
241
+ * `analysis_total_chars`; code-point safe, single pass with O(limit)
242
+ * memory via `truncateCodePointsHeadTail`), and `extraction` is capped at
243
+ * `maxStructuredContentExtractionChars` serialized characters with its
244
+ * shape preserved. Small payloads pass through untouched. The seam mirrors
245
+ * `content.text` and EnriCode `VisionAnalyzeMediaResultTruncator` so models
246
+ * reading only `structuredContent` never hallucinate continuity.
247
+ *
248
+ * @param result - Full tool result.
249
+ * @returns Bounded structured content.
250
+ */
251
+ static boundStructuredContent(result) {
252
+ const limit = ANALYZE_MEDIA_LIMITS.maxStructuredContentAnalysisChars;
253
+ const headChars = ANALYZE_MEDIA_LIMITS.maxStructuredContentAnalysisHeadChars;
254
+ // The seam counts against the budget: shrink the tail by exactly the
255
+ // seam's code points (iterating to a fixed point because the seam
256
+ // prints the tail length) so head + seam + tail never exceeds the
257
+ // declared limit.
258
+ let tailChars = limit - headChars;
259
+ const totalProbe = truncateCodePointsHeadTail(result.analysis, headChars, tailChars);
260
+ const totalChars = totalProbe.totalChars;
261
+ let seam = EnriVisionServer.buildStructuredSeam(headChars, tailChars, totalChars);
262
+ if (totalProbe.truncated) {
263
+ for (let pass = 0; pass < 3; pass += 1) {
264
+ const seamChars = Array.from(seam).length;
265
+ const nextTail = Math.max(0, limit - headChars - seamChars);
266
+ if (nextTail === tailChars) {
267
+ break;
268
+ }
269
+ tailChars = nextTail;
270
+ seam = EnriVisionServer.buildStructuredSeam(headChars, tailChars, totalChars);
271
+ }
272
+ }
273
+ const cut = truncateCodePointsHeadTail(result.analysis, headChars, tailChars);
274
+ const boundedElements = Array.isArray(result.elements)
275
+ ? result.elements.slice(0, EnriVisionServer.MAX_ENVELOPE_ELEMENTS).map((element) => ({
276
+ ...element,
277
+ label: truncateCodePointsHeadTail(String(element.label ?? ""), 200, 0).text,
278
+ }))
279
+ : result.elements;
280
+ const boundedAnalysis = cut.truncated
281
+ ? `${cut.head}${seam}${cut.tail}`
282
+ : result.analysis;
283
+ const boundedExtraction = EnriVisionServer.boundExtraction(result.extraction);
284
+ const boundedWarnings = EnriVisionServer.boundEnvelopeWarnings(Array.isArray(result.warnings) ? result.warnings : undefined);
285
+ const boundedMediaType = EnriVisionServer.sanitizeMediaTypeLabel(result.media_type);
286
+ const sourceWarnings = Array.isArray(result.warnings) ? result.warnings : [];
287
+ const warningsChanged = boundedWarnings.length !== sourceWarnings.length
288
+ || boundedWarnings.some((warning, index) => warning !== sourceWarnings[index]);
289
+ const mediaTypeChanged = boundedMediaType !== result.media_type;
290
+ if (totalChars <= limit && boundedExtraction === result.extraction && boundedElements === result.elements && !warningsChanged && !mediaTypeChanged) {
291
+ return { ...result };
292
+ }
293
+ return {
294
+ ...result,
295
+ elements: boundedElements,
296
+ media_type: boundedMediaType,
297
+ warnings: boundedWarnings,
298
+ analysis: boundedAnalysis,
299
+ ...(totalChars <= limit
300
+ ? {}
301
+ : { analysis_truncated: true, analysis_total_chars: totalChars }),
302
+ extraction: boundedExtraction,
303
+ };
304
+ }
305
+ /**
306
+ * Builds the Spanish-first bilingual truncation seam for the structured
307
+ * analysis head+tail cut.
308
+ *
309
+ * @param headChars - Head code points kept.
310
+ * @param tailChars - Tail code points kept.
311
+ * @param totalChars - Total analysis code points.
312
+ * @returns Seam string placed between head and tail.
313
+ */
314
+ static buildStructuredSeam(headChars, tailChars, totalChars) {
315
+ return `…[truncado: se muestran principio (${String(headChars)}) y fin (${String(tailChars)}) de ${String(totalChars)} caracteres / truncated: showing head (${String(headChars)}) and tail (${String(tailChars)}) of ${String(totalChars)} chars]…`;
316
+ }
317
+ /**
318
+ * Caps an extraction payload to the structured-content budget.
319
+ *
320
+ * @remarks
321
+ * Single `JSON.stringify` size probe measured in code points (not UTF-16
322
+ * units, so astral-plane text is budgeted in the same units as every
323
+ * other truncation in this module): payloads within budget keep their
324
+ * exact reference (callers can rely on passthrough). Over-budget payloads
325
+ * get long strings head+tail cut at `maxBoundExtractionStringChars` with
326
+ * an Spanish-first bilingual marker; the object shape is preserved. The
327
+ * result is re-probed after every cut: when tightening still exceeds the
328
+ * budget the per-string cap is quartered (down to 64 chars) and, as a last
329
+ * resort, the payload degrades to a bilingual omission marker — so
330
+ * `serialized(bounded)` never exceeds the budget. The walk is iterative
331
+ * with explicit depth and node budgets, so a hostile deep/wide server
332
+ * response degrades to the omission marker instead of overflowing
333
+ * the call stack.
334
+ *
335
+ * @param extraction - Raw extraction object.
336
+ * @returns Original object when within budget, otherwise a bounded copy.
337
+ */
338
+ static boundExtraction(extraction) {
339
+ let serialized;
340
+ try {
341
+ serialized = JSON.stringify(extraction);
342
+ }
343
+ catch {
344
+ return {};
345
+ }
346
+ if (!EnriVisionServer.isOverExtractionBudget(serialized)) {
347
+ return extraction;
348
+ }
349
+ let perString = ANALYZE_MEDIA_LIMITS.maxBoundExtractionStringChars;
350
+ let bounded = EnriVisionServer.cutLongStrings(extraction, perString);
351
+ for (let round = 0; round < 4; round += 1) {
352
+ let reserialized;
353
+ try {
354
+ reserialized = JSON.stringify(bounded);
355
+ }
356
+ catch {
357
+ return {};
358
+ }
359
+ if (!EnriVisionServer.isOverExtractionBudget(reserialized)) {
360
+ return bounded;
361
+ }
362
+ perString = Math.max(64, Math.floor(perString / 4));
363
+ bounded = EnriVisionServer.cutLongStrings(extraction, perString);
364
+ }
365
+ try {
366
+ const finalSerialized = JSON.stringify(bounded);
367
+ if (!EnriVisionServer.isOverExtractionBudget(finalSerialized)) {
368
+ return bounded;
369
+ }
370
+ }
371
+ catch {
372
+ return {};
373
+ }
374
+ return {
375
+ _omitted: `[extracción omitida: aún excedía ${String(ANALYZE_MEDIA_LIMITS.maxStructuredContentExtractionChars)} caracteres tras recortar strings largas / extraction omitted: still exceeded ${String(ANALYZE_MEDIA_LIMITS.maxStructuredContentExtractionChars)} chars after tightening long strings]`,
376
+ };
377
+ }
378
+ /**
379
+ * Reports whether one serialized extraction exceeds the budget (code points).
380
+ *
381
+ * @remarks
382
+ * Single-pass probe with O(budget) memory (no second full string): counts
383
+ * code points while retaining at most the budget head.
384
+ *
385
+ * @param serialized - Serialized extraction payload.
386
+ * @returns True when the payload exceeds `maxStructuredContentExtractionChars`.
387
+ */
388
+ static isOverExtractionBudget(serialized) {
389
+ return truncateCodePointsHeadTail(serialized, ANALYZE_MEDIA_LIMITS.maxStructuredContentExtractionChars, 0).truncated;
390
+ }
391
+ /**
392
+ * Copies a value cutting strings longer than the cap (head+tail).
393
+ *
394
+ * @remarks
395
+ * Iterative post-order walk with an explicit stack: `MAX_DEPTH` bounds
396
+ * nesting (deeper subtrees become a bilingual omission marker) and
397
+ * `MAX_NODES` bounds breadth (past the budget, remaining containers
398
+ * become the same marker), so hostile server payloads cannot overflow the
399
+ * call stack or stall the MCP frame. `__proto__`/`constructor`/`prototype`
400
+ * keys are still dropped. JSON-derived extractions cannot cycle, but the
401
+ * node budget doubles as a cycle backstop.
402
+ *
403
+ * @param value - Unknown value to bound.
404
+ * @param perString - Maximum code points kept per string (split head/tail).
405
+ * @returns Bounded copy.
406
+ */
407
+ static cutLongStrings(value, perString) {
408
+ if (typeof value === "string") {
409
+ return EnriVisionServer.cutOneLongString(value, perString);
410
+ }
411
+ if (value === null || typeof value !== "object") {
412
+ return value;
413
+ }
414
+ const MAX_DEPTH = 32;
415
+ const MAX_NODES = 20000;
416
+ const root = Array.isArray(value) ? [] : {};
417
+ let nodes = 1;
418
+ const stack = [{ source: value, copy: root, depth: 0 }];
419
+ while (stack.length > 0) {
420
+ const frame = stack.pop();
421
+ const entries = Array.isArray(frame.source)
422
+ ? frame.source.map((item, index) => [index, item])
423
+ : Object.entries(frame.source);
424
+ for (const [key, child] of entries) {
425
+ if (typeof key === "string"
426
+ && (key === "__proto__" || key === "constructor" || key === "prototype")) {
427
+ continue;
428
+ }
429
+ nodes += 1;
430
+ const assign = (bounded) => {
431
+ if (Array.isArray(frame.copy)) {
432
+ frame.copy[key] = bounded;
433
+ }
434
+ else {
435
+ frame.copy[key] = bounded;
436
+ }
437
+ };
438
+ if (typeof child === "string") {
439
+ assign(EnriVisionServer.cutOneLongString(child, perString));
440
+ }
441
+ else if (child !== null && typeof child === "object") {
442
+ if (frame.depth + 1 > MAX_DEPTH || nodes > MAX_NODES) {
443
+ assign("[contenido omitido: estructura demasiado profunda o extensa / content omitted: structure too deep or wide]");
444
+ }
445
+ else {
446
+ const childCopy = Array.isArray(child) ? [] : {};
447
+ assign(childCopy);
448
+ stack.push({
449
+ source: child,
450
+ copy: childCopy,
451
+ depth: frame.depth + 1,
452
+ });
453
+ }
454
+ }
455
+ else {
456
+ assign(child);
457
+ }
458
+ }
459
+ }
460
+ return root;
461
+ }
462
+ /**
463
+ * Cuts one string to the per-string budget keeping head and tail.
464
+ *
465
+ * @param value - Raw string.
466
+ * @param perString - Maximum code points kept (split head/tail).
467
+ * @returns Original string when within budget, otherwise a bounded copy with an Spanish-first bilingual seam.
468
+ */
469
+ static cutOneLongString(value, perString) {
470
+ const head = Math.ceil(perString / 2);
471
+ const tail = Math.floor(perString / 2);
472
+ const cut = truncateCodePointsHeadTail(value, head, tail);
473
+ if (!cut.truncated) {
474
+ return value;
475
+ }
476
+ return `${cut.head}…[truncado: se muestran principio y fin de ${String(cut.totalChars)} caracteres / truncated: showing head and tail of ${String(cut.totalChars)} chars]…${cut.tail}`;
477
+ }
478
+ /**
479
+ * Builds the Spanish-first bilingual truncation seam for the text-output
480
+ * analysis head+tail cut, including its framing newlines.
481
+ *
482
+ * @param head - Head code points kept.
483
+ * @param tail - Tail code points kept.
484
+ * @param totalChars - Total analysis code points.
485
+ * @returns Seam string placed between head and tail.
486
+ */
487
+ static buildTextOutputSeam(head, tail, totalChars) {
488
+ return `\n\n[…texto truncado por tamaño: se muestran principio (${String(head)}) y fin (${String(tail)}) de ${String(totalChars)} caracteres / truncated text by size: showing head (${String(head)}) and tail (${String(tail)}) of ${String(totalChars)} chars…]\n\n`;
489
+ }
490
+ /**
491
+ * Formats the model-facing text output with a bounded analysis appendix.
492
+ *
493
+ * @remarks
494
+ * The server `analysis` text can reach tens of MiB; only head+tail
495
+ * (`maxAnalysisTextChars` code points split evenly, single pass) reach the
496
+ * model context, followed by an explicit Spanish-first bilingual truncation
497
+ * notice naming both ends and the total. Keeping the tail preserves
498
+ * conclusions that head-only truncation drops. When analysis fails, the
499
+ * thrown message may embed a `Detalle del servidor:` fragment in the proxy
500
+ * language. Element labels are sliced by code point (never splitting
501
+ * surrogate pairs) and flattened to one line; the server `media_type` is
502
+ * stripped of line breaks and bounded to 128 chars so a hostile value
503
+ * cannot break the envelope.
504
+ *
505
+ * @param analysis - Raw server analysis text.
506
+ * @param mediaType - Detected media type.
507
+ * @param elements - Optional grounded element boxes.
508
+ * @param warnings - Optional Spanish-first bilingual honesty notes (e.g., clamped clip).
509
+ * @returns Bounded Spanish-first bilingual text output.
510
+ */
511
+ static formatAnalysisText(analysis, mediaType, elements, warnings) {
512
+ const limit = ANALYZE_MEDIA_LIMITS.maxAnalysisTextChars;
513
+ const head = Math.ceil(limit / 2);
514
+ // The seam plus its framing newlines count against the budget: shrink
515
+ // the tail by exactly their code points (fixed-point pass because the
516
+ // seam prints the tail length) so head + seam + tail never exceeds the
517
+ // declared limit.
518
+ let tail = Math.floor(limit / 2);
519
+ const probe = truncateCodePointsHeadTail(analysis, head, tail);
520
+ const totalChars = probe.totalChars;
521
+ let seamText = EnriVisionServer.buildTextOutputSeam(head, tail, totalChars);
522
+ if (probe.truncated) {
523
+ for (let pass = 0; pass < 3; pass += 1) {
524
+ const seamChars = Array.from(seamText).length;
525
+ const nextTail = Math.max(0, limit - head - seamChars);
526
+ if (nextTail === tail) {
527
+ break;
528
+ }
529
+ tail = nextTail;
530
+ seamText = EnriVisionServer.buildTextOutputSeam(head, tail, totalChars);
531
+ }
532
+ }
533
+ const cut = truncateCodePointsHeadTail(analysis, head, tail);
534
+ const trimmed = cut.truncated
535
+ ? `${cut.head}${seamText}${cut.tail}`
536
+ : analysis;
537
+ const safeMediaType = EnriVisionServer.sanitizeMediaTypeLabel(mediaType);
538
+ const header = `ANÁLISIS (${safeMediaType}) / ANALYSIS (${safeMediaType}):\n${trimmed}`;
539
+ const cappedElements = Array.isArray(elements)
540
+ ? elements.slice(0, EnriVisionServer.MAX_ENVELOPE_ELEMENTS)
541
+ : [];
542
+ const withElements = cappedElements.length > 0
543
+ ? `${header}\n\nelementos ('elements', cajas relativas a la imagen original, coordenadas normalizadas 0-1 —no píxeles—; (0,0) es la esquina superior izquierda; reutilizables directamente como 'region' para zoom; NUNCA invente coordenadas) / elements ('elements', boxes relative to the original image, normalized 0-1 coords — not pixels; (0,0) is the top-left corner; reusable directly as 'region' for zoom; NEVER invent coordinates):\n${cappedElements
544
+ .map((element) => {
545
+ const singleLine = String(element.label ?? "").replace(/[\r\n]+/gu, " ");
546
+ const label = truncateCodePointsHeadTail(singleLine, 200, 0).text;
547
+ return `- ${label} [${element.box.x}, ${element.box.y}, ${element.box.width}, ${element.box.height}]`;
548
+ })
549
+ .join("\n")}`
550
+ : header;
551
+ const cappedWarnings = EnriVisionServer.boundEnvelopeWarnings(warnings);
552
+ if (cappedWarnings.length > 0) {
553
+ const warningLines = cappedWarnings.map((warning) => `- ${warning}`).join("\n");
554
+ return `${withElements}\n\navisos / warnings:\n${warningLines}`;
555
+ }
556
+ return withElements;
557
+ }
558
+ /**
559
+ * Sanitizes one server `media_type` value for the text envelope header.
560
+ *
561
+ * @remarks
562
+ * The header interpolates the value verbatim today (validated non-empty
563
+ * only): line breaks are flattened and the value is bounded to 128 code
564
+ * points so a hostile server value cannot inject envelope lines.
565
+ *
566
+ * @param mediaType - Raw server media type.
567
+ * @returns Single-line media type label (at most 128 code points).
568
+ */
569
+ static sanitizeMediaTypeLabel(mediaType) {
570
+ const singleLine = String(mediaType ?? "").replace(/[\r\n]+/gu, " ").trim();
571
+ const source = singleLine.length > 0 ? singleLine : "unknown";
572
+ return truncateCodePointsHeadTail(source, 128, 0).text;
573
+ }
91
574
  /**
92
575
  * Returns the JSON schema tool definition for `analyze_media`.
93
576
  *
@@ -96,186 +579,413 @@ export class EnriVisionServer {
96
579
  getAnalyzeMediaToolDefinition() {
97
580
  return {
98
581
  name: "analyze_media",
99
- description: "Sube y analiza un archivo local mediante EnriProxy (extracción del lado servidor + análisis con modelo).\n" +
582
+ description: "Sube y analiza un archivo mediante EnriProxy (extracción del lado servidor + análisis con modelo).\n / Upload and analyze a media file via EnriProxy (server-side extraction + model analysis)." +
583
+ "\n" +
584
+ "Cuándo usarla: PDFs grandes o escaneados donde el Read puede truncar; video/audio u otros binarios que el cliente no puede leer; HEIC/AVIF/TIFF/APNG/SVG/Office cuando el Read no es confiable; archivos muy grandes con subidas reanudables (hasta 4 GiB).\n / When to use: large or scanned PDFs where client Read may truncate; video/audio or binary media the client cannot Read; HEIC/AVIF/TIFF/APNG/SVG/Office docs when client Read is unreliable; very large files needing resumable uploads (up to 4 GiB)." +
585
+ "\n" +
586
+ "Reglas: use `path` para un archivo, `paths` para varias imágenes. Cuando `paths` trae al menos una entrada válida, `path` se ignora (contrato explícito: mandar ambos se permite, `path` se ignora en silencio — prefiera semántica oneOf y mande solo uno). Las entradas en blanco se descartan; claves desconocidas en `video`/`audio`/`document`/`images` se rechazan. `question` es opcional aquí (obligatoria en EnriCode); attachmentIndex/attachmentId no existen aquí (sólo EnriCode).\n / Rules: use `path` for one file, `paths` for several images (UI screenshots/photo sets). When `paths` carries at least one valid entry, `path` is ignored (explicit ignore-path contract: sending both is allowed, `path` is silently ignored — prefer oneOf semantics and send only one). Blank `paths` entries are discarded; unknown keys inside `video`/`audio`/`document`/`images` are rejected (check typos like `max_pages_totall`). `question` is optional here (required in EnriCode vision.analyze_media); attachmentIndex/attachmentId do not exist here (EnriCode-only)." +
587
+ "\n" +
588
+ "Presupuestos (timeout = min(ENRIVISION_TIMEOUT_MS del operador, presupuesto del modo)): single = 10 min (una pasada, rápida y barata); multipass = 20 min (por segmentos/lotes + reducción); auto = 20 min (el servidor elige y puede escalar a multipass). Si no sabe cuál usar, omita el afinado (auto).\n / Analysis budgets (client analyze timeout = min(operator ENRIVISION_TIMEOUT_MS, mode budget)): single = 10 min (one pass, fast and cheap, 1 image or simple questions); multipass = 20 min (per-segment/batch map + reduce; PDFs over ~20 pages, long videos, image sets); auto = 20 min (the server picks and may escalate to multipass). If unsure, omit tuning (auto)." +
100
589
  "\n" +
101
- "Cuándo usarla:\n" +
102
- "- PDFs grandes (muchas páginas) o escaneados donde el Read del cliente puede truncar o perder contenido.\n" +
103
- "- Video/audio u otros medios binarios que su cliente no puede leer con Read.\n" +
104
- "- Archivos de audio en formatos comunes (mp3, wav, flac, m4a, aac, ogg/oga, opus, wma, weba, mka, aiff/aif/aifc, caf, m4b/m4r, mp1/mp2/mpa/mpga).\n" +
105
- "- HEIC/AVIF/TIFF/APNG/SVG/documentos de Office cuando el Read del cliente es poco confiable.\n" +
106
- "- Archivos muy grandes que requieren subidas reanudables (hasta 4GB).\n" +
107
- "- PDFs/videos grandes: use `analysis_mode` = 'multipass' para mejor cobertura (auto prefiere multipass para PDFs de más de 20 páginas).\n" +
108
- "- Para preguntas de video en un tiempo específico (por ejemplo, \"¿qué pasa en 12:34?\"), use `video.clip_start_seconds` y `video.clip_duration_seconds`.\n" +
590
+ "Clip de video: para preguntas en un tiempo específico (\"¿qué pasa en 12:34?\") use video.clip_start_seconds + video.clip_duration_seconds: convierta a segundos (12:34 = 12*60+34 = 754), por ejemplo clip_start_seconds=754 y clip_duration_seconds=30. O dé video.clip_end_seconds (fin = inicio + duración, 0-86400 s).\n / Video clip targeting: for time-specific questions (\"what happens at 12:34?\") use video.clip_start_seconds + video.clip_duration_seconds: convert to seconds (12:34 = 12*60+34 = 754) and request a window, e.g. clip_start_seconds=754 and clip_duration_seconds=30. Or give video.clip_end_seconds instead (end = start + duration, 0-86400 s)." +
109
591
  "\n" +
110
- "Reglas:\n" +
111
- "- Use `path` para un archivo, o `paths` para varias imágenes (capturas de UI/sets de fotos).\n" +
112
- "- `path`/`paths` aceptan rutas absolutas en la máquina donde corre este servidor MCP (el cliente), o URLs http(s) que se descargan temporalmente en esa misma máquina (hasta 64 MiB; no se permiten hosts locales ni redes privadas).\n" +
113
- "- Requiere una API key válida de EnriProxy (env `ENRIPROXY_API_KEY`, enviada como Authorization: Bearer ...).\n" +
114
- "- Prefiera el Read nativo del cliente sólo para texto/PDF/imágenes comunes pequeños y simples cuando funcione; prefiera esta herramienta para PDFs grandes.\n" +
115
- "- Responda estrictamente con la salida de la herramienta; si faltan fotogramas/transcripción, dígalo.\n" +
116
- "- Video: los fotogramas y la transcripción pertenecen a la MISMA línea de tiempo del video (no son imágenes sin relación).\n" +
117
- "- Los GIF/WebP/APNG/SVG animados se convierten en fotogramas clave representativos.\n" +
118
- "- Establezca `language` (por ejemplo, 'es') para coincidir con el idioma del usuario y evitar deriva de idioma.\n" +
592
+ "Enteros estrictos: los knobs enteros aceptan números o strings enteras completas (\"60\" vale; \"8.0\", \"8abc\" y 7.9 fallan). Los flotantes aceptan decimales (\"12.5\" vale). transcribe vale true por defecto y no tiene efecto en imágenes/documentos (se declara en warnings, se ignora). Requiere API key válida de EnriProxy (env ENRIPROXY_API_KEY).\n / Strict integers: integer knobs accept numbers or complete integer strings (\"60\" works; \"8.0\", \"8abc\", 7.9 fail). Floats accept decimals (\"12.5\" works). transcribe defaults to true and has no effect on images/documents (declared in warnings, ignored). Requires a valid EnriProxy API key (env ENRIPROXY_API_KEY, sent as Authorization: Bearer ...)." +
119
593
  "\n" +
120
- "Depuración de capturas de UI (cuando el material sean capturas de pantalla de aplicaciones):\n" +
121
- "- Abra con un veredicto de una línea en lenguaje claro (por ejemplo, 'el formulario de login renderiza correctamente' o 'el header se solapa con la barra lateral').\n" +
122
- "- Describa zona por zona (header, barra lateral, contenido principal, modales, notificaciones), no como escena general.\n" +
123
- "- Aproxime los colores como valores hex (por ejemplo, #1F6FEB) y nómbrelos; señale colores inesperados o inconsistentes.\n" +
124
- "- Cuantifique defectos de layout: desbordes, recortes, solapamientos, desalineaciones, espaciados faltantes, texto cortado; estime magnitudes en píxeles cuando sea posible.\n" +
125
- "- Transcriba textualmente etiquetas, botones y cualquier mensaje de error o estado visible.\n" +
126
- "- Si la solicitud indica qué se esperaba, compare observado vs esperado de forma explícita.",
594
+ "Errores: las fallas devuelven isError con texto bilingüe más structuredContent {code, retryable, httpStatus?} con el vocabulario EnriCode; retryable marca 429/5xx/timeouts. Si el mensaje trae `Detalle del servidor:` en el idioma del proxy, repórtelo tal cual. Fotogramas y transcripción comparten la MISMA línea de tiempo. `model` es el id del modelo para afinidad de dispatch (máximo 128 caracteres o env ENRIVISION_MODEL; omita para auto-dispatch). `language` controla el idioma de la RESPUESTA; `transcription_language` aparte el idioma que Whisper espera al TRANSCRIBIR (\"auto\" = detectar solo).\n / Errors: failures return isError with bilingual text plus structuredContent {code, retryable, httpStatus?} reusing the EnriCode vocabulary (ENRICODE_ERR_TOOL_INPUT_INVALID / EXECUTION_FAILED / EXECUTION_TIMEOUT / EXECUTION_ABORTED); retryable marks 429/5xx/timeouts. If the message carries a `Detalle del servidor:` fragment in the proxy language, report it verbatim. Video frames and transcription share the SAME video timeline. Animated GIF/WebP/APNG/SVG become representative key frames. `model` is the active model id for server-side dispatch affinity (max 128 chars, or env ENRIVISION_MODEL; omit for auto-dispatch). Set `language` (e.g. \"es\") to match the user language and avoid drift: `language` controls the analysis RESPONSE language; `transcription_language` separately controls the language Whisper expects when TRANSCRIBING audio (\"auto\" = detect only)." +
595
+ "\n" +
596
+ "Ejemplos mínimos: (1) una imagen: {\"path\": \"/tmp/foto.png\", \"question\": \"...\"}. (2) clip de video 12:34->754s: {\"path\": \"/tmp/charla.mp4\", \"question\": \"...\", \"video\": {\"clip_start_seconds\": 754, \"clip_duration_seconds\": 30}}. (3) PDF largo multipass: {\"path\": \"/tmp/manual.pdf\", \"question\": \"...\", \"analysis_mode\": \"multipass\"}. Rutas absolutas del host MCP (en Windows valen `C:/...`; en POSIX lanzarían error).\n / Minimal examples: (1) single image: {\"path\": \"/tmp/shot.png\", \"question\": \"What does each capture show?\"}. (2) video clip 12:34->754s: {\"path\": \"/tmp/talk.mp4\", \"question\": \"What happens at 12:34?\", \"video\": {\"clip_start_seconds\": 754, \"clip_duration_seconds\": 30}}. (3) long PDF multipass: {\"path\": \"/tmp/manual.pdf\", \"question\": \"Summarize each chapter.\", \"analysis_mode\": \"multipass\"}. Absolute MCP-host paths (`C:/...` drive paths only work on a Windows host; POSIX rejects them)." +
597
+ "\n" +
598
+ "Continuación: si la respuesta trae has_more con cursor (segment_summaries_cursor o transcription_segments_cursor), pida el resto con solo cursor (+ offset opcional, por defecto next_offset; también `limit` opcional 1-100 para acotar la ventana). Con cursor no mande path/paths. / Continuation: when the response carries has_more with a cursor (segment_summaries_cursor or transcription_segments_cursor), ask for the rest with only cursor (+ optional offset, defaults to next_offset; optional `limit` 1-100 bounds the window); never send path/paths with cursor." +
599
+ "\n" +
600
+ "Depuración de capturas de UI: abra con un veredicto de una línea; describa zona por zona; aproxime colores como hex; cuantifique defectos de layout; transcriba etiquetas, botones y errores visibles; compare observado vs esperado cuando aplique. / UI-screenshot debugging (when the media are app screenshots): open with a one-line plain verdict; describe zone by zone (header, sidebar, main content, modals, notifications), not as a general scene; approximate colors as hex values (e.g. #1F6FEB) and name them; quantify layout defects (overflows, clipping, overlaps, misalignments, missing spacing, cut text) estimating pixel magnitudes when possible; transcribe labels, buttons, and any visible error/status text; when the request states what was expected, compare observed vs expected explicitly.",
127
601
  inputSchema: {
128
602
  type: "object",
129
603
  properties: {
130
604
  path: {
131
605
  type: "string",
132
- description: "Ruta absoluta a un archivo local en la máquina donde corre el servidor MCP (por ejemplo, C:\\\\Users\\\\User\\\\Downloads\\\\video.mp4), o una URL http(s) de imagen/video/audio/PDF para descargar y analizar (hasta 64 MiB; hosts locales y redes privadas bloqueados)."
606
+ description: "Ruta absoluta a un archivo local en la máquina donde corre el servidor MCP (por ejemplo, C:\\\\Users\\\\User\\\\Downloads\\\\video.mp4), o una URL http(s) de imagen/video/audio/PDF para descargar y analizar (hasta 64 MiB; hosts locales y redes privadas bloqueados). Una URL solitaria que excede 64 MiB escala a la ingesta `source_url` del servidor (descarga reanudable del lado de EnriProxy con más hops y techo mayor); los archivos locales usan subida reanudable hasta 4 GiB. Cuando `paths` trae al menos una entrada válida, `path` se ignora. / Absolute local file path on the machine running this MCP server (e.g. C:\\Users\\User\\Downloads\\video.mp4), or one http(s) URL of image/video/audio/PDF to download and analyze (up to 64 MiB; localhost and private networks blocked). A solitary URL above 64 MiB escalates to the server's `source_url` ingestion (resumable server-side download with extra hops and a higher ceiling); local files use resumable upload up to 4 GiB. When `paths` carries at least one valid entry, `path` is ignored."
133
607
  },
134
608
  paths: {
135
609
  type: "array",
136
- description: "Rutas absolutas a varios archivos de imagen locales o URLs http(s) (capturas de UI/sets de fotos; cada URL hasta 64 MiB). Cuando se proporcionan, EnriVision sube un único archivo de conjunto para procesamiento por lotes y reducción del lado servidor.",
610
+ description: "Rutas absolutas a varios archivos de imagen locales o URLs http(s) (capturas de UI/sets de fotos; cada URL hasta 64 MiB). Cuando se proporcionan, EnriVision sube un único archivo de conjunto para procesamiento por lotes y reducción del lado servidor. Las entradas en blanco se descartan. / Absolute local paths to several image files, or http(s) image URLs (UI screenshots/photo sets; each URL up to 64 MiB). When provided, EnriVision uploads a single set archive for server-side batching + reduce.",
137
611
  items: {
138
- type: "string"
612
+ type: "string",
613
+ description: "Una imagen: ruta absoluta local o URL http(s) (hasta 64 MiB; hosts locales y redes privadas bloqueados). / One image: absolute local path or http(s) URL (up to 64 MiB; localhost and private networks blocked)."
139
614
  }
140
615
  },
141
616
  context: {
142
617
  type: "string",
143
- description: "Pista opcional de análisis: ui, diagram, chart, error, code, meeting, tutorial, photo. Déjelo vacío para detección automática."
618
+ description: "Pista opcional de análisis: ui, diagram, chart, error, code, meeting, tutorial, photo. Déjelo vacío para detección automática. Máximo 2000 caracteres; si los excede falla antes de subir. / Optional analysis hint: ui, diagram, chart, error, code, meeting, tutorial, photo. Leave empty for auto-detect. Max 2000 chars; longer fails before upload."
144
619
  },
145
620
  question: {
146
621
  type: "string",
147
- description: "Pregunta explícita opcional que responder sobre el archivo."
622
+ description: "Pregunta explícita opcional que responder sobre el archivo (opcional aquí; en EnriCode vision.analyze_media es obligatoria). Máximo 2000 caracteres; si los excede falla antes de subir. / Optional explicit question to answer about the file (optional here; required in EnriCode vision.analyze_media). Max 2000 chars; longer fails before upload."
148
623
  },
149
624
  language: {
150
625
  type: "string",
151
- description: "Código de idioma preferido de respuesta (ISO 639-1), por ejemplo 'es', 'en'."
626
+ description: "Código de idioma preferido de la RESPUESTA del análisis (ISO 639-1), por ejemplo 'es', 'en'. No afecta la transcripción: para eso use 'transcription_language'. Precedencia: parámetro explícito > ENRIVISION_DEFAULT_LANGUAGE > servidor. / Preferred RESPONSE language code of the analysis (ISO 639-1), e.g. 'es', 'en'. Does not affect transcription: use 'transcription_language' for that. Precedence: explicit param > ENRIVISION_DEFAULT_LANGUAGE > server."
152
627
  },
153
628
  max_frames: {
154
- type: "integer",
155
- description: "Máximo opcional de fotogramas para videos (1-20) en modo single-pass. Para tiempos específicos, prefiera video.clip_start_seconds + video.clip_duration_seconds. Para multipass, use video.max_frames_per_segment."
629
+ type: ["integer", "string"],
630
+ description: "Máximo opcional de fotogramas para videos, entero 1-20 (por defecto 20), en modo 'single' (pasada única). También acepta maxFrames. Para tiempos específicos, prefiera video.clip_start_seconds + video.clip_duration_seconds. Para multipass, use video.max_frames_per_segment. / Optional max frames for videos, integer 1-20 (default 20), in 'single' mode. Also accepts maxFrames; complete integer strings work."
631
+ },
632
+ model: {
633
+ type: "string",
634
+ description: "Id opcional del modelo activo para afinidad de dispatch del lado servidor (incluido el reroute Muse Spark); texto no vacío de máximo 128 caracteres. También acepta el env ENRIVISION_MODEL. Omita para auto-dispatch. / Optional active model id for server-side dispatch affinity (including the Muse Spark reroute); non-empty text, max 128 chars. Also accepts env ENRIVISION_MODEL. Omit for auto-dispatch."
156
635
  },
157
636
  transcribe: {
158
- type: "boolean",
159
- description: "Sobreescritura opcional para activar/desactivar la transcripción de audio en videos."
637
+ type: ["boolean", "string"],
638
+ description: "Sobreescritura opcional para activar/desactivar la transcripción de audio en videos. Acepta true/false y \"true\"/\"false\" (los demás valores se rechazan). / Optional override to enable/disable audio transcription on videos. Accepts true/false and \"true\"/\"false\" (other values are rejected). Has no effect on images/documents (declared in warnings, ignored)."
160
639
  },
161
640
  transcription_language: {
162
641
  type: "string",
163
- description: "Pista opcional de idioma para la transcripción de audio/video (por ejemplo, 'auto', 'es', 'en')."
642
+ description: "También acepta transcriptionLanguage. Pista opcional de idioma que Whisper espera al TRANSCRIBIR el audio/video (por ejemplo, 'auto', 'es', 'en'; 'auto' = detectar solo). No cambia el idioma de la respuesta: para eso use 'language'. / Also accepts transcriptionLanguage. Optional hint for the language Whisper expects when TRANSCRIBING audio/video (e.g. 'auto', 'es', 'en'; 'auto' = detect only). Does not change the response language: use 'language' for that."
643
+ },
644
+ transcriptionLanguage: {
645
+ type: "string",
646
+ description: "Alias de transcription_language (misma pista, misma precedencia). / Alias of transcription_language (same hint, same precedence)."
647
+ },
648
+ maxFrames: {
649
+ type: ["integer", "string"],
650
+ description: "Alias de max_frames (entero 1-20, por defecto 20, modo single). Las strings enteras completas valen. / Alias of max_frames (integer 1-20, default 20, single mode). Complete integer strings work."
164
651
  },
165
652
  analysis_mode: {
166
653
  type: "string",
167
654
  enum: ["auto", "single", "multipass"],
168
- description: "Selector opcional de modo de análisis: auto, single o multipass."
655
+ description: "También acepta analysisMode. Selector opcional de modo de análisis. 'single' = una sola pasada, rápida y barata (1 imagen, preguntas simples). 'multipass' = por segmentos/lotes + reducción (PDFs de más de 20 páginas, videos largos, conjuntos). 'auto' = el servidor elige (prefiere multipass para PDFs de más de 20 páginas). Omita si no sabe cuál usar. / Also accepts analysisMode. Optional analysis-mode selector. 'single' = one pass, fast and cheap (1 image, simple questions). 'multipass' = per-segment/batch + reduce (PDFs over ~20 pages, long videos, sets). 'auto' = the server picks (prefers multipass for PDFs over ~20 pages). Omit if unsure."
656
+ },
657
+ analysisMode: {
658
+ type: "string",
659
+ enum: ["auto", "single", "multipass"],
660
+ description: "Alias de analysis_mode (mismo selector, mismos presupuestos). / Alias of analysis_mode (same selector, same budgets)."
169
661
  },
170
662
  region: {
171
663
  type: "object",
172
- description: "Región relativa de la IMAGEN original para analizar a resolución nativa (zoom). Coordenadas entre 0 y 1; (0,0) es la esquina superior izquierda. Use las cajas devueltas en 'elements' de un análisis previo de la misma imagen: NUNCA invente coordenadas. Ideal para leer texto pequeño (labels, código) que en la imagen completa comprimida resulta ilegible. Sólo imágenes (path, no paths).",
664
+ description: "Región relativa de la IMAGEN original para analizar a resolución nativa (zoom; acepta números y strings numéricas como \"0.1\"). Coordenadas entre 0 y 1; (0,0) es la esquina superior izquierda. Use las cajas devueltas en 'elements' de un análisis previo de la misma imagen: NUNCA invente coordenadas. Ideal para leer texto pequeño (etiquetas, código) que en la imagen completa comprimida resulta ilegible. Regla única: sólo imágenes individuales (`path` o `paths` con un solo elemento); con conjuntos de varias imágenes, video, PDF u otra media no-imagen la llamada se rechaza con error. / Relative REGION of the ORIGINAL image for native-resolution zoom (accepts numbers and numeric strings like \"0.1\"). Coords between 0 and 1; (0,0) is the top-left corner. Use the boxes returned in 'elements' of a previous analysis of the same image: NEVER invent coordinates. Ideal for small text (labels, code) illegible in the compressed full image. Single images only (`path` or single-entry `paths`); multi-image sets, video, PDF, or other non-image media are rejected.",
173
665
  properties: {
174
666
  x: {
175
- type: "number",
176
- description: "Coordenada horizontal relativa de la esquina superior izquierda (0 = borde izquierdo)."
667
+ type: ["number", "string"],
668
+ description: "Coordenada horizontal relativa de la esquina superior izquierda (0 = borde izquierdo). / Relative horizontal coord of the top-left corner (0 = left edge)."
177
669
  },
178
670
  y: {
179
- type: "number",
180
- description: "Coordenada vertical relativa de la esquina superior izquierda (0 = borde superior)."
671
+ type: ["number", "string"],
672
+ description: "Coordenada vertical relativa de la esquina superior izquierda (0 = borde superior). / Relative vertical coord of the top-left corner (0 = top edge)."
181
673
  },
182
674
  width: {
183
- type: "number",
184
- description: "Ancho relativo (1 = ancho completo)."
675
+ type: ["number", "string"],
676
+ description: "Ancho relativo (1 = ancho completo). / Relative width (1 = full width)."
185
677
  },
186
678
  height: {
187
- type: "number",
188
- description: "Alto relativo (1 = alto completo)."
679
+ type: ["number", "string"],
680
+ description: "Alto relativo (1 = alto completo). / Relative height (1 = full height)."
189
681
  }
190
682
  },
191
683
  required: ["x", "y", "width", "height"]
192
684
  },
685
+ cursor: {
686
+ type: "string",
687
+ description: "Cursor opaco de continuación de una respuesta truncada (segment_summaries_cursor o transcription_segments_cursor). Con cursor NO se sube ni analiza nada: solo lee la siguiente ventana de la lista. No se combina con 'path'/'paths'. / Opaque continuation cursor from a truncated response (segment_summaries_cursor or transcription_segments_cursor). With cursor nothing is uploaded or analyzed: it only reads the next window of the list. Cannot be combined with 'path'/'paths'."
688
+ },
689
+ offset: {
690
+ type: "integer",
691
+ description: "Índice inicial de la continuación (entero >= 0; por defecto, el next_offset de la respuesta). / Continuation start index (integer >= 0; defaults to the response next_offset)."
692
+ },
693
+ limit: {
694
+ type: "integer",
695
+ minimum: 1,
696
+ maximum: 100,
697
+ description: "Máximo de entradas a leer en esta continuación (1-100; por defecto el tamaño de ventana del servidor). / Maximum entries to read in this continuation (1-100; defaults to the server window size)."
698
+ },
193
699
  video: {
194
700
  type: "object",
195
- description: "Ajuste opcional de multipass para video. Se usa sólo al analizar videos.",
701
+ description: "Ajuste opcional de multipass para video. Se usa sólo al analizar videos. Dentro de video valen snake_case y camelCase (clip_start_seconds o clipStartSeconds, segment_seconds o segmentSeconds, max_segments o maxSegments, max_frames_per_segment o maxFramesPerSegment), y los planos clipStartSeconds/clipEndSeconds/clipDurationSeconds/segmentSeconds/maxSegments/maxFramesPerSegment valen igual (el plano gana sobre ambos anidados). Sin plano, video.segment_seconds y audio.segment_seconds (o max_segments) con valores distintos se rechazan: use el plano o solo uno de los dos objetos. / Optional multipass tuning for video. Only used when analyzing videos. Inside video both snake_case and camelCase work, and the flat clipStartSeconds/clipEndSeconds/clipDurationSeconds/segmentSeconds/maxSegments/maxFramesPerSegment aliases work the same (flat wins over both nested). Without a flat, differing video.segment_seconds vs audio.segment_seconds (or max_segments) values are rejected: use the flat or only one of the two objects.",
196
702
  properties: {
197
703
  clip_start_seconds: {
198
- type: "number",
199
- description: "Offset opcional de inicio del clip en segundos para análisis de video dirigido a un tiempo."
704
+ type: ["number", "string"],
705
+ description: "Inicio opcional del clip en segundos (0-86400). Para 12:34 use 754 (= 12*60+34). Con clip_end_seconds, fin = inicio + duración. / Optional clip start in seconds (0-86400). For 12:34 use 754 (= 12*60+34). With clip_end_seconds, end = start + duration."
706
+ },
707
+ clip_end_seconds: {
708
+ type: ["number", "string"],
709
+ description: "Fin opcional del clip en segundos (0-86400, debe ser mayor que el inicio). Si se da, la duración se calcula como fin menos inicio e ignora clip_duration_seconds. / Optional clip end in seconds (0-86400, must exceed start). When given, duration derives as end minus start and clip_duration_seconds is ignored."
200
710
  },
201
711
  clip_duration_seconds: {
202
- type: "number",
203
- description: "Duración opcional del clip en segundos para análisis de video dirigido a un tiempo."
712
+ type: ["number", "string"],
713
+ description: "Duración opcional del clip en segundos (mayor que 0, hasta 86400). Úsela junto a clip_start_seconds; si da clip_end_seconds, no la necesita. Si el fin implícito (inicio + duración) excede 86400 segundos, la duración se recorta al límite con un aviso en 'warnings'. / Optional clip duration in seconds (greater than 0, up to 86400). Use with clip_start_seconds; not needed with clip_end_seconds. When start + duration exceeds 86400 s the duration is trimmed to the limit with a 'warnings' note."
204
714
  },
205
715
  segment_seconds: {
206
- type: "number",
207
- description: "Duración del segmento en segundos."
716
+ type: ["number", "string"],
717
+ description: "Duración del segmento en segundos para video (5-600; por defecto 60). / Segment duration in seconds for video (5-600; default 60)."
208
718
  },
209
719
  max_segments: {
210
- type: "integer",
211
- description: "Número máximo de segmentos a analizar."
720
+ type: ["integer", "string"],
721
+ description: "Número máximo de segmentos de video a analizar (entero 1-60; el servidor rechaza valores mayores). / Max video segments to analyze (integer 1-60; the server rejects larger values)."
212
722
  },
213
723
  max_frames_per_segment: {
214
- type: "integer",
215
- description: "Máximo de fotogramas a extraer por segmento."
724
+ type: ["integer", "string"],
725
+ description: "Máximo de fotogramas a extraer por segmento de video (entero 1-20; por defecto 8). / Max frames to extract per video segment (integer 1-20; default 8)."
726
+ },
727
+ clipStartSeconds: {
728
+ type: ["number", "string"],
729
+ description: "Alias de clip_start_seconds (0-86400). El plano clipStartSeconds gana sobre el anidado. / Alias of clip_start_seconds (0-86400). Flat clipStartSeconds wins over nested."
730
+ },
731
+ clipEndSeconds: {
732
+ type: ["number", "string"],
733
+ description: "Alias de clip_end_seconds (0-86400, debe ser mayor que el inicio). El plano gana sobre el anidado. / Alias of clip_end_seconds (0-86400, must exceed start). Flat wins over nested."
734
+ },
735
+ clipDurationSeconds: {
736
+ type: ["number", "string"],
737
+ description: "Alias de clip_duration_seconds (mayor que 0, hasta 86400). El plano gana sobre el anidado. / Alias of clip_duration_seconds (greater than 0, up to 86400). Flat wins over nested."
738
+ },
739
+ segmentSeconds: {
740
+ type: ["number", "string"],
741
+ description: "Alias de segment_seconds para video (5-600; por defecto 60). El plano gana sobre el anidado. / Alias of segment_seconds for video (5-600; default 60). Flat wins over nested."
742
+ },
743
+ maxSegments: {
744
+ type: ["integer", "string"],
745
+ description: "Alias de max_segments para video (entero 1-60). El plano gana sobre el anidado. / Alias of max_segments for video (integer 1-60). Flat wins over nested."
746
+ },
747
+ maxFramesPerSegment: {
748
+ type: ["integer", "string"],
749
+ description: "Alias de max_frames_per_segment (entero 1-20; por defecto 8). El plano gana sobre el anidado. / Alias of max_frames_per_segment (integer 1-20; default 8). Flat wins over nested."
216
750
  }
217
751
  }
218
752
  },
219
753
  document: {
220
754
  type: "object",
221
- description: "Ajuste opcional de multipass para documentos (PDF).",
755
+ description: "Ajuste opcional de multipass para documentos (PDF). Dentro de document valen snake_case, camelCase y los legados max_pages/maxPages/documentMaxPages/document_max_pages. Los planos documentMaxPages/document_max_pages valen igual que document.max_pages_total (el plano gana). / Optional multipass tuning for documents (PDF). Inside document snake_case, camelCase, and legacy max_pages/maxPages/documentMaxPages/document_max_pages work. The flat documentMaxPages/document_max_pages aliases equal document.max_pages_total (flat wins).",
222
756
  properties: {
223
757
  max_pages_total: {
224
- type: "integer",
225
- description: "Número máximo de páginas a analizar en total."
758
+ type: ["integer", "string"],
759
+ description: "Número máximo de páginas a analizar en total (entero 1-200; por defecto 20). Más páginas = más costo y tiempo; omita para pocas páginas. / Max pages to analyze in total (integer 1-200; default 20). More pages = more cost and time; omit for few pages."
226
760
  },
227
761
  pages_per_batch: {
228
- type: "integer",
229
- description: "Páginas por lote para las llamadas map de multipass."
762
+ type: ["integer", "string"],
763
+ description: "Páginas por lote para las llamadas map de multipass (entero 1-200). Lotes chicos = más llamadas pero menos memoria; omita para el valor del servidor. / Pages per batch for multipass map calls (integer 1-200). Smaller batches = more calls but less memory; omit for the server value."
230
764
  },
231
765
  max_images_per_batch: {
232
- type: "integer",
233
- description: "Máximo de páginas renderizadas (imágenes) por lote."
766
+ type: ["integer", "string"],
767
+ description: "Máximo de páginas renderizadas (imágenes) por lote (entero 0-20; 0 = sin render). Más imágenes = más costo de visión; omita para el valor del servidor. / Max rendered (image) pages per batch (integer 0-20; 0 = no render). More images = more vision cost; omit for the server value."
234
768
  },
235
769
  scanned_text_threshold_chars: {
236
- type: "integer",
237
- description: "Longitud mínima de texto extraído para tratar una página como textual."
770
+ type: ["integer", "string"],
771
+ description: "Longitud mínima de texto extraído para tratar una página como textual en vez de escaneada (entero 0-5000). Sólo afecta el enrutamiento texto-vs-visión; omita para el valor del servidor. / Min extracted-text length to treat a page as textual instead of scanned (integer 0-5000). Only affects text-vs-vision routing; omit for the server value."
772
+ },
773
+ maxPagesTotal: {
774
+ type: ["integer", "string"],
775
+ description: "Alias de max_pages_total (entero 1-200; por defecto 20). El plano gana sobre el anidado. / Alias of max_pages_total (integer 1-200; default 20). Flat wins over nested."
776
+ },
777
+ max_pages: {
778
+ type: ["integer", "string"],
779
+ description: "Alias legado de max_pages_total (entero 1-200). El plano gana sobre el anidado. / Legacy alias of max_pages_total (integer 1-200). Flat wins over nested."
780
+ },
781
+ maxPages: {
782
+ type: ["integer", "string"],
783
+ description: "Alias legado de max_pages_total (entero 1-200). El plano gana sobre el anidado. / Legacy alias of max_pages_total (integer 1-200). Flat wins over nested."
784
+ },
785
+ documentMaxPages: {
786
+ type: ["integer", "string"],
787
+ description: "Alias legado de max_pages_total (entero 1-200). El plano gana sobre el anidado. / Legacy alias of max_pages_total (integer 1-200). Flat wins over nested."
788
+ },
789
+ document_max_pages: {
790
+ type: ["integer", "string"],
791
+ description: "Alias legado de max_pages_total (entero 1-200). El plano gana sobre el anidado. / Legacy alias of max_pages_total (integer 1-200). Flat wins over nested."
792
+ },
793
+ pagesPerBatch: {
794
+ type: ["integer", "string"],
795
+ description: "Alias de pages_per_batch (entero 1-200). / Alias of pages_per_batch (integer 1-200)."
796
+ },
797
+ maxImagesPerBatch: {
798
+ type: ["integer", "string"],
799
+ description: "Alias de max_images_per_batch (entero 0-20; 0 = sin render). / Alias of max_images_per_batch (integer 0-20; 0 = no render)."
800
+ },
801
+ scannedTextThresholdChars: {
802
+ type: ["integer", "string"],
803
+ description: "Alias de scanned_text_threshold_chars (entero 0-5000). / Alias of scanned_text_threshold_chars (integer 0-5000)."
238
804
  }
239
805
  }
240
806
  },
241
807
  audio: {
242
808
  type: "object",
243
- description: "Ajuste opcional de multipass para audio (se usa sólo al analizar archivos de audio).",
809
+ description: "Ajuste opcional de multipass para audio (se usa sólo al analizar archivos de audio). Dentro de audio valen timestamps, audioTimestamps o audio_timestamps, segment_seconds o segmentSeconds, max_segments o maxSegments, y los planos audioTimestamps/audio_timestamps/segmentSeconds/segment_seconds/maxSegments/max_segments valen igual (el plano gana sobre ambos anidados). Sin plano, valores distintos entre video y audio para el mismo knob se rechazan. timestamps acepta true/false y \"true\"/\"false\". / Optional multipass tuning for audio (only used when analyzing audio files). Inside audio timestamps, audioTimestamps, or audio_timestamps work, as do segment_seconds/segmentSeconds and max_segments/maxSegments; flat aliases work the same (flat wins over both nested). Without a flat, differing video vs audio values for the same knob are rejected. timestamps accepts true/false and \"true\"/\"false\".",
244
810
  properties: {
245
811
  timestamps: {
246
- type: "boolean",
247
- description: "Si incluir segmentos con marca de tiempo en la extracción de audio."
812
+ type: ["boolean", "string"],
813
+ description: "Si incluir segmentos con marca de tiempo en la extracción de audio. / Whether to include timestamped segments in the audio extraction."
248
814
  },
249
815
  segment_seconds: {
250
- type: "number",
251
- description: "Duración del segmento en segundos para multipass de audio."
816
+ type: ["number", "string"],
817
+ description: "Duración del segmento en segundos para multipass de audio (5-600; por defecto 60). / Segment duration in seconds for audio multipass (5-600; default 60)."
252
818
  },
253
819
  max_segments: {
254
- type: "integer",
255
- description: "Número máximo de segmentos de audio a analizar."
820
+ type: ["integer", "string"],
821
+ description: "Número máximo de segmentos de audio a analizar (entero 1-60; el servidor rechaza valores mayores). / Max audio segments to analyze (integer 1-60; the server rejects larger values)."
822
+ },
823
+ audioTimestamps: {
824
+ type: ["boolean", "string"],
825
+ description: "Alias de timestamps (acepta true/false y \"true\"/\"false\"). El plano gana sobre el anidado. / Alias of timestamps (accepts true/false and \"true\"/\"false\"). Flat wins over nested."
826
+ },
827
+ audio_timestamps: {
828
+ type: ["boolean", "string"],
829
+ description: "Alias de timestamps (solo audio). El plano gana sobre el anidado. / Alias of timestamps (audio only). Flat wins over nested."
830
+ },
831
+ segmentSeconds: {
832
+ type: ["number", "string"],
833
+ description: "Alias de segment_seconds para audio (5-600; por defecto 60). El plano gana sobre el anidado. / Alias of segment_seconds for audio (5-600; default 60). Flat wins over nested."
834
+ },
835
+ maxSegments: {
836
+ type: ["integer", "string"],
837
+ description: "Alias de max_segments para audio (entero 1-60). El plano gana sobre el anidado. / Alias of max_segments for audio (integer 1-60). Flat wins over nested."
256
838
  }
257
839
  }
258
840
  },
259
841
  images: {
260
842
  type: "object",
261
- description: "Ajuste opcional de multipass para conjuntos de imágenes (se usa sólo con `paths`).",
843
+ description: "Ajuste opcional de multipass para conjuntos de imágenes (se usa sólo con `paths`). Dentro de images valen snake_case y camelCase (max_images_total o maxImagesTotal, images_per_batch o imagesPerBatch, max_dimension o maxDimension). / Optional multipass tuning for image sets (only used with `paths`). Inside images snake_case and camelCase work.",
262
844
  properties: {
263
845
  max_images_total: {
264
- type: "integer",
265
- description: "Número máximo de imágenes a analizar en total."
846
+ type: ["integer", "string"],
847
+ description: "Número máximo de imágenes del conjunto a analizar (entero 1-500). Más imágenes = más costo y tiempo; omita para analizarlas todas. / Max set images to analyze (integer 1-500). More images = more cost and time; omit to analyze all."
266
848
  },
267
849
  images_per_batch: {
268
- type: "integer",
269
- description: "Imágenes por lote para las llamadas map de multipass."
850
+ type: ["integer", "string"],
851
+ description: "Imágenes por lote para las llamadas map de multipass (entero 1-20). Lotes chicos = más llamadas pero menos memoria; omita para el valor del servidor. / Images per batch for multipass map calls (integer 1-20). Smaller batches = more calls but less memory; omit for the server value."
270
852
  },
271
853
  max_dimension: {
272
- type: "integer",
273
- description: "Dimensión máxima para las imágenes (ancho/alto)."
854
+ type: ["integer", "string"],
855
+ description: "Dimensión máxima de cada imagen en píxeles, ancho/alto (entero 256-4096). Valores grandes = más detalle y más costo; omita para el valor del servidor. / Max image dimension in pixels, width/height (integer 256-4096). Larger values = more detail and more cost; omit for the server value."
856
+ },
857
+ maxImagesTotal: {
858
+ type: ["integer", "string"],
859
+ description: "Alias de max_images_total (entero 1-500). / Alias of max_images_total (integer 1-500)."
860
+ },
861
+ imagesPerBatch: {
862
+ type: ["integer", "string"],
863
+ description: "Alias de images_per_batch (entero 1-20). / Alias of images_per_batch (integer 1-20)."
864
+ },
865
+ maxDimension: {
866
+ type: ["integer", "string"],
867
+ description: "Alias de max_dimension (entero 256-4096). / Alias of max_dimension (integer 256-4096)."
274
868
  }
275
869
  }
870
+ },
871
+ segmentSeconds: {
872
+ type: ["number", "string"],
873
+ description: "Atajo plano de segment_seconds (5-600 s; también vale segment_seconds). Sin objetos video/audio alimenta a ambos y el servidor aplica el que corresponda; con un solo objeto alimenta a ese; con ambos y sin plano, valores distintos se rechazan. El plano gana sobre ambos anidados. / Flat shortcut for segment_seconds (5-600 s; segment_seconds also works). Without video/audio objects it feeds both and the server applies the matching one; with one object it feeds that one; with both and differing values (no flat) it is rejected. Flat wins over both nested."
874
+ },
875
+ segment_seconds: {
876
+ type: ["number", "string"],
877
+ description: "Alias plano de segmentSeconds (5-600 s). El plano gana sobre video.segment_seconds y audio.segment_seconds. / Flat alias for segmentSeconds (5-600 s). Flat wins over video.segment_seconds and audio.segment_seconds."
878
+ },
879
+ maxSegments: {
880
+ type: ["integer", "string"],
881
+ description: "Atajo plano de max_segments (entero 1-60; también vale max_segments). Misma precedencia que segmentSeconds: sin objetos alimenta a ambos, con uno alimenta a ese, con ambos distintos sin plano se rechaza. El plano gana. / Flat shortcut for max_segments (integer 1-60; max_segments also works). Same precedence as segmentSeconds. Flat wins."
882
+ },
883
+ max_segments: {
884
+ type: ["integer", "string"],
885
+ description: "Alias plano de maxSegments (entero 1-60). El plano gana sobre video.max_segments y audio.max_segments. / Flat alias for maxSegments (integer 1-60). Flat wins over video.max_segments and audio.max_segments."
886
+ },
887
+ maxFramesPerSegment: {
888
+ type: ["integer", "string"],
889
+ description: "Atajo plano de video.max_frames_per_segment (entero 1-20; también vale max_frames_per_segment). Solo aplica a video; con audio se rechaza. El plano gana sobre el anidado. / Flat shortcut for video.max_frames_per_segment (integer 1-20; max_frames_per_segment also works). Video only; rejected with audio. Flat wins over nested."
890
+ },
891
+ max_frames_per_segment: {
892
+ type: ["integer", "string"],
893
+ description: "Alias plano de maxFramesPerSegment (entero 1-20, solo video). El plano gana sobre el anidado. / Flat alias for maxFramesPerSegment (integer 1-20, video only). Flat wins over nested."
894
+ },
895
+ audioTimestamps: {
896
+ type: ["boolean", "string"],
897
+ description: "Atajo plano de audio.timestamps (también vale audio_timestamps; acepta true/false y \"true\"/\"false\"). Solo aplica a audio. El plano gana sobre el anidado. / Flat shortcut for audio.timestamps (audio_timestamps also works; accepts true/false and \"true\"/\"false\"). Audio only. Flat wins over nested."
898
+ },
899
+ audio_timestamps: {
900
+ type: ["boolean", "string"],
901
+ description: "Alias plano de audioTimestamps (solo audio). El plano gana sobre el anidado. / Flat alias for audioTimestamps (audio only). Flat wins over nested."
902
+ },
903
+ documentMaxPages: {
904
+ type: ["integer", "string"],
905
+ description: "Atajo plano de document.max_pages_total (entero 1-200; también vale document_max_pages). Solo aplica a documentos. El plano gana sobre el anidado. / Flat shortcut for document.max_pages_total (integer 1-200; document_max_pages also works). Documents only. Flat wins over nested."
906
+ },
907
+ document_max_pages: {
908
+ type: ["integer", "string"],
909
+ description: "Alias plano de documentMaxPages (entero 1-200, solo documentos). El plano gana sobre el anidado. / Flat alias for documentMaxPages (integer 1-200, documents only). Flat wins over nested."
910
+ },
911
+ clipStartSeconds: {
912
+ type: ["number", "string"],
913
+ description: "Atajo plano de video.clip_start_seconds (0-86400 s; también vale clip_start_seconds). Para 12:34 use 754. El plano gana sobre el anidado. / Flat shortcut for video.clip_start_seconds (0-86400 s; clip_start_seconds also works). For 12:34 use 754. Flat wins over nested."
914
+ },
915
+ clip_start_seconds: {
916
+ type: ["number", "string"],
917
+ description: "Alias plano de clipStartSeconds (0-86400 s). El plano gana sobre el anidado. / Flat alias for clipStartSeconds (0-86400 s). Flat wins over nested."
918
+ },
919
+ clipEndSeconds: {
920
+ type: ["number", "string"],
921
+ description: "Atajo plano de video.clip_end_seconds (0-86400 s, mayor que el inicio; también vale clip_end_seconds). La duración se calcula como fin menos inicio. El plano gana sobre el anidado. / Flat shortcut for video.clip_end_seconds (0-86400 s, greater than start; clip_end_seconds also works). Duration derives as end minus start. Flat wins over nested."
922
+ },
923
+ clip_end_seconds: {
924
+ type: ["number", "string"],
925
+ description: "Alias plano de clipEndSeconds (0-86400 s). El plano gana sobre el anidado. / Flat alias for clipEndSeconds (0-86400 s). Flat wins over nested."
926
+ },
927
+ clipDurationSeconds: {
928
+ type: ["number", "string"],
929
+ description: "Atajo plano de video.clip_duration_seconds (mayor que 0, hasta 86400 s; también vale clip_duration_seconds). Úselo junto a clipStartSeconds. El plano gana sobre el anidado. / Flat shortcut for video.clip_duration_seconds (greater than 0, up to 86400 s; clip_duration_seconds also works). Use with clipStartSeconds. Flat wins over nested."
930
+ },
931
+ clip_duration_seconds: {
932
+ type: ["number", "string"],
933
+ description: "Alias plano de clipDurationSeconds (hasta 86400 s). El plano gana sobre el anidado. / Flat alias for clipDurationSeconds (up to 86400 s). Flat wins over nested."
934
+ }
935
+ },
936
+ anyOf: [{ required: ["path"] }, { required: ["paths"] }, { required: ["cursor"] }]
937
+ },
938
+ outputSchema: {
939
+ type: "object",
940
+ properties: {
941
+ analysis: {
942
+ type: "string",
943
+ description: "Análisis en texto producido por EnriProxy. / Text analysis produced by EnriProxy."
944
+ },
945
+ elements: {
946
+ type: "array",
947
+ description: "Cajas de elementos detectados en análisis de imagen (coordenadas relativas 0-1, reutilizables como `region`). / Detected element boxes in image analyses (relative 0-1 coords, reusable as `region`).",
948
+ items: {
949
+ type: "object",
950
+ properties: {
951
+ label: { type: "string" },
952
+ box: {
953
+ type: "object",
954
+ properties: {
955
+ x: { type: "number" },
956
+ y: { type: "number" },
957
+ width: { type: "number" },
958
+ height: { type: "number" }
959
+ },
960
+ required: ["x", "y", "width", "height"]
961
+ }
962
+ },
963
+ required: ["label", "box"]
964
+ }
965
+ },
966
+ media_type: {
967
+ type: "string",
968
+ description: "Tipo de media detectado. / Detected media type."
969
+ },
970
+ warnings: {
971
+ type: "array",
972
+ description: "Avisos de honestidad bilingües (por ejemplo, ventana de clip recortada al límite de 24 h). Solo presente cuando el parseo ajustó un valor pedido. / Honesty warnings (bilingual, e.g. clip window trimmed to the 24 h limit). Only present when parsing adjusted a requested value.",
973
+ items: { type: "string" }
974
+ },
975
+ extraction: {
976
+ type: "object",
977
+ description: "Metadatos de extracción devueltos por el servidor (sin identificadores internos). Las strings muy largas se recortan principio+fin con el marcador […truncado…]; la forma del objeto se preserva. / Extraction metadata returned by the server (no internal ids). Very long strings are head+tail trimmed with a […truncated…] marker; object shape is preserved."
978
+ },
979
+ analysis_truncated: {
980
+ type: "boolean",
981
+ description: "`true` cuando `analysis` se truncó al tope de `structuredContent` (262144 caracteres en puntos de código; se conservan principio y fin). / Flag that is true when `analysis` was truncated to the `structuredContent` cap (262144 chars in code points; head and tail kept)."
982
+ },
983
+ analysis_total_chars: {
984
+ type: "integer",
985
+ description: "Total de caracteres (puntos de código) del análisis completo antes de truncar. / Total chars (code points) of the full analysis before truncation."
276
986
  }
277
987
  },
278
- anyOf: [{ required: ["path"] }, { required: ["paths"] }]
988
+ required: ["analysis", "media_type", "extraction"]
279
989
  }
280
990
  };
281
991
  }