@bedolla/enrivision 0.1.4 → 0.1.6
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 +31 -6
- package/dist/client/EnriProxyClient.d.ts +289 -244
- package/dist/client/EnriProxyClient.d.ts.map +1 -1
- package/dist/client/EnriProxyClient.js +841 -115
- package/dist/client/EnriProxyClient.js.map +1 -1
- package/dist/client/EnriProxyClientContract.d.ts +425 -0
- package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
- package/dist/client/EnriProxyClientContract.js +87 -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 +780 -93
- 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 +457 -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 +299 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.js +824 -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 +155 -294
- package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
- package/dist/tools/AnalyzeMediaTool.js +600 -457
- package/dist/tools/AnalyzeMediaTool.js.map +1 -1
- package/package.json +2 -1
|
@@ -12,38 +12,60 @@
|
|
|
12
12
|
import { request as httpRequest } from "node:http";
|
|
13
13
|
import { request as httpsRequest } from "node:https";
|
|
14
14
|
import { URL } from "node:url";
|
|
15
|
+
import { buildClipWindowClampedWarning, optionalFraction, optionalInt, optionalNumber } from "../shared/validation.js";
|
|
16
|
+
import { AUDIO_KNOWN_KEYS, DOCUMENT_KNOWN_KEYS, IMAGES_KNOWN_KEYS, VIDEO_KNOWN_KEYS, } from "../tools/AnalyzeMediaParamParser.js";
|
|
17
|
+
import { EnriProxyHttpError, extractServerErrorDetail, } from "./EnriProxyClientContract.js";
|
|
18
|
+
export { EnriProxyHttpError } from "./EnriProxyClientContract.js";
|
|
15
19
|
/**
|
|
16
|
-
*
|
|
20
|
+
* Maximum clip-window bound in seconds (0-86400, 24 h).
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Mirrors `ANALYZE_MEDIA_LIMITS.maxClipSeconds` and the
|
|
24
|
+
* `AnalyzeMediaParamParser` clip contract so the client never silently
|
|
25
|
+
* coerces out-of-range windows: invalid values throw in Spanish instead.
|
|
17
26
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
27
|
+
const MAX_CLIP_SECONDS = 86400;
|
|
28
|
+
/**
|
|
29
|
+
* Maximum `source_url` characters the proxy ingests (EnriProxy
|
|
30
|
+
* `VISION_MAX_SOURCE_URL_CHARS`): longer URLs fail in the client backstop,
|
|
31
|
+
* before any byte travels, instead of failing at the server after cost.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_SOURCE_URL_CHARS = 2048;
|
|
34
|
+
/**
|
|
35
|
+
* Timeout for upload session creation (`POST /v1/uploads`, 60 s).
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Mirrors EnriCode `VisionAnalyzeMediaUploadCoordinator` (60 s create
|
|
39
|
+
* budget): session creation is a tiny metadata call, but it deserves more
|
|
40
|
+
* headroom than offset probes. Capped by the operator timeout via `Math.min`.
|
|
41
|
+
*/
|
|
42
|
+
export const CREATE_CONTROL_TIMEOUT_MS = 60_000;
|
|
43
|
+
/**
|
|
44
|
+
* Timeout for upload offset probes (`HEAD /v1/uploads/:id`, 15 s).
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* Mirrors EnriCode (15 s probe budget): offset queries are single-header
|
|
48
|
+
* reads and must fail fast. Capped by the operator timeout via `Math.min`.
|
|
49
|
+
*/
|
|
50
|
+
export const PROBE_CONTROL_TIMEOUT_MS = 15_000;
|
|
51
|
+
/**
|
|
52
|
+
* Budget for the fail-open vision-capability probe (`GET /v1/account/models`, 15 s).
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* Mirrors EnriCode `assertRemoteVisionCapable` and
|
|
56
|
+
* `ANALYZE_MEDIA_LIMITS.visionProbeTimeoutMs` (pinned equal by tests): the
|
|
57
|
+
* probe must never stall session creation.
|
|
58
|
+
*/
|
|
59
|
+
export const ACCOUNT_MODELS_PROBE_TIMEOUT_MS = 15_000;
|
|
60
|
+
/**
|
|
61
|
+
* Timeout for best-effort orphan cleanup (`DELETE /v1/uploads/:id`).
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* The cleanup call runs on an independent signal (the caller may already be
|
|
65
|
+
* cancelled), so it carries its own short budget and never blocks the
|
|
66
|
+
* error path.
|
|
67
|
+
*/
|
|
68
|
+
const CLEANUP_TIMEOUT_MS = 15_000;
|
|
47
69
|
/**
|
|
48
70
|
* Minimal client for EnriProxy HTTP endpoints.
|
|
49
71
|
*/
|
|
@@ -75,6 +97,7 @@ export class EnriProxyClient {
|
|
|
75
97
|
*
|
|
76
98
|
* @param params - Session parameters
|
|
77
99
|
* @returns Session response
|
|
100
|
+
* @throws Error with the parsed server detail when creation fails, or when the success body is not valid JSON.
|
|
78
101
|
*/
|
|
79
102
|
async createUploadSession(params) {
|
|
80
103
|
const url = this.buildUrl("/v1/uploads");
|
|
@@ -84,34 +107,79 @@ export class EnriProxyClient {
|
|
|
84
107
|
content_type: params.contentType,
|
|
85
108
|
client_trace_id: params.clientTraceId
|
|
86
109
|
};
|
|
87
|
-
const result = await this.requestJson("POST", url, payload, this.
|
|
110
|
+
const result = await this.requestJson("POST", url, payload, this.createControlTimeoutMs(), params.signal);
|
|
88
111
|
if (result.status < 200 || result.status >= 300) {
|
|
89
|
-
throw
|
|
112
|
+
throw EnriProxyClient.buildHttpError(`Falló la creación de la sesión de subida (HTTP ${result.status}). / Upload session creation failed (HTTP ${result.status}).`, result);
|
|
90
113
|
}
|
|
91
|
-
|
|
92
|
-
return parsed;
|
|
114
|
+
return EnriProxyClient.parseJsonBody(result.body, result.status);
|
|
93
115
|
}
|
|
94
116
|
/**
|
|
95
117
|
* Queries the current upload offset for a session.
|
|
96
118
|
*
|
|
97
119
|
* @param uploadId - Upload id
|
|
120
|
+
* @param signal - Optional cancellation signal.
|
|
98
121
|
* @returns Offset in bytes
|
|
122
|
+
* @throws Error with the parsed server detail when the query fails.
|
|
99
123
|
*/
|
|
100
|
-
async getUploadOffset(uploadId) {
|
|
124
|
+
async getUploadOffset(uploadId, signal) {
|
|
101
125
|
const url = this.buildUrl(`/v1/uploads/${encodeURIComponent(uploadId)}`);
|
|
102
|
-
const result = await this.requestRaw("HEAD", url, undefined, undefined, this.
|
|
126
|
+
const result = await this.requestRaw("HEAD", url, undefined, undefined, this.probeControlTimeoutMs(), signal);
|
|
103
127
|
if (result.status < 200 || result.status >= 300) {
|
|
104
|
-
throw
|
|
128
|
+
throw EnriProxyClient.buildHttpError(`Falló la consulta del offset de subida (HTTP ${result.status}). / Upload offset query failed (HTTP ${result.status}).`, result);
|
|
105
129
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
130
|
+
return this.parseUploadOffsetHeader(result.headers);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Deletes an upload session and its stored bytes (best-effort orphan cleanup).
|
|
134
|
+
*
|
|
135
|
+
* @remarks
|
|
136
|
+
* Mirrors `DELETE /v1/uploads/:id` on EnriProxy. Callers must invoke this
|
|
137
|
+
* with an independent timeout signal (never the already-cancelled caller
|
|
138
|
+
* signal) and swallow failures: cleanup must never mask the original
|
|
139
|
+
* upload/analysis error.
|
|
140
|
+
*
|
|
141
|
+
* @param uploadId - Upload id to delete.
|
|
142
|
+
* @param signal - Optional cancellation signal (prefer an independent timeout).
|
|
143
|
+
* @throws Error with an Spanish-first bilingual message when the server rejects the deletion.
|
|
144
|
+
*/
|
|
145
|
+
async deleteUploadSession(uploadId, signal) {
|
|
146
|
+
const url = this.buildUrl(`/v1/uploads/${encodeURIComponent(uploadId)}`);
|
|
147
|
+
const result = await this.requestRaw("DELETE", url, undefined, undefined, CLEANUP_TIMEOUT_MS, signal);
|
|
148
|
+
if (result.status < 200 || result.status >= 300) {
|
|
149
|
+
throw EnriProxyClient.buildHttpError(`Falló la eliminación de la sesión de subida (HTTP ${result.status}). / Upload session deletion failed (HTTP ${result.status}).`, result);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Fetches the account model catalog for the fail-open vision probe.
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* Thin `GET /v1/account/models` reader for `AnalyzeMediaTool` (mirrors
|
|
157
|
+
* EnriCode `assertRemoteVisionCapable`): callers treat every failure as
|
|
158
|
+
* fail-open (proceed with the upload) and only reject on an explicit
|
|
159
|
+
* `vision === false` match, so a stale discovery snapshot never blocks a
|
|
160
|
+
* valid upload. Non-2xx responses throw {@link EnriProxyHttpError} (the
|
|
161
|
+
* caller uses the status to invalidate cached verdicts on 401/404);
|
|
162
|
+
* non-JSON 2xx bodies throw an Spanish-first bilingual error.
|
|
163
|
+
*
|
|
164
|
+
* @param signal - Optional cancellation signal.
|
|
165
|
+
* @returns Parsed response body.
|
|
166
|
+
* @throws Error with an Spanish-first bilingual message when the probe request fails.
|
|
167
|
+
*/
|
|
168
|
+
async getAccountModels(signal) {
|
|
169
|
+
const url = this.buildUrl("/v1/account/models");
|
|
170
|
+
const timeoutMs = Number.isFinite(this.timeoutMs) && this.timeoutMs > 0
|
|
171
|
+
? Math.min(this.timeoutMs, ACCOUNT_MODELS_PROBE_TIMEOUT_MS)
|
|
172
|
+
: ACCOUNT_MODELS_PROBE_TIMEOUT_MS;
|
|
173
|
+
const result = await this.requestRaw("GET", url, undefined, undefined, timeoutMs, signal);
|
|
174
|
+
if (result.status < 200 || result.status >= 300) {
|
|
175
|
+
throw EnriProxyClient.buildHttpError(`Falló la consulta de modelos de la cuenta (HTTP ${result.status}). / Account models query failed (HTTP ${result.status}).`, result);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(result.body);
|
|
109
179
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
throw new Error(`Invalid Upload-Offset header: ${offsetHeader}`);
|
|
180
|
+
catch {
|
|
181
|
+
throw new Error(`La respuesta del servidor no es JSON válido (HTTP ${String(result.status)}). / Server response is not valid JSON (HTTP ${String(result.status)}).`);
|
|
113
182
|
}
|
|
114
|
-
return offset;
|
|
115
183
|
}
|
|
116
184
|
/**
|
|
117
185
|
* Appends a chunk to an upload session.
|
|
@@ -127,78 +195,113 @@ export class EnriProxyClient {
|
|
|
127
195
|
"Upload-Offset": String(params.offset),
|
|
128
196
|
"Content-Length": String(params.chunk.length)
|
|
129
197
|
};
|
|
130
|
-
const result = await this.requestRaw("PATCH", url, headers, params.chunk, timeoutMs);
|
|
198
|
+
const result = await this.requestRaw("PATCH", url, headers, params.chunk, timeoutMs, params.signal);
|
|
131
199
|
if (result.status < 200 || result.status >= 300) {
|
|
132
|
-
throw
|
|
200
|
+
throw EnriProxyClient.buildHttpError(`Falló la subida del fragmento (HTTP ${result.status}). / Chunk upload failed (HTTP ${result.status}).`, result);
|
|
133
201
|
}
|
|
134
202
|
const offsetHeader = this.getHeaderValue(result.headers, "upload-offset");
|
|
135
203
|
if (!offsetHeader) {
|
|
136
|
-
throw new Error("Missing Upload-Offset header in server response.");
|
|
204
|
+
throw new Error("Falta el encabezado Upload-Offset en la respuesta del servidor. / Missing Upload-Offset header in the server response.");
|
|
137
205
|
}
|
|
138
|
-
|
|
139
|
-
if (!Number.isFinite(newOffset) || newOffset < 0) {
|
|
140
|
-
throw new Error(`Invalid Upload-Offset header: ${offsetHeader}`);
|
|
141
|
-
}
|
|
142
|
-
return newOffset;
|
|
206
|
+
return this.parseUploadOffsetHeader(result.headers);
|
|
143
207
|
}
|
|
144
208
|
/**
|
|
145
209
|
* Triggers server-side vision analysis for an uploaded file.
|
|
146
210
|
*
|
|
147
211
|
* @param params - Analysis parameters
|
|
148
212
|
* @returns Analysis response
|
|
213
|
+
* @throws Error with the parsed server detail when analysis fails, or when the success body is not valid JSON.
|
|
149
214
|
*/
|
|
150
215
|
async analyze(params) {
|
|
151
216
|
const url = this.buildUrl("/v1/vision/analyze");
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
217
|
+
const localWarnings = [];
|
|
218
|
+
// Pre-upload guards mirroring AnalyzeMediaParamParser: typos must fail
|
|
219
|
+
// here, not after paying a full-file upload plus server analysis.
|
|
220
|
+
EnriProxyClient.requirePreUploadTuning(params);
|
|
221
|
+
const uploadId = typeof params.uploadId === "string" && params.uploadId.trim().length > 0 ? params.uploadId.trim() : undefined;
|
|
222
|
+
const sourceUrl = typeof params.sourceUrl === "string" && params.sourceUrl.trim().length > 0 ? params.sourceUrl.trim() : undefined;
|
|
223
|
+
if ((uploadId === undefined) === (sourceUrl === undefined)) {
|
|
224
|
+
throw new Error("Proporcione exactamente uno de 'uploadId' o 'sourceUrl'. / Provide exactly one of 'uploadId' or 'sourceUrl'.");
|
|
225
|
+
}
|
|
226
|
+
if (typeof sourceUrl === "string" && Array.from(sourceUrl).length > MAX_SOURCE_URL_CHARS) {
|
|
227
|
+
throw new Error(`sourceUrl 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. / sourceUrl exceeds the ${String(MAX_SOURCE_URL_CHARS)}-char server ingest limit (EnriProxy VISION_MAX_SOURCE_URL_CHARS); use a shorter URL.`);
|
|
228
|
+
}
|
|
229
|
+
// Fail fast on prompt budgets the server 400s after upload cost
|
|
230
|
+
// (mirrors AnalyzeMediaParamParser + EnriCode request records).
|
|
231
|
+
EnriProxyClient.requireBoundedPromptText(params.question, "question");
|
|
232
|
+
EnriProxyClient.requireBoundedPromptText(params.context, "context");
|
|
233
|
+
if (typeof params.model === "string" && Array.from(params.model.trim()).length > 128) {
|
|
234
|
+
throw new Error("model excede el máximo de 128 caracteres. / model exceeds the 128-char maximum.");
|
|
235
|
+
}
|
|
236
|
+
const payload = uploadId !== undefined ? { upload_id: uploadId } : { source_url: sourceUrl };
|
|
237
|
+
if (typeof params.model === "string" && params.model.trim()) {
|
|
238
|
+
payload["model"] = params.model.trim();
|
|
239
|
+
}
|
|
155
240
|
if (typeof params.context === "string" && params.context.trim())
|
|
156
241
|
payload["context"] = params.context.trim();
|
|
157
242
|
if (typeof params.question === "string" && params.question.trim())
|
|
158
243
|
payload["question"] = params.question.trim();
|
|
159
244
|
if (typeof params.language === "string" && params.language.trim())
|
|
160
245
|
payload["language"] = params.language.trim();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
246
|
+
const maxFrames = EnriProxyClient.requireOptionalInt(params.maxFrames, "max_frames", 1, 20);
|
|
247
|
+
if (typeof maxFrames !== "undefined") {
|
|
248
|
+
payload["max_frames"] = maxFrames;
|
|
249
|
+
}
|
|
250
|
+
// EnriCode always sends an explicit `request.transcribe ?? true`: omitting
|
|
251
|
+
// the knob would defer to the operator's server-side `transcribe_by_default`
|
|
252
|
+
// (default true, configurable), making MCP behavior diverge from EnriCode
|
|
253
|
+
// on hardened servers. Defaulting here keeps both clients identical.
|
|
254
|
+
payload["transcribe"] = EnriProxyClient.requireTranscribe(params.transcribe);
|
|
165
255
|
if (typeof params.transcriptionLanguage === "string" && params.transcriptionLanguage.trim()) {
|
|
166
256
|
payload["transcription_language"] = params.transcriptionLanguage.trim();
|
|
167
257
|
}
|
|
168
258
|
if (typeof params.analysisMode === "string" && params.analysisMode.trim()) {
|
|
169
259
|
payload["analysis_mode"] = params.analysisMode.trim();
|
|
170
260
|
}
|
|
171
|
-
if (typeof params.region === "object" &&
|
|
172
|
-
params.region
|
|
173
|
-
Number.isFinite(params.region.x) &&
|
|
174
|
-
Number.isFinite(params.region.y) &&
|
|
175
|
-
Number.isFinite(params.region.width) &&
|
|
176
|
-
Number.isFinite(params.region.height)) {
|
|
177
|
-
payload["region"] = {
|
|
178
|
-
x: params.region.x,
|
|
179
|
-
y: params.region.y,
|
|
180
|
-
width: params.region.width,
|
|
181
|
-
height: params.region.height
|
|
182
|
-
};
|
|
261
|
+
if (typeof params.region === "object" && params.region !== null) {
|
|
262
|
+
payload["region"] = EnriProxyClient.requireValidRegion(params.region);
|
|
183
263
|
}
|
|
184
264
|
if (params.video && typeof params.video === "object") {
|
|
185
265
|
const videoPayload = {};
|
|
186
|
-
|
|
187
|
-
|
|
266
|
+
const requestedClipDuration = requireClipDuration(params.video.clipDurationSeconds);
|
|
267
|
+
let clipDurationSeconds = requestedClipDuration;
|
|
268
|
+
// A window anchored only by duration still starts at 0: synthesize
|
|
269
|
+
// it so the server never defaults the offset. Mirrors EnriCode
|
|
270
|
+
// VisionAnalyzeMediaRequestRecords (clip_start_seconds travels even
|
|
271
|
+
// when 0 while a window exists) and AnalyzeMediaParamParser.
|
|
272
|
+
const clipStartRaw = requireClipBound(params.video.clipStartSeconds, "video.clip_start_seconds");
|
|
273
|
+
const clipStartSeconds = typeof clipStartRaw !== "undefined"
|
|
274
|
+
? clipStartRaw
|
|
275
|
+
: (typeof clipDurationSeconds !== "undefined" ? 0 : undefined);
|
|
276
|
+
if (typeof clipStartSeconds !== "undefined") {
|
|
277
|
+
videoPayload["clip_start_seconds"] = clipStartSeconds;
|
|
278
|
+
}
|
|
279
|
+
// Parity clamp backstop (the tool parser already clamps with a
|
|
280
|
+
// Spanish warning): an overflowing direct-client window is clamped
|
|
281
|
+
// to the 24 h range instead of failing, mirroring the proxy trim.
|
|
282
|
+
if (typeof clipStartSeconds !== "undefined"
|
|
283
|
+
&& typeof clipDurationSeconds !== "undefined"
|
|
284
|
+
&& clipStartSeconds + clipDurationSeconds > MAX_CLIP_SECONDS) {
|
|
285
|
+
if (clipStartSeconds >= MAX_CLIP_SECONDS) {
|
|
286
|
+
throw new Error(`video.clip_start_seconds (${String(clipStartSeconds)}) ya llegó al límite de ${String(MAX_CLIP_SECONDS)} segundos (24 h): baje el inicio para dejar una ventana analizable. / video.clip_start_seconds (${String(clipStartSeconds)}) already reached the ${String(MAX_CLIP_SECONDS)} s limit (24 h): lower the start to leave an analyzable window.`);
|
|
287
|
+
}
|
|
288
|
+
clipDurationSeconds = MAX_CLIP_SECONDS - clipStartSeconds;
|
|
289
|
+
localWarnings.push(buildClipWindowClampedWarning(clipStartSeconds, requestedClipDuration ?? clipDurationSeconds, clipDurationSeconds, MAX_CLIP_SECONDS));
|
|
188
290
|
}
|
|
189
|
-
if (typeof
|
|
190
|
-
|
|
191
|
-
params.video.clipDurationSeconds > 0) {
|
|
192
|
-
videoPayload["clip_duration_seconds"] = params.video.clipDurationSeconds;
|
|
291
|
+
if (typeof clipDurationSeconds !== "undefined") {
|
|
292
|
+
videoPayload["clip_duration_seconds"] = clipDurationSeconds;
|
|
193
293
|
}
|
|
194
|
-
|
|
195
|
-
|
|
294
|
+
const segmentSeconds = EnriProxyClient.requireOptionalNumber(params.video.segmentSeconds, "video.segment_seconds", 5, 600);
|
|
295
|
+
if (typeof segmentSeconds !== "undefined") {
|
|
296
|
+
videoPayload["segment_seconds"] = segmentSeconds;
|
|
196
297
|
}
|
|
197
|
-
|
|
198
|
-
|
|
298
|
+
const maxSegments = EnriProxyClient.requireOptionalInt(params.video.maxSegments, "video.max_segments", 1, 60);
|
|
299
|
+
if (typeof maxSegments !== "undefined") {
|
|
300
|
+
videoPayload["max_segments"] = maxSegments;
|
|
199
301
|
}
|
|
200
|
-
|
|
201
|
-
|
|
302
|
+
const maxFramesPerSegment = EnriProxyClient.requireOptionalInt(params.video.maxFramesPerSegment, "video.max_frames_per_segment", 1, 20);
|
|
303
|
+
if (typeof maxFramesPerSegment !== "undefined") {
|
|
304
|
+
videoPayload["max_frames_per_segment"] = maxFramesPerSegment;
|
|
202
305
|
}
|
|
203
306
|
if (Object.keys(videoPayload).length > 0) {
|
|
204
307
|
payload["video"] = videoPayload;
|
|
@@ -206,18 +309,22 @@ export class EnriProxyClient {
|
|
|
206
309
|
}
|
|
207
310
|
if (params.document && typeof params.document === "object") {
|
|
208
311
|
const documentPayload = {};
|
|
209
|
-
|
|
210
|
-
|
|
312
|
+
const maxPagesTotal = EnriProxyClient.requireOptionalInt(params.document.maxPagesTotal, "document.max_pages_total", 1, 200);
|
|
313
|
+
if (typeof maxPagesTotal !== "undefined") {
|
|
314
|
+
documentPayload["max_pages_total"] = maxPagesTotal;
|
|
211
315
|
}
|
|
212
|
-
|
|
213
|
-
|
|
316
|
+
const pagesPerBatch = EnriProxyClient.requireOptionalInt(params.document.pagesPerBatch, "document.pages_per_batch", 1, 200);
|
|
317
|
+
if (typeof pagesPerBatch !== "undefined") {
|
|
318
|
+
documentPayload["pages_per_batch"] = pagesPerBatch;
|
|
214
319
|
}
|
|
215
|
-
|
|
216
|
-
|
|
320
|
+
EnriProxyClient.throwOnBatchExceedingTotal(pagesPerBatch, maxPagesTotal, "document");
|
|
321
|
+
const maxImagesPerBatch = EnriProxyClient.requireOptionalInt(params.document.maxImagesPerBatch, "document.max_images_per_batch", 0, 20);
|
|
322
|
+
if (typeof maxImagesPerBatch !== "undefined") {
|
|
323
|
+
documentPayload["max_images_per_batch"] = maxImagesPerBatch;
|
|
217
324
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
documentPayload["scanned_text_threshold_chars"] =
|
|
325
|
+
const scannedTextThresholdChars = EnriProxyClient.requireOptionalInt(params.document.scannedTextThresholdChars, "document.scanned_text_threshold_chars", 0, 5000);
|
|
326
|
+
if (typeof scannedTextThresholdChars !== "undefined") {
|
|
327
|
+
documentPayload["scanned_text_threshold_chars"] = scannedTextThresholdChars;
|
|
221
328
|
}
|
|
222
329
|
if (Object.keys(documentPayload).length > 0) {
|
|
223
330
|
payload["document"] = documentPayload;
|
|
@@ -225,14 +332,17 @@ export class EnriProxyClient {
|
|
|
225
332
|
}
|
|
226
333
|
if (params.audio && typeof params.audio === "object") {
|
|
227
334
|
const audioPayload = {};
|
|
228
|
-
|
|
229
|
-
|
|
335
|
+
const timestamps = EnriProxyClient.requireOptionalBoolean(params.audio.timestamps, "audio.timestamps");
|
|
336
|
+
if (typeof timestamps !== "undefined") {
|
|
337
|
+
audioPayload["timestamps"] = timestamps;
|
|
230
338
|
}
|
|
231
|
-
|
|
232
|
-
|
|
339
|
+
const audioSegmentSeconds = EnriProxyClient.requireOptionalNumber(params.audio.segmentSeconds, "audio.segment_seconds", 5, 600);
|
|
340
|
+
if (typeof audioSegmentSeconds !== "undefined") {
|
|
341
|
+
audioPayload["segment_seconds"] = audioSegmentSeconds;
|
|
233
342
|
}
|
|
234
|
-
|
|
235
|
-
|
|
343
|
+
const audioMaxSegments = EnriProxyClient.requireOptionalInt(params.audio.maxSegments, "audio.max_segments", 1, 60);
|
|
344
|
+
if (typeof audioMaxSegments !== "undefined") {
|
|
345
|
+
audioPayload["max_segments"] = audioMaxSegments;
|
|
236
346
|
}
|
|
237
347
|
if (Object.keys(audioPayload).length > 0) {
|
|
238
348
|
payload["audio"] = audioPayload;
|
|
@@ -240,34 +350,539 @@ export class EnriProxyClient {
|
|
|
240
350
|
}
|
|
241
351
|
if (params.images && typeof params.images === "object") {
|
|
242
352
|
const imagesPayload = {};
|
|
243
|
-
|
|
244
|
-
|
|
353
|
+
const maxImagesTotal = EnriProxyClient.requireOptionalInt(params.images.maxImagesTotal, "images.max_images_total", 1, 500);
|
|
354
|
+
if (typeof maxImagesTotal !== "undefined") {
|
|
355
|
+
imagesPayload["max_images_total"] = maxImagesTotal;
|
|
245
356
|
}
|
|
246
|
-
|
|
247
|
-
|
|
357
|
+
const imagesPerBatch = EnriProxyClient.requireOptionalInt(params.images.imagesPerBatch, "images.images_per_batch", 1, 20);
|
|
358
|
+
if (typeof imagesPerBatch !== "undefined") {
|
|
359
|
+
imagesPayload["images_per_batch"] = imagesPerBatch;
|
|
248
360
|
}
|
|
249
|
-
|
|
250
|
-
|
|
361
|
+
EnriProxyClient.throwOnBatchExceedingTotal(imagesPerBatch, maxImagesTotal, "images");
|
|
362
|
+
const maxDimension = EnriProxyClient.requireOptionalInt(params.images.maxDimension, "images.max_dimension", 256, 4096);
|
|
363
|
+
if (typeof maxDimension !== "undefined") {
|
|
364
|
+
imagesPayload["max_dimension"] = maxDimension;
|
|
251
365
|
}
|
|
252
366
|
if (Object.keys(imagesPayload).length > 0) {
|
|
253
367
|
payload["images"] = imagesPayload;
|
|
254
368
|
}
|
|
255
369
|
}
|
|
256
|
-
const
|
|
370
|
+
const analyzeTimeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
|
|
371
|
+
? Math.floor(params.timeoutMs)
|
|
372
|
+
: this.timeoutMs;
|
|
373
|
+
const result = await this.requestJson("POST", url, payload, analyzeTimeoutMs, params.signal);
|
|
374
|
+
if (result.status < 200 || result.status >= 300) {
|
|
375
|
+
throw EnriProxyClient.buildHttpError(`El análisis de visión falló (HTTP ${result.status}). / Vision analysis failed (HTTP ${result.status}).`, result);
|
|
376
|
+
}
|
|
377
|
+
return EnriProxyClient.requireValidAnalyzeResponse(EnriProxyClient.parseJsonBody(result.body, result.status), localWarnings);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Reads one continuation window over a truncated media list.
|
|
381
|
+
*
|
|
382
|
+
* @param params - Cursor plus optional offset/limit.
|
|
383
|
+
* @returns Page entries with totals and continuation state.
|
|
384
|
+
* @throws Error with a Spanish-first bilingual message on invalid
|
|
385
|
+
* cursors, expired cursors, or malformed responses.
|
|
386
|
+
*/
|
|
387
|
+
async fetchSegmentPage(params) {
|
|
388
|
+
const source = (params ?? {});
|
|
389
|
+
const cursor = source["cursor"];
|
|
390
|
+
if (typeof cursor !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(cursor)) {
|
|
391
|
+
throw new Error("cursor debe ser el cursor opaco devuelto en una respuesta truncada. / cursor must be the opaque cursor from a truncated response.");
|
|
392
|
+
}
|
|
393
|
+
const offset = source["offset"];
|
|
394
|
+
if (typeof offset !== "undefined" && (typeof offset !== "number" || !Number.isFinite(offset) || offset < 0)) {
|
|
395
|
+
throw new Error("offset debe ser un entero mayor o igual que 0. / offset must be an integer greater than or equal to 0.");
|
|
396
|
+
}
|
|
397
|
+
const limit = source["limit"];
|
|
398
|
+
if (typeof limit !== "undefined" && (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1)) {
|
|
399
|
+
throw new Error("limit debe ser un entero mayor o igual que 1. / limit must be an integer greater than or equal to 1.");
|
|
400
|
+
}
|
|
401
|
+
const url = this.buildUrl("/v1/vision/segments");
|
|
402
|
+
const payload = { cursor };
|
|
403
|
+
if (typeof offset !== "undefined") {
|
|
404
|
+
payload["offset"] = Math.floor(offset);
|
|
405
|
+
}
|
|
406
|
+
if (typeof limit !== "undefined") {
|
|
407
|
+
payload["limit"] = Math.floor(limit);
|
|
408
|
+
}
|
|
409
|
+
const timeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
|
|
410
|
+
? Math.floor(params.timeoutMs)
|
|
411
|
+
: this.timeoutMs;
|
|
412
|
+
const result = await this.requestJson("POST", url, payload, timeoutMs, params.signal);
|
|
257
413
|
if (result.status < 200 || result.status >= 300) {
|
|
258
|
-
throw
|
|
414
|
+
throw EnriProxyClient.buildHttpError(`La lectura de continuación falló (HTTP ${result.status}). / Continuation read failed (HTTP ${result.status}).`, result);
|
|
415
|
+
}
|
|
416
|
+
return EnriProxyClient.requireValidSegmentPage(EnriProxyClient.parseJsonBody(result.body, result.status));
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Validates one continuation page body.
|
|
420
|
+
*
|
|
421
|
+
* @param raw - Parsed response body.
|
|
422
|
+
* @returns Validated page.
|
|
423
|
+
* @throws Error with a Spanish-first bilingual message when the body
|
|
424
|
+
* carries no usable page.
|
|
425
|
+
*/
|
|
426
|
+
static requireValidSegmentPage(raw) {
|
|
427
|
+
const record = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
|
|
428
|
+
if (!Array.isArray(record["entries"])) {
|
|
429
|
+
throw new Error("La respuesta de continuación no trae entries. / Continuation response carries no entries.");
|
|
430
|
+
}
|
|
431
|
+
const total = record["total"];
|
|
432
|
+
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) {
|
|
433
|
+
throw new Error("La respuesta de continuación no trae total. / Continuation response carries no total.");
|
|
434
|
+
}
|
|
435
|
+
const nextOffset = record["next_offset"];
|
|
436
|
+
if (typeof nextOffset !== "number" || !Number.isFinite(nextOffset) || nextOffset < 0) {
|
|
437
|
+
throw new Error("La respuesta de continuación no trae next_offset. / Continuation response carries no next_offset.");
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
entries: record["entries"],
|
|
441
|
+
total: Math.floor(total),
|
|
442
|
+
hasMore: record["has_more"] === true,
|
|
443
|
+
nextOffset: Math.floor(nextOffset),
|
|
444
|
+
cursor: typeof record["cursor"] === "string" ? record["cursor"] : "",
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Rejects oversized prompt text before any byte is uploaded.
|
|
449
|
+
*
|
|
450
|
+
* @remarks
|
|
451
|
+
* Mirrors the parser gate (2000 chars): the server 400s after upload cost.
|
|
452
|
+
*
|
|
453
|
+
* @param value - Optional prompt text.
|
|
454
|
+
* @param fieldName - `question` or `context`.
|
|
455
|
+
* @throws Error in Spanish naming the 2000-character cap.
|
|
456
|
+
*/
|
|
457
|
+
static requireBoundedPromptText(value, fieldName) {
|
|
458
|
+
if (typeof value === "undefined") {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
// Strict like the tool parser: a non-string prompt is a caller bug,
|
|
462
|
+
// not an empty prompt.
|
|
463
|
+
if (typeof value !== "string") {
|
|
464
|
+
throw new Error(`${fieldName} debe ser una cadena de texto. / ${fieldName} must be a string.`);
|
|
465
|
+
}
|
|
466
|
+
if (Array.from(value).length > 2000) {
|
|
467
|
+
throw new Error(`${fieldName} excede el máximo de 2000 caracteres. Acorte el texto y reintente: el servidor rechaza este mismo tope después de cobrar el upload. / ${fieldName} exceeds the 2000-char maximum. Shorten the text and retry: the server rejects this same cap after charging the upload.`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Rejects mistyped tuning before any upload cost on the direct-client path.
|
|
472
|
+
*
|
|
473
|
+
* @remarks
|
|
474
|
+
* Mirrors `AnalyzeMediaParamParser` (enum, language pattern, section
|
|
475
|
+
* key closure): direct callers bypass the tool parser, and a typo would
|
|
476
|
+
* otherwise analyze the whole file at full cost. Exposed for reuse and
|
|
477
|
+
* unit-pinned against the parser key sets.
|
|
478
|
+
*
|
|
479
|
+
* @param params - Analysis parameters about to travel.
|
|
480
|
+
* @throws Error with a Spanish-first bilingual message on the first defect.
|
|
481
|
+
*/
|
|
482
|
+
static requirePreUploadTuning(params) {
|
|
483
|
+
if (typeof params.analysisMode === "string" && params.analysisMode.trim()) {
|
|
484
|
+
const mode = params.analysisMode.trim();
|
|
485
|
+
if (mode !== "auto" && mode !== "single" && mode !== "multipass") {
|
|
486
|
+
throw new Error("analysis_mode debe ser uno de: auto|single|multipass. / analysis_mode must be one of: auto|single|multipass.");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
EnriProxyClient.requireLanguageHint(params.language, "language");
|
|
490
|
+
EnriProxyClient.requireLanguageHint(params.transcriptionLanguage, "transcription_language");
|
|
491
|
+
EnriProxyClient.requireKnownSectionKeys(params.video, VIDEO_KNOWN_KEYS, "video");
|
|
492
|
+
EnriProxyClient.requireKnownSectionKeys(params.document, DOCUMENT_KNOWN_KEYS, "document");
|
|
493
|
+
EnriProxyClient.requireKnownSectionKeys(params.audio, AUDIO_KNOWN_KEYS, "audio");
|
|
494
|
+
EnriProxyClient.requireKnownSectionKeys(params.images, IMAGES_KNOWN_KEYS, "images");
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Validates one language hint against the parser pattern.
|
|
498
|
+
*
|
|
499
|
+
* @param value - Candidate hint.
|
|
500
|
+
* @param fieldName - Dotted field name for error messages.
|
|
501
|
+
* @returns Nothing.
|
|
502
|
+
* @throws Error with a Spanish-first bilingual message on mismatch.
|
|
503
|
+
*/
|
|
504
|
+
static requireLanguageHint(value, fieldName) {
|
|
505
|
+
if (typeof value === "undefined") {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (typeof value !== "string") {
|
|
509
|
+
throw new Error(`${fieldName} debe ser una cadena de texto. / ${fieldName} must be a string.`);
|
|
510
|
+
}
|
|
511
|
+
const trimmed = value.trim();
|
|
512
|
+
if (trimmed.length === 0) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (trimmed.length > 32 || !/^[A-Za-z]{2,8}([-_][A-Za-z0-9]{1,8}){0,2}$/.test(trimmed)) {
|
|
516
|
+
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).`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Rejects unknown keys inside one tuning section.
|
|
521
|
+
*
|
|
522
|
+
* @param section - Candidate section object.
|
|
523
|
+
* @param knownKeys - Parser-owned accepted spellings.
|
|
524
|
+
* @param sectionName - Section name for error messages.
|
|
525
|
+
* @returns Nothing.
|
|
526
|
+
* @throws Error with a Spanish-first bilingual message on unknown keys.
|
|
527
|
+
*/
|
|
528
|
+
static requireKnownSectionKeys(section, knownKeys, sectionName) {
|
|
529
|
+
if (typeof section === "undefined") {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (section === null || typeof section !== "object" || Array.isArray(section)) {
|
|
533
|
+
throw new Error(`${sectionName} debe ser un objeto. / ${sectionName} must be an object.`);
|
|
534
|
+
}
|
|
535
|
+
const unknown = Object.keys(section).filter((key) => !knownKeys.has(key));
|
|
536
|
+
if (unknown.length > 0) {
|
|
537
|
+
throw new Error(`${sectionName} tiene claves desconocidas (${unknown.join(", ")}): revise la escritura. / ${sectionName} has unknown keys (${unknown.join(", ")}): check the spelling.`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Rejects per-batch sizes exceeding their totals on the direct-client path.
|
|
542
|
+
*
|
|
543
|
+
* @remarks
|
|
544
|
+
* The tool parser already guards this; direct callers bypass it, and the
|
|
545
|
+
* server would truncate after paid multipass work.
|
|
546
|
+
*
|
|
547
|
+
* @param perBatch - Per-batch value, or undefined when absent.
|
|
548
|
+
* @param total - Total value, or undefined when absent.
|
|
549
|
+
* @param family - `document` or `images`.
|
|
550
|
+
* @throws Error with an Spanish-first bilingual message when the batch exceeds the total.
|
|
551
|
+
*/
|
|
552
|
+
static throwOnBatchExceedingTotal(perBatch, total, family) {
|
|
553
|
+
if (typeof perBatch !== "undefined" && typeof total !== "undefined" && perBatch > total) {
|
|
554
|
+
const batchField = family === "document" ? "pages_per_batch" : "images_per_batch";
|
|
555
|
+
const totalField = family === "document" ? "max_pages_total" : "max_images_total";
|
|
556
|
+
throw new Error(`${family}.${batchField} supera a ${totalField} (${String(perBatch)} > ${String(total)}): el lote no puede ser mayor que el total. / ${family}.${batchField} exceeds ${totalField} (${String(perBatch)} > ${String(total)}): a batch cannot be greater than the total.`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Coerces an optional integer tuning knob, failing loudly on garbage.
|
|
561
|
+
*
|
|
562
|
+
* @remarks
|
|
563
|
+
* Parity with `AnalyzeMediaParamParser` (`optionalInt` accepts `"60"`):
|
|
564
|
+
* integers and complete integer strings travel floored; any other present
|
|
565
|
+
* value throws bilingually instead of being silently dropped (a dropped
|
|
566
|
+
* knob would analyze the whole file at full cost with default tuning).
|
|
567
|
+
*
|
|
568
|
+
* @param raw - Raw knob value.
|
|
569
|
+
* @param fieldName - Dotted field name for error messages.
|
|
570
|
+
* @param min - Inclusive minimum (shares the parser range table so direct
|
|
571
|
+
* callers fail pre-upload like parser-gated calls).
|
|
572
|
+
* @param max - Inclusive maximum (shares the parser range table).
|
|
573
|
+
* @returns Floored integer, or undefined when absent.
|
|
574
|
+
* @throws Error with an Spanish-first bilingual message when present but not an integer within range.
|
|
575
|
+
*/
|
|
576
|
+
static requireOptionalInt(raw, fieldName, min, max) {
|
|
577
|
+
if (typeof raw === "undefined") {
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
const parsed = optionalInt(raw);
|
|
581
|
+
if (typeof parsed === "undefined" || !Number.isFinite(parsed)) {
|
|
582
|
+
throw new Error(`${fieldName} debe ser un entero (también vale su forma string como "60"). / ${fieldName} must be an integer (its string form like "60" also works).`);
|
|
583
|
+
}
|
|
584
|
+
const floored = Math.floor(parsed);
|
|
585
|
+
if (typeof min === "number" && typeof max === "number" && (floored < min || floored > max)) {
|
|
586
|
+
throw new Error(`${fieldName} debe ser un entero entre ${String(min)} y ${String(max)} (se recibió ${String(floored)}). / ${fieldName} must be an integer between ${String(min)} and ${String(max)} (got ${String(floored)}).`);
|
|
587
|
+
}
|
|
588
|
+
return floored;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Coerces an optional float tuning knob, failing loudly on garbage.
|
|
592
|
+
*
|
|
593
|
+
* @remarks
|
|
594
|
+
* Parity with `AnalyzeMediaParamParser` (`optionalNumber` accepts
|
|
595
|
+
* `"12.5"`): numbers and complete numeric strings travel; any other
|
|
596
|
+
* present value throws bilingually instead of being silently dropped.
|
|
597
|
+
*
|
|
598
|
+
* @param raw - Raw knob value.
|
|
599
|
+
* @param fieldName - Dotted field name for error messages.
|
|
600
|
+
* @param min - Inclusive minimum (shares the parser range table).
|
|
601
|
+
* @param max - Inclusive maximum (shares the parser range table).
|
|
602
|
+
* @returns Finite number, or undefined when absent.
|
|
603
|
+
* @throws Error with an Spanish-first bilingual message when present but not a number within range.
|
|
604
|
+
*/
|
|
605
|
+
static requireOptionalNumber(raw, fieldName, min, max) {
|
|
606
|
+
if (typeof raw === "undefined") {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
const parsed = typeof raw === "number" && Number.isFinite(raw) ? raw : optionalNumber(raw);
|
|
610
|
+
if (typeof parsed === "undefined" || !Number.isFinite(parsed)) {
|
|
611
|
+
throw new Error(`${fieldName} debe ser un número (también vale su forma string como "12.5"). / ${fieldName} must be a number (its string form like "12.5" also works).`);
|
|
612
|
+
}
|
|
613
|
+
if (typeof min === "number" && typeof max === "number" && (parsed < min || parsed > max)) {
|
|
614
|
+
throw new Error(`${fieldName} debe ser un número entre ${String(min)} y ${String(max)} (se recibió ${String(parsed)}). / ${fieldName} must be a number between ${String(min)} and ${String(max)} (got ${String(parsed)}).`);
|
|
259
615
|
}
|
|
260
|
-
const parsed = JSON.parse(result.body);
|
|
261
616
|
return parsed;
|
|
262
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* Coerces an optional boolean tuning knob, failing loudly on garbage.
|
|
620
|
+
*
|
|
621
|
+
* @remarks
|
|
622
|
+
* Parity with `AnalyzeMediaParamParser` (`assertOptionalBoolean` accepts
|
|
623
|
+
* `"true"`/`"false"`): booleans and true/false strings travel; any
|
|
624
|
+
* other present value throws bilingually instead of being silently dropped.
|
|
625
|
+
*
|
|
626
|
+
* @param raw - Raw knob value.
|
|
627
|
+
* @param fieldName - Dotted field name for error messages.
|
|
628
|
+
* @returns Boolean, or undefined when absent.
|
|
629
|
+
* @throws Error with an Spanish-first bilingual message when present but not a boolean.
|
|
630
|
+
*/
|
|
631
|
+
static requireOptionalBoolean(raw, fieldName) {
|
|
632
|
+
if (typeof raw === "undefined") {
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
if (typeof raw === "boolean") {
|
|
636
|
+
return raw;
|
|
637
|
+
}
|
|
638
|
+
if (typeof raw === "string") {
|
|
639
|
+
const normalized = raw.trim().toLowerCase();
|
|
640
|
+
if (normalized === "true") {
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
if (normalized === "false") {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
throw new Error(`${fieldName} debe ser un booleano (true o false; también vale "true"/"false"). / ${fieldName} must be a boolean (true or false; "true"/"false" also work).`);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Resolves the `transcribe` knob, defaulting to true like EnriCode.
|
|
651
|
+
*
|
|
652
|
+
* @remarks
|
|
653
|
+
* A `"false"` string must never collapse to the `true` default (that
|
|
654
|
+
* would pay a full transcription the caller explicitly disabled):
|
|
655
|
+
* true/false strings coerce, anything else present throws in Spanish.
|
|
656
|
+
*
|
|
657
|
+
* @param raw - Raw transcribe value.
|
|
658
|
+
* @returns Resolved transcribe flag.
|
|
659
|
+
* @throws Error with an Spanish-first bilingual message when present but not a boolean nor a true/false string.
|
|
660
|
+
*/
|
|
661
|
+
static requireTranscribe(raw) {
|
|
662
|
+
if (typeof raw === "undefined") {
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
const parsed = EnriProxyClient.requireOptionalBoolean(raw, "transcribe");
|
|
666
|
+
return parsed ?? true;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Validates a `POST /v1/vision/analyze` response body for third-party callers.
|
|
670
|
+
*
|
|
671
|
+
* @remarks
|
|
672
|
+
* A malformed 200 body (`{}`, `analysis: 123`, missing `media_type`)
|
|
673
|
+
* must surface as a Spanish coaching error, never as an English
|
|
674
|
+
* `TypeError` from downstream formatters (model-facing text is always
|
|
675
|
+
* Spanish). Malformed `elements` entries are sanitized (dropped), not
|
|
676
|
+
* fatal: partial grounding still analyzes.
|
|
677
|
+
*
|
|
678
|
+
* @param raw - Parsed response body.
|
|
679
|
+
* @returns Validated analysis response.
|
|
680
|
+
* @throws Error with an Spanish-first bilingual message when the body shape is invalid.
|
|
681
|
+
*/
|
|
682
|
+
static requireValidAnalyzeResponse(raw, localWarnings) {
|
|
683
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
684
|
+
throw new Error("La respuesta del servidor es inválida: se esperaba un objeto JSON con 'analysis', 'media_type' y 'extraction'. / Server response is invalid: expected a JSON object with 'analysis', 'media_type', and 'extraction'.");
|
|
685
|
+
}
|
|
686
|
+
const record = raw;
|
|
687
|
+
const analysis = record["analysis"];
|
|
688
|
+
if (typeof analysis !== "string" || analysis.trim().length === 0) {
|
|
689
|
+
throw new Error("La respuesta del servidor es inválida: 'analysis' debe ser texto no vacío. / Server response is invalid: 'analysis' must be non-empty text.");
|
|
690
|
+
}
|
|
691
|
+
const mediaType = record["media_type"];
|
|
692
|
+
if (typeof mediaType !== "string" || mediaType.trim().length === 0) {
|
|
693
|
+
throw new Error("La respuesta del servidor es inválida: 'media_type' debe ser texto no vacío. / Server response is invalid: 'media_type' must be non-empty text.");
|
|
694
|
+
}
|
|
695
|
+
const extraction = record["extraction"];
|
|
696
|
+
if (typeof extraction !== "undefined" && (typeof extraction !== "object" || extraction === null || Array.isArray(extraction))) {
|
|
697
|
+
throw new Error("La respuesta del servidor es inválida: 'extraction' debe ser un objeto. / Server response is invalid: 'extraction' must be an object.");
|
|
698
|
+
}
|
|
699
|
+
const elements = record["elements"];
|
|
700
|
+
if (typeof elements !== "undefined" && !Array.isArray(elements)) {
|
|
701
|
+
throw new Error("La respuesta del servidor es inválida: 'elements' debe ser un arreglo de cajas. / Server response is invalid: 'elements' must be an array of boxes.");
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
analysis,
|
|
705
|
+
media_type: mediaType,
|
|
706
|
+
extraction: (extraction ?? {}),
|
|
707
|
+
...(typeof elements !== "undefined"
|
|
708
|
+
? { elements: EnriProxyClient.sanitizeAnalyzeElements(elements) }
|
|
709
|
+
: {}),
|
|
710
|
+
...(typeof localWarnings !== "undefined" && localWarnings.length > 0 ? { warnings: [...localWarnings] } : {}),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Keeps only well-formed grounded element boxes.
|
|
715
|
+
*
|
|
716
|
+
* @remarks
|
|
717
|
+
* Malformed entries (non-string label, non-finite box coordinates) are
|
|
718
|
+
* dropped so one bad box never fails the whole analysis; downstream
|
|
719
|
+
* formatters can assume `{label, box: {x, y, width, height}}`.
|
|
720
|
+
*
|
|
721
|
+
* @param elements - Raw elements array from the server.
|
|
722
|
+
* @returns Sanitized element boxes.
|
|
723
|
+
*/
|
|
724
|
+
static sanitizeAnalyzeElements(elements) {
|
|
725
|
+
const kept = [];
|
|
726
|
+
for (const candidate of elements) {
|
|
727
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const record = candidate;
|
|
731
|
+
if (typeof record["label"] !== "string") {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const box = record["box"];
|
|
735
|
+
if (typeof box !== "object" || box === null || Array.isArray(box)) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const boxRecord = box;
|
|
739
|
+
const coords = [boxRecord["x"], boxRecord["y"], boxRecord["width"], boxRecord["height"]];
|
|
740
|
+
if (!coords.every((coord) => typeof coord === "number" && Number.isFinite(coord))) {
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
kept.push({
|
|
744
|
+
label: record["label"],
|
|
745
|
+
box: {
|
|
746
|
+
x: boxRecord["x"],
|
|
747
|
+
y: boxRecord["y"],
|
|
748
|
+
width: boxRecord["width"],
|
|
749
|
+
height: boxRecord["height"],
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return kept;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Builds an {@link EnriProxyHttpError} embedding the parsed server detail.
|
|
757
|
+
*
|
|
758
|
+
* @param baseMessage - Base message naming the failed operation with its HTTP status.
|
|
759
|
+
* @param result - Raw HTTP result carrying headers and body.
|
|
760
|
+
* @returns HTTP error preserving status, headers, and body.
|
|
761
|
+
*/
|
|
762
|
+
static buildHttpError(baseMessage, result) {
|
|
763
|
+
const detail = extractServerErrorDetail(result.body);
|
|
764
|
+
const message = detail ? `${baseMessage} Detalle del servidor: ${detail}` : baseMessage;
|
|
765
|
+
return new EnriProxyHttpError(message, result.status, result.headers, result.body);
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Parses a 2xx JSON body with a bilingual guard for non-JSON payloads.
|
|
769
|
+
*
|
|
770
|
+
* @param body - Raw response body.
|
|
771
|
+
* @param status - HTTP status code (reported in the error).
|
|
772
|
+
* @returns Parsed body.
|
|
773
|
+
* @throws Error with an Spanish-first bilingual message when the body is not valid JSON.
|
|
774
|
+
*/
|
|
775
|
+
static parseJsonBody(body, status) {
|
|
776
|
+
try {
|
|
777
|
+
return JSON.parse(body);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
throw new Error(`La respuesta del servidor no es JSON válido (HTTP ${String(status)}). / Server response is not valid JSON (HTTP ${String(status)}).`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
263
783
|
/**
|
|
264
784
|
* Builds an absolute URL relative to the configured base URL.
|
|
265
785
|
*
|
|
266
|
-
* @
|
|
786
|
+
* @remarks
|
|
787
|
+
* The base subpath is preserved (`http://host/proxy` + `/v1/uploads` =
|
|
788
|
+
* `http://host/proxy/v1/uploads`): `new URL(path, base)` alone would
|
|
789
|
+
* discard it. A root base keeps working exactly as before.
|
|
790
|
+
*
|
|
791
|
+
* @param pathname - Pathname to append (must start with `/`).
|
|
267
792
|
* @returns URL instance
|
|
793
|
+
* @throws Error with an Spanish-first bilingual message when the configured base URL is malformed.
|
|
268
794
|
*/
|
|
269
795
|
buildUrl(pathname) {
|
|
270
|
-
|
|
796
|
+
try {
|
|
797
|
+
return new URL(`${this.baseUrl}${pathname}`);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
throw new Error(`ENRIPROXY_URL inválida: '${this.baseUrl}'. Use una URL http(s) completa. / Invalid ENRIPROXY_URL: '${this.baseUrl}'. Use a complete http(s) URL.`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Parses a strict `Upload-Offset` response header.
|
|
805
|
+
*
|
|
806
|
+
* @remarks
|
|
807
|
+
* Strict by design: only `^\d+$` (after trimming) is accepted, so
|
|
808
|
+
* `"12abc"` or `"1.5"` fail bilingually instead of being prefix-parsed
|
|
809
|
+
* (`parseInt("12abc") === 12`) and resuming at a wrong offset.
|
|
810
|
+
*
|
|
811
|
+
* @param headers - Response headers.
|
|
812
|
+
* @returns Offset in bytes.
|
|
813
|
+
* @throws Error with an Spanish-first bilingual message when the header is missing or malformed.
|
|
814
|
+
*/
|
|
815
|
+
parseUploadOffsetHeader(headers) {
|
|
816
|
+
const offsetHeader = this.getHeaderValue(headers, "upload-offset");
|
|
817
|
+
if (!offsetHeader) {
|
|
818
|
+
throw new Error("Falta el encabezado Upload-Offset en la respuesta del servidor. / Missing Upload-Offset header in the server response.");
|
|
819
|
+
}
|
|
820
|
+
const trimmed = offsetHeader.trim();
|
|
821
|
+
if (!/^\d+$/u.test(trimmed)) {
|
|
822
|
+
throw new Error(`Encabezado Upload-Offset inválido: ${offsetHeader} / Invalid Upload-Offset header: ${offsetHeader}.`);
|
|
823
|
+
}
|
|
824
|
+
return Number.parseInt(trimmed, 10);
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Validates a relative image region for native-resolution zoom.
|
|
828
|
+
*
|
|
829
|
+
* @remarks
|
|
830
|
+
* Mirrors the `AnalyzeMediaParamParser` region contract (`[0,1]` bounds,
|
|
831
|
+
* positive size, `x+width<=1`/`y+height<=1`, complete numeric strings
|
|
832
|
+
* coerced via `optionalFraction`) so direct client callers get the same
|
|
833
|
+
* Spanish coaching instead of a late server rejection.
|
|
834
|
+
*
|
|
835
|
+
* @param region - Raw region value.
|
|
836
|
+
* @returns Validated region payload.
|
|
837
|
+
* @throws Error with an Spanish-first bilingual message when the region is malformed or out of range.
|
|
838
|
+
*/
|
|
839
|
+
static requireValidRegion(region) {
|
|
840
|
+
const unknownKeys = Object.keys(region).filter((key) => key !== "x" && key !== "y" && key !== "width" && key !== "height");
|
|
841
|
+
if (unknownKeys.length > 0) {
|
|
842
|
+
throw new Error(`region trae claves desconocidas (${unknownKeys.join(", ")}): se rechazan. Claves válidas: x, y, width, height. / region has unknown keys (${unknownKeys.join(", ")}): they are rejected. Valid keys: x, y, width, height.`);
|
|
843
|
+
}
|
|
844
|
+
const readFraction = (value, fieldName) => {
|
|
845
|
+
const parsed = typeof value === "number" && Number.isFinite(value) ? value : optionalFraction(value);
|
|
846
|
+
if (typeof parsed === "undefined" || parsed < 0 || parsed > 1) {
|
|
847
|
+
throw new Error(`region.${fieldName} debe ser un número entre 0 y 1 (coordenadas relativas a la imagen original). / region.${fieldName} must be a number between 0 and 1 (coords relative to the original image).`);
|
|
848
|
+
}
|
|
849
|
+
return parsed;
|
|
850
|
+
};
|
|
851
|
+
const valid = {
|
|
852
|
+
x: readFraction(region.x, "x"),
|
|
853
|
+
y: readFraction(region.y, "y"),
|
|
854
|
+
width: readFraction(region.width, "width"),
|
|
855
|
+
height: readFraction(region.height, "height"),
|
|
856
|
+
};
|
|
857
|
+
if (valid.width <= 0 || valid.height <= 0) {
|
|
858
|
+
throw new Error("region.width y region.height deben ser mayores que 0. / region.width and region.height must be greater than 0.");
|
|
859
|
+
}
|
|
860
|
+
if (valid.x + valid.width > 1 || valid.y + valid.height > 1) {
|
|
861
|
+
throw new Error("region debe caber en la imagen original: x+width y y+height no pueden exceder 1. / region must fit inside the original image: x+width and y+height cannot exceed 1.");
|
|
862
|
+
}
|
|
863
|
+
return valid;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Resolves the session-creation control timeout (60 s, mirrors EnriCode).
|
|
867
|
+
*
|
|
868
|
+
* @returns Control timeout in milliseconds (never above 60 s).
|
|
869
|
+
*/
|
|
870
|
+
createControlTimeoutMs() {
|
|
871
|
+
if (Number.isFinite(this.timeoutMs) && this.timeoutMs > 0) {
|
|
872
|
+
return Math.min(this.timeoutMs, CREATE_CONTROL_TIMEOUT_MS);
|
|
873
|
+
}
|
|
874
|
+
return CREATE_CONTROL_TIMEOUT_MS;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Resolves the offset-probe control timeout (15 s, mirrors EnriCode).
|
|
878
|
+
*
|
|
879
|
+
* @returns Control timeout in milliseconds (never above 15 s).
|
|
880
|
+
*/
|
|
881
|
+
probeControlTimeoutMs() {
|
|
882
|
+
if (Number.isFinite(this.timeoutMs) && this.timeoutMs > 0) {
|
|
883
|
+
return Math.min(this.timeoutMs, PROBE_CONTROL_TIMEOUT_MS);
|
|
884
|
+
}
|
|
885
|
+
return PROBE_CONTROL_TIMEOUT_MS;
|
|
271
886
|
}
|
|
272
887
|
/**
|
|
273
888
|
* Extracts a response header as a single string.
|
|
@@ -296,15 +911,16 @@ export class EnriProxyClient {
|
|
|
296
911
|
* @param url - Target URL
|
|
297
912
|
* @param jsonBody - JSON payload
|
|
298
913
|
* @param timeoutMs - Timeout in milliseconds
|
|
914
|
+
* @param signal - Optional cancellation signal.
|
|
299
915
|
* @returns HTTP result
|
|
300
916
|
*/
|
|
301
|
-
async requestJson(method, url, jsonBody, timeoutMs) {
|
|
917
|
+
async requestJson(method, url, jsonBody, timeoutMs, signal) {
|
|
302
918
|
const body = JSON.stringify(jsonBody);
|
|
303
919
|
const headers = {
|
|
304
920
|
"Content-Type": "application/json",
|
|
305
921
|
"Content-Length": String(Buffer.byteLength(body))
|
|
306
922
|
};
|
|
307
|
-
return await this.requestRaw(method, url, headers, Buffer.from(body, "utf8"), timeoutMs);
|
|
923
|
+
return await this.requestRaw(method, url, headers, Buffer.from(body, "utf8"), timeoutMs, signal);
|
|
308
924
|
}
|
|
309
925
|
/**
|
|
310
926
|
* Sends an HTTP request with optional headers and body.
|
|
@@ -314,9 +930,13 @@ export class EnriProxyClient {
|
|
|
314
930
|
* @param headers - Request headers
|
|
315
931
|
* @param body - Request body
|
|
316
932
|
* @param timeoutMs - Timeout in milliseconds
|
|
933
|
+
* @param signal - Optional cancellation signal; aborts with a Spanish error.
|
|
317
934
|
* @returns HTTP result
|
|
318
935
|
*/
|
|
319
|
-
async requestRaw(method, url, headers, body, timeoutMs) {
|
|
936
|
+
async requestRaw(method, url, headers, body, timeoutMs, signal) {
|
|
937
|
+
if (signal?.aborted) {
|
|
938
|
+
throw new Error("La solicitud fue cancelada por el cliente. / Request cancelled by the client.");
|
|
939
|
+
}
|
|
320
940
|
const isHttps = url.protocol === "https:";
|
|
321
941
|
const reqFn = isHttps ? httpsRequest : httpRequest;
|
|
322
942
|
const requestHeaders = {
|
|
@@ -330,26 +950,83 @@ export class EnriProxyClient {
|
|
|
330
950
|
}, (res) => {
|
|
331
951
|
const chunks = [];
|
|
332
952
|
let received = 0;
|
|
953
|
+
// Settles exactly once: the overflow path rejects inline (destroy
|
|
954
|
+
// events are unreliable once the socket teardown starts) while a
|
|
955
|
+
// buffered socket may still emit `end` (which would resolve with
|
|
956
|
+
// truncated bytes). The flag plus the overflow marker below make
|
|
957
|
+
// oversize bodies always reject.
|
|
958
|
+
let settled = false;
|
|
959
|
+
let overflowed = false;
|
|
333
960
|
const maxResponseBytes = 50 * 1024 * 1024; // 50MB safeguard
|
|
961
|
+
const overflowError = () => new Error("La respuesta excedió el tamaño máximo permitido (50 MiB); se descartó, nunca truncada. / Response exceeded the maximum allowed size (50 MiB); it was discarded, never truncated.");
|
|
334
962
|
res.on("data", (chunk) => {
|
|
963
|
+
if (settled) {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
335
966
|
received += chunk.length;
|
|
336
967
|
if (received > maxResponseBytes) {
|
|
337
|
-
|
|
968
|
+
// Reject inline: destroying the request/response does not
|
|
969
|
+
// reliably surface another event (the socket teardown can
|
|
970
|
+
// swallow the destroy error), so the promise must settle here.
|
|
971
|
+
// The `end` handler below keeps the overflowed backstop for
|
|
972
|
+
// the same-tick race where `end` wins regardless.
|
|
973
|
+
overflowed = true;
|
|
974
|
+
settled = true;
|
|
975
|
+
try {
|
|
976
|
+
res.destroy();
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// Best-effort: the inline rejection below carries the error.
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
req.destroy();
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
// Best-effort: the inline rejection below carries the error.
|
|
986
|
+
}
|
|
987
|
+
reject(overflowError());
|
|
338
988
|
return;
|
|
339
989
|
}
|
|
340
990
|
chunks.push(chunk);
|
|
341
991
|
});
|
|
342
992
|
res.on("end", () => {
|
|
993
|
+
signal?.removeEventListener("abort", onAbort);
|
|
994
|
+
req.setTimeout(0);
|
|
995
|
+
if (settled) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
settled = true;
|
|
999
|
+
if (overflowed) {
|
|
1000
|
+
reject(overflowError());
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
343
1003
|
resolve({
|
|
344
1004
|
status: res.statusCode ?? 0,
|
|
345
1005
|
headers: res.headers,
|
|
346
1006
|
body: Buffer.concat(chunks).toString("utf8")
|
|
347
1007
|
});
|
|
348
1008
|
});
|
|
1009
|
+
res.on("error", (error) => {
|
|
1010
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1011
|
+
req.setTimeout(0);
|
|
1012
|
+
if (settled) {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
settled = true;
|
|
1016
|
+
reject(error);
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
const onAbort = () => {
|
|
1020
|
+
req.destroy(new Error("La solicitud fue cancelada por el cliente. / Request cancelled by the client."));
|
|
1021
|
+
};
|
|
1022
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1023
|
+
req.on("error", (error) => {
|
|
1024
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1025
|
+
req.setTimeout(0);
|
|
1026
|
+
reject(error);
|
|
349
1027
|
});
|
|
350
|
-
req.on("error", (error) => reject(error));
|
|
351
1028
|
req.setTimeout(timeoutMs, () => {
|
|
352
|
-
req.destroy(new Error(`La petición expiró después de ${timeoutMs}ms
|
|
1029
|
+
req.destroy(new Error(`La petición expiró después de ${timeoutMs}ms / Request timed out after ${timeoutMs}ms.`));
|
|
353
1030
|
});
|
|
354
1031
|
if (body && body.length > 0 && method !== "HEAD") {
|
|
355
1032
|
req.write(body);
|
|
@@ -358,4 +1035,53 @@ export class EnriProxyClient {
|
|
|
358
1035
|
});
|
|
359
1036
|
}
|
|
360
1037
|
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Validates an optional clip-window start bound.
|
|
1040
|
+
*
|
|
1041
|
+
* @remarks
|
|
1042
|
+
* Mirrors `AnalyzeMediaParamParser` float semantics (`0-86400`): only
|
|
1043
|
+
* `undefined` is omitted; numbers and complete numeric strings inside the
|
|
1044
|
+
* range travel, otherwise it throws bilingually instead of being coerced.
|
|
1045
|
+
*
|
|
1046
|
+
* @param raw - Raw bound value.
|
|
1047
|
+
* @param fieldName - Dotted field name for error messages.
|
|
1048
|
+
* @returns Validated bound, or undefined when absent.
|
|
1049
|
+
* @throws Error with an Spanish-first bilingual message when present but not a number within range.
|
|
1050
|
+
*/
|
|
1051
|
+
function requireClipBound(raw, fieldName) {
|
|
1052
|
+
if (typeof raw === "undefined") {
|
|
1053
|
+
return undefined;
|
|
1054
|
+
}
|
|
1055
|
+
const parsed = typeof raw === "number" && Number.isFinite(raw) ? raw : optionalNumber(raw);
|
|
1056
|
+
if (typeof parsed === "undefined" || !Number.isFinite(parsed) || parsed < 0 || parsed > MAX_CLIP_SECONDS) {
|
|
1057
|
+
throw new Error(`${fieldName} debe ser un número entre 0 y ${String(MAX_CLIP_SECONDS)} (segundos). / ${fieldName} must be a number between 0 and ${String(MAX_CLIP_SECONDS)} (seconds).`);
|
|
1058
|
+
}
|
|
1059
|
+
return parsed;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Validates an optional clip-window duration.
|
|
1063
|
+
*
|
|
1064
|
+
* @remarks
|
|
1065
|
+
* Mirrors `AnalyzeMediaParamParser` duration semantics (`(0, 86400]`):
|
|
1066
|
+
* only `undefined` is omitted; numbers and complete numeric strings in
|
|
1067
|
+
* range travel, otherwise it throws bilingually instead of being silently
|
|
1068
|
+
* dropped.
|
|
1069
|
+
*
|
|
1070
|
+
* @param raw - Raw duration value.
|
|
1071
|
+
* @returns Validated duration, or undefined when absent.
|
|
1072
|
+
* @throws Error with an Spanish-first bilingual message when present but not a positive number within range.
|
|
1073
|
+
*/
|
|
1074
|
+
function requireClipDuration(raw) {
|
|
1075
|
+
if (typeof raw === "undefined") {
|
|
1076
|
+
return undefined;
|
|
1077
|
+
}
|
|
1078
|
+
const parsed = typeof raw === "number" && Number.isFinite(raw) ? raw : optionalNumber(raw);
|
|
1079
|
+
if (typeof parsed === "undefined"
|
|
1080
|
+
|| !Number.isFinite(parsed)
|
|
1081
|
+
|| parsed <= 0
|
|
1082
|
+
|| parsed > MAX_CLIP_SECONDS) {
|
|
1083
|
+
throw new Error(`video.clip_duration_seconds debe ser un número mayor que 0 y menor o igual que ${String(MAX_CLIP_SECONDS)} (segundos). / video.clip_duration_seconds must be a number greater than 0 and at most ${String(MAX_CLIP_SECONDS)} (seconds).`);
|
|
1084
|
+
}
|
|
1085
|
+
return parsed;
|
|
1086
|
+
}
|
|
361
1087
|
//# sourceMappingURL=EnriProxyClient.js.map
|