@transcribe-api/sdk 0.1.3 → 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 +392 -54
- package/index.js +1143 -1354
- package/package.json +2 -2
- package/worker.js +543 -296
package/index.js
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
const DEFAULT_BASE_URL = "https://api.transcribeapi.com/v1";
|
|
2
|
-
const MAX_BATCH_FILES = 10000;
|
|
3
|
-
const MAX_BATCH_TOTAL_SIZE_BYTES = 10 * 1024 * 1024 * 1024;
|
|
4
|
-
const MAX_SYNC_AUDIO_BYTES = 30 * 1024 * 1024;
|
|
5
|
-
const MAX_SYNC_AUDIO_SECONDS = 10 * 60;
|
|
6
|
-
const MULTIPART_UPLOAD_THRESHOLD_BYTES =
|
|
7
|
-
const
|
|
8
|
-
const
|
|
1
|
+
const DEFAULT_BASE_URL = "https://api.transcribeapi.com/v1";
|
|
2
|
+
const MAX_BATCH_FILES = 10000;
|
|
3
|
+
const MAX_BATCH_TOTAL_SIZE_BYTES = 10 * 1024 * 1024 * 1024;
|
|
4
|
+
const MAX_SYNC_AUDIO_BYTES = 30 * 1024 * 1024;
|
|
5
|
+
const MAX_SYNC_AUDIO_SECONDS = 10 * 60;
|
|
6
|
+
const MULTIPART_UPLOAD_THRESHOLD_BYTES = 128 * 1024 * 1024;
|
|
7
|
+
const DEFAULT_UPLOAD_CONCURRENCY = 1;
|
|
8
|
+
const MAX_UPLOAD_CONCURRENCY = 32;
|
|
9
9
|
const MAX_MULTIPART_ADAPTIVE_ATTEMPTS = 12;
|
|
10
10
|
const MULTIPART_IDLE_WAIT_MS = 50;
|
|
11
|
-
const MULTIPART_RESUME_STATE_VERSION = 1;
|
|
12
11
|
const MIN_POLLING_INTERVAL_SECONDS = 10;
|
|
13
12
|
const DEFAULT_POLLING_SPINNER_INTERVAL_MS = 150;
|
|
14
13
|
const TERMINAL_JOB_STATUSES = new Set(["completed", "failed", "insufficient_funds"]);
|
|
15
14
|
const BATCH_MP4_UNSUPPORTED_MESSAGE = "Batch uploads do not support .mp4 for MVP. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
|
|
16
15
|
const BATCH_UNSUPPORTED_MESSAGE = "Unsupported batch audio format. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
|
|
17
|
-
|
|
16
|
+
|
|
18
17
|
export class TranscribeAPIError extends Error {
|
|
19
18
|
constructor(message, { status = null, code = null, extra = null, response = null } = {}) {
|
|
20
19
|
super(message);
|
|
@@ -43,68 +42,68 @@ export class TranscribeAPIError extends Error {
|
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
}
|
|
46
|
-
|
|
47
|
-
function sleep(ms) {
|
|
48
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function extractErrorInfo(error) {
|
|
52
|
-
return {
|
|
53
|
-
name: error?.name || null,
|
|
54
|
-
message: error?.message || String(error),
|
|
55
|
-
code: error?.code || error?.cause?.code || null,
|
|
56
|
-
status: error?.status || null,
|
|
57
|
-
cause_message: error?.cause?.message || null,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function isRetryableError(error) {
|
|
62
|
-
const code = String(error?.code || error?.cause?.code || "");
|
|
63
|
-
if (
|
|
64
|
-
code === "ECONNRESET"
|
|
65
|
-
|| code === "ETIMEDOUT"
|
|
66
|
-
|| code === "ECONNREFUSED"
|
|
67
|
-
|| code === "EPIPE"
|
|
68
|
-
|| code === "UND_ERR_SOCKET"
|
|
69
|
-
|| code === "UND_ERR_CONNECT_TIMEOUT"
|
|
70
|
-
|| code === "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC"
|
|
71
|
-
) {
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
if (String(error?.message || "").includes("fetch failed")) {
|
|
75
|
-
return true;
|
|
76
|
-
}
|
|
77
|
-
return !error?.status || error.status === 429 || error.status >= 500;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function retry(operation, { attempts = 3, baseDelayMs = 250 } = {}) {
|
|
81
|
-
let lastError;
|
|
82
|
-
for (let index = 0; index < attempts; index += 1) {
|
|
83
|
-
try {
|
|
84
|
-
return await operation(index);
|
|
85
|
-
} catch (error) {
|
|
86
|
-
lastError = error;
|
|
87
|
-
const retryable = isRetryableError(error);
|
|
88
|
-
if (!retryable || index === attempts - 1) {
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
await sleep(baseDelayMs * (2 ** index));
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
throw lastError;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function
|
|
98
|
-
if (value === undefined || value === null || value === "") {
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
const parsed = Number.parseInt(String(value), 10);
|
|
102
|
-
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
103
|
-
throw new TranscribeAPIError("`
|
|
104
|
-
code: "
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return Math.min(parsed,
|
|
45
|
+
|
|
46
|
+
function sleep(ms) {
|
|
47
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractErrorInfo(error) {
|
|
51
|
+
return {
|
|
52
|
+
name: error?.name || null,
|
|
53
|
+
message: error?.message || String(error),
|
|
54
|
+
code: error?.code || error?.cause?.code || null,
|
|
55
|
+
status: error?.status || null,
|
|
56
|
+
cause_message: error?.cause?.message || null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isRetryableError(error) {
|
|
61
|
+
const code = String(error?.code || error?.cause?.code || "");
|
|
62
|
+
if (
|
|
63
|
+
code === "ECONNRESET"
|
|
64
|
+
|| code === "ETIMEDOUT"
|
|
65
|
+
|| code === "ECONNREFUSED"
|
|
66
|
+
|| code === "EPIPE"
|
|
67
|
+
|| code === "UND_ERR_SOCKET"
|
|
68
|
+
|| code === "UND_ERR_CONNECT_TIMEOUT"
|
|
69
|
+
|| code === "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC"
|
|
70
|
+
) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (String(error?.message || "").includes("fetch failed")) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return !error?.status || error.status === 429 || error.status >= 500;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function retry(operation, { attempts = 3, baseDelayMs = 250 } = {}) {
|
|
80
|
+
let lastError;
|
|
81
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
82
|
+
try {
|
|
83
|
+
return await operation(index);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
lastError = error;
|
|
86
|
+
const retryable = isRetryableError(error);
|
|
87
|
+
if (!retryable || index === attempts - 1) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
await sleep(baseDelayMs * (2 ** index));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw lastError;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeUploadConcurrency(value) {
|
|
97
|
+
if (value === undefined || value === null || value === "") {
|
|
98
|
+
return DEFAULT_UPLOAD_CONCURRENCY;
|
|
99
|
+
}
|
|
100
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
101
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
102
|
+
throw new TranscribeAPIError("`uploadConcurrency` must be an integer >= 1.", {
|
|
103
|
+
code: "invalid_upload_concurrency",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return Math.min(parsed, MAX_UPLOAD_CONCURRENCY);
|
|
108
107
|
}
|
|
109
108
|
|
|
110
109
|
function normalizePollingConfig(polling) {
|
|
@@ -143,212 +142,97 @@ function normalizePollingConfig(polling) {
|
|
|
143
142
|
timeout,
|
|
144
143
|
};
|
|
145
144
|
}
|
|
146
|
-
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
const fs = await import("node:fs/promises");
|
|
240
|
-
await fs.writeFile(
|
|
241
|
-
multipartResumeStatePath(file.path),
|
|
242
|
-
JSON.stringify(state, null, 2),
|
|
243
|
-
"utf8",
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
async function deleteMultipartResumeState(file) {
|
|
248
|
-
if (!isNodePathFile(file)) {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
try {
|
|
252
|
-
const fs = await import("node:fs/promises");
|
|
253
|
-
await fs.unlink(multipartResumeStatePath(file.path));
|
|
254
|
-
} catch {
|
|
255
|
-
// Ignore missing or inaccessible state files.
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function buildMultipartResumeState({ baseUrl, jobId, model, file, upload, completedParts }) {
|
|
260
|
-
return {
|
|
261
|
-
version: MULTIPART_RESUME_STATE_VERSION,
|
|
262
|
-
api_base_url: baseUrl,
|
|
263
|
-
job_id: jobId,
|
|
264
|
-
model: model || null,
|
|
265
|
-
file_path: file.path,
|
|
266
|
-
file_name: file.name,
|
|
267
|
-
file_size: file.size,
|
|
268
|
-
upload: serializeUploadForResume(upload),
|
|
269
|
-
completed_parts: normalizeCompletedParts(completedParts),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function contentTypeFromName(name = "") {
|
|
274
|
-
const lower = String(name).toLowerCase();
|
|
275
|
-
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
|
276
|
-
if (lower.endsWith(".mpeg")) return "audio/mpeg";
|
|
277
|
-
if (lower.endsWith(".mpga")) return "audio/mpeg";
|
|
278
|
-
if (lower.endsWith(".wav")) return "audio/wav";
|
|
279
|
-
if (lower.endsWith(".m4a")) return "audio/mp4";
|
|
280
|
-
if (lower.endsWith(".webm")) return "audio/webm";
|
|
281
|
-
return "application/octet-stream";
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function isRemoteFileInput(input) {
|
|
285
|
-
return Boolean(input && typeof input === "object" && typeof input.url === "string");
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function isRemoteBatchItem(input) {
|
|
289
|
-
return Boolean(input && typeof input === "object" && typeof input.url === "string");
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function defaultReferenceId(index) {
|
|
293
|
-
return `file_${String(index + 1).padStart(6, "0")}`;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function normalizeBatchInputItem(item, index) {
|
|
297
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
298
|
-
throw new TranscribeAPIError("Each batch item must be an object with `reference_id` and either `file` or `url`.", {
|
|
299
|
-
code: "invalid_batch_item",
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const referenceId = String(item.reference_id || "").trim();
|
|
304
|
-
if (!referenceId) {
|
|
305
|
-
throw new TranscribeAPIError(`files[${index}].reference_id is required.`, {
|
|
306
|
-
code: "missing_reference_id",
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
|
|
311
|
-
const hasUrl = typeof item.url === "string" && item.url.trim();
|
|
312
|
-
if (hasFile && hasUrl) {
|
|
313
|
-
throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`, not both.`, {
|
|
314
|
-
code: "invalid_batch_item",
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
if (!hasFile && !hasUrl) {
|
|
318
|
-
throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`.`, {
|
|
319
|
-
code: "invalid_batch_item",
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return {
|
|
324
|
-
referenceId,
|
|
325
|
-
file: hasFile ? item.file : null,
|
|
326
|
-
url: hasUrl ? item.url.trim() : null,
|
|
327
|
-
durationEstimateSec: item.durationEstimateSec || item.duration_estimate_sec || null,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function uploadDescriptorForFile(referenceId, file) {
|
|
332
|
-
const descriptor = { reference_id: referenceId };
|
|
333
|
-
if (Number(file?.size || 0) >= MULTIPART_UPLOAD_THRESHOLD_BYTES) {
|
|
334
|
-
descriptor.size_bytes = Number(file.size || 0);
|
|
335
|
-
}
|
|
336
|
-
return descriptor;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function normalizeResponseUploads(response) {
|
|
340
|
-
if (Array.isArray(response?.uploads)) {
|
|
341
|
-
return response.uploads;
|
|
342
|
-
}
|
|
343
|
-
if (response?.upload) {
|
|
344
|
-
return [{
|
|
345
|
-
reference_id: response.reference_id || defaultReferenceId(0),
|
|
346
|
-
upload: response.upload,
|
|
347
|
-
}];
|
|
348
|
-
}
|
|
349
|
-
return [];
|
|
350
|
-
}
|
|
351
|
-
|
|
145
|
+
|
|
146
|
+
function contentTypeFromName(name = "") {
|
|
147
|
+
const lower = String(name).toLowerCase();
|
|
148
|
+
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
|
149
|
+
if (lower.endsWith(".mpeg")) return "audio/mpeg";
|
|
150
|
+
if (lower.endsWith(".mpga")) return "audio/mpeg";
|
|
151
|
+
if (lower.endsWith(".wav")) return "audio/wav";
|
|
152
|
+
if (lower.endsWith(".m4a")) return "audio/mp4";
|
|
153
|
+
if (lower.endsWith(".webm")) return "audio/webm";
|
|
154
|
+
return "application/octet-stream";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isRemoteBatchItem(input) {
|
|
158
|
+
return Boolean(input && typeof input === "object" && typeof input.url === "string");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isFilesInput(input) {
|
|
162
|
+
return Array.isArray(input);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeLanguageCode(language, fieldName = "`language`") {
|
|
166
|
+
const value = String(language || "").trim().toLowerCase();
|
|
167
|
+
if (!value || value === "auto") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (!/^[a-z]{2}$/i.test(value)) {
|
|
171
|
+
throw new TranscribeAPIError(`${fieldName} must be a two-letter language code such as \`en\` or \`fr\`.`, {
|
|
172
|
+
code: "invalid_language",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function defaultReferenceId(index) {
|
|
179
|
+
return String(index + 1).padStart(5, "0");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeBatchInputItem(item, index) {
|
|
183
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
184
|
+
throw new TranscribeAPIError("Each batch item must be an object with either `file` or `url`.", {
|
|
185
|
+
code: "invalid_batch_item",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const referenceId = String(item.reference_id || "").trim() || null;
|
|
190
|
+
|
|
191
|
+
const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
|
|
192
|
+
const hasUrl = typeof item.url === "string" && item.url.trim();
|
|
193
|
+
if (hasFile && hasUrl) {
|
|
194
|
+
throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`, not both.`, {
|
|
195
|
+
code: "invalid_batch_item",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (!hasFile && !hasUrl) {
|
|
199
|
+
throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`.`, {
|
|
200
|
+
code: "invalid_batch_item",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
referenceId,
|
|
206
|
+
file: hasFile ? item.file : null,
|
|
207
|
+
url: hasUrl ? item.url.trim() : null,
|
|
208
|
+
durationEstimateSec: item.durationEstimateSec || item.duration_estimate_sec || null,
|
|
209
|
+
hasLanguage: Object.prototype.hasOwnProperty.call(item, "language"),
|
|
210
|
+
language: normalizeLanguageCode(item.language, `files[${index}].language`),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function uploadDescriptorForFile(referenceId, file) {
|
|
215
|
+
const descriptor = {};
|
|
216
|
+
if (referenceId) descriptor.reference_id = referenceId;
|
|
217
|
+
if (Number(file?.size || 0) >= MULTIPART_UPLOAD_THRESHOLD_BYTES) {
|
|
218
|
+
descriptor.size_bytes = Number(file.size || 0);
|
|
219
|
+
}
|
|
220
|
+
return descriptor;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeResponseUploads(response) {
|
|
224
|
+
if (Array.isArray(response?.uploads)) {
|
|
225
|
+
return response.uploads;
|
|
226
|
+
}
|
|
227
|
+
if (response?.upload) {
|
|
228
|
+
return [{
|
|
229
|
+
reference_id: response.reference_id || defaultReferenceId(0),
|
|
230
|
+
upload: response.upload,
|
|
231
|
+
}];
|
|
232
|
+
}
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
|
|
352
236
|
function uploadFromResponse(response, referenceId) {
|
|
353
237
|
if (response?.upload) {
|
|
354
238
|
return response.upload;
|
|
@@ -384,11 +268,14 @@ function formatBytes(bytes) {
|
|
|
384
268
|
|
|
385
269
|
function createSdkLoggerProgressHandler(logger = console) {
|
|
386
270
|
let activeProgressLine = false;
|
|
271
|
+
let lastRenderedLength = 0;
|
|
387
272
|
|
|
388
273
|
const writeLine = (line) => {
|
|
389
274
|
if (activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
|
|
275
|
+
process.stdout.write("\r".padEnd(lastRenderedLength + 1, " "));
|
|
390
276
|
process.stdout.write("\n");
|
|
391
277
|
activeProgressLine = false;
|
|
278
|
+
lastRenderedLength = 0;
|
|
392
279
|
}
|
|
393
280
|
if (typeof logger?.log === "function") {
|
|
394
281
|
logger.log(line);
|
|
@@ -397,7 +284,9 @@ function createSdkLoggerProgressHandler(logger = console) {
|
|
|
397
284
|
|
|
398
285
|
const writeProgress = (line) => {
|
|
399
286
|
if (typeof process !== "undefined" && process?.stdout?.write) {
|
|
400
|
-
|
|
287
|
+
const paddedLine = line.padEnd(lastRenderedLength, " ");
|
|
288
|
+
lastRenderedLength = paddedLine.length;
|
|
289
|
+
process.stdout.write(`\r${paddedLine}`);
|
|
401
290
|
activeProgressLine = true;
|
|
402
291
|
return;
|
|
403
292
|
}
|
|
@@ -411,7 +300,7 @@ function createSdkLoggerProgressHandler(logger = console) {
|
|
|
411
300
|
return;
|
|
412
301
|
}
|
|
413
302
|
if (event.event === "upload_started") {
|
|
414
|
-
writeLine(`Uploading ${event.uploadFiles}
|
|
303
|
+
writeLine(`Uploading ${event.uploadFiles} file(s) for ${event.jobId}`);
|
|
415
304
|
return;
|
|
416
305
|
}
|
|
417
306
|
if (event.event === "upload_progress") {
|
|
@@ -422,11 +311,17 @@ function createSdkLoggerProgressHandler(logger = console) {
|
|
|
422
311
|
if (total && loaded >= total && activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
|
|
423
312
|
process.stdout.write("\n");
|
|
424
313
|
activeProgressLine = false;
|
|
314
|
+
lastRenderedLength = 0;
|
|
425
315
|
}
|
|
426
316
|
return;
|
|
427
317
|
}
|
|
428
318
|
if (event.event === "upload_completed") {
|
|
429
319
|
if (event.suppressLog) {
|
|
320
|
+
if (activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
|
|
321
|
+
process.stdout.write("\n");
|
|
322
|
+
activeProgressLine = false;
|
|
323
|
+
lastRenderedLength = 0;
|
|
324
|
+
}
|
|
430
325
|
return;
|
|
431
326
|
}
|
|
432
327
|
writeLine(`Uploaded completed: ${JSON.stringify(event.response, null, 2)}`);
|
|
@@ -497,9 +392,7 @@ function createSdkPollingLogger(logger = console) {
|
|
|
497
392
|
},
|
|
498
393
|
finish({ jobStatus, resultUrl }) {
|
|
499
394
|
currentStatus = jobStatus || currentStatus;
|
|
500
|
-
writeLine(resultUrl
|
|
501
|
-
? `Polling complete: ${currentStatus} - ${resultUrl}`
|
|
502
|
-
: `Polling complete: ${currentStatus}`);
|
|
395
|
+
writeLine(resultUrl || `Polling complete: ${currentStatus}`);
|
|
503
396
|
},
|
|
504
397
|
timeout({ timeoutSeconds, jobStatus }) {
|
|
505
398
|
currentStatus = jobStatus || currentStatus;
|
|
@@ -508,6 +401,17 @@ function createSdkPollingLogger(logger = console) {
|
|
|
508
401
|
};
|
|
509
402
|
}
|
|
510
403
|
|
|
404
|
+
function logTerminalAsyncResult(result, logger = console) {
|
|
405
|
+
if (typeof logger?.log !== "function" || !result || typeof result !== "object") {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (result.result_url) {
|
|
409
|
+
logger.log(String(result.result_url));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
logger.log(JSON.stringify(result, null, 2));
|
|
413
|
+
}
|
|
414
|
+
|
|
511
415
|
function composeProgressHandler({ onProgress, showLogs = false, logger = console } = {}) {
|
|
512
416
|
const logHandler = showLogs ? createSdkLoggerProgressHandler(logger) : null;
|
|
513
417
|
if (!logHandler) {
|
|
@@ -518,845 +422,742 @@ function composeProgressHandler({ onProgress, showLogs = false, logger = console
|
|
|
518
422
|
emitProgress(onProgress, event);
|
|
519
423
|
};
|
|
520
424
|
}
|
|
521
|
-
|
|
522
|
-
function assertSupportedBatchFormat(file) {
|
|
523
|
-
const lowerName = String(file?.name || "").toLowerCase();
|
|
524
|
-
const lowerType = String(file?.type || contentTypeFromName(file?.name)).toLowerCase();
|
|
525
|
-
|
|
526
|
-
if (lowerName.endsWith(".mp4") || lowerType === "video/mp4") {
|
|
527
|
-
throw new TranscribeAPIError(BATCH_MP4_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (
|
|
531
|
-
lowerName.endsWith(".mp3")
|
|
532
|
-
|| lowerName.endsWith(".mpeg")
|
|
533
|
-
|| lowerName.endsWith(".mpga")
|
|
534
|
-
|| lowerName.endsWith(".m4a")
|
|
535
|
-
|| lowerName.endsWith(".wav")
|
|
536
|
-
|| lowerName.endsWith(".webm")
|
|
537
|
-
|| lowerType.includes("audio/mpeg")
|
|
538
|
-
|| lowerType.includes("mpga")
|
|
539
|
-
|| lowerType.includes("audio/mp4")
|
|
540
|
-
|| lowerType.includes("audio/x-m4a")
|
|
541
|
-
|| lowerType.includes("audio/wav")
|
|
542
|
-
|| lowerType.includes("audio/wave")
|
|
543
|
-
|| lowerType.includes("audio/webm")
|
|
544
|
-
|| lowerType.includes("video/webm")
|
|
545
|
-
) {
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
throw new TranscribeAPIError(BATCH_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function estimateDurationFromSize(sizeBytes) {
|
|
553
|
-
return Math.max(1, Math.ceil(Number(sizeBytes || 0) / 16000));
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function ascii(bytes, offset, length) {
|
|
557
|
-
return Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function readUint32(bytes, offset, littleEndian = false) {
|
|
561
|
-
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, littleEndian);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function readUint64(bytes, offset) {
|
|
565
|
-
const high = readUint32(bytes, offset);
|
|
566
|
-
const low = readUint32(bytes, offset + 4);
|
|
567
|
-
return high * 2 ** 32 + low;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function syncSafeInteger(bytes, offset) {
|
|
571
|
-
return ((bytes[offset] & 0x7f) << 21)
|
|
572
|
-
| ((bytes[offset + 1] & 0x7f) << 14)
|
|
573
|
-
| ((bytes[offset + 2] & 0x7f) << 7)
|
|
574
|
-
| (bytes[offset + 3] & 0x7f);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function id3Offset(bytes) {
|
|
578
|
-
if (bytes.length >= 10 && ascii(bytes, 0, 3) === "ID3") {
|
|
579
|
-
return 10 + syncSafeInteger(bytes, 6);
|
|
580
|
-
}
|
|
581
|
-
return 0;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const MP3_BITRATES = {
|
|
585
|
-
V1L3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
|
|
586
|
-
V2L3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
|
587
|
-
};
|
|
588
|
-
|
|
589
|
-
const MP3_SAMPLE_RATES = {
|
|
590
|
-
3: [44100, 48000, 32000],
|
|
591
|
-
2: [22050, 24000, 16000],
|
|
592
|
-
0: [11025, 12000, 8000],
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
function mp3FrameInfoAt(bytes, offset) {
|
|
596
|
-
if (offset + 4 > bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
|
|
597
|
-
return null;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const versionBits = (bytes[offset + 1] >> 3) & 0x03;
|
|
601
|
-
const layerBits = (bytes[offset + 1] >> 1) & 0x03;
|
|
602
|
-
const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
|
|
603
|
-
const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
|
|
604
|
-
const padding = (bytes[offset + 2] >> 1) & 0x01;
|
|
605
|
-
if (versionBits === 1 || layerBits !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) {
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
const sampleRate = MP3_SAMPLE_RATES[versionBits]?.[sampleRateIndex];
|
|
610
|
-
const bitrateKbps = versionBits === 3
|
|
611
|
-
? MP3_BITRATES.V1L3[bitrateIndex]
|
|
612
|
-
: MP3_BITRATES.V2L3[bitrateIndex];
|
|
613
|
-
if (!sampleRate || !bitrateKbps) {
|
|
614
|
-
return null;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const bitrate = bitrateKbps * 1000;
|
|
618
|
-
const frameLength = Math.floor(((versionBits === 3 ? 144 : 72) * bitrate) / sampleRate + padding);
|
|
619
|
-
if (frameLength <= 0) {
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
return {
|
|
624
|
-
bitrate,
|
|
625
|
-
sampleRate,
|
|
626
|
-
frameLength,
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
function findMp3Frame(bytes, startOffset = 0) {
|
|
631
|
-
for (let offset = Math.max(0, startOffset); offset < bytes.length - 4; offset += 1) {
|
|
632
|
-
const info = mp3FrameInfoAt(bytes, offset);
|
|
633
|
-
if (info) {
|
|
634
|
-
return { offset, ...info };
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
return null;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function parseMp3Duration(bytes, objectSize) {
|
|
641
|
-
const offset = id3Offset(bytes);
|
|
642
|
-
const firstFrame = findMp3Frame(bytes, offset);
|
|
643
|
-
if (!firstFrame) {
|
|
644
|
-
return null;
|
|
645
|
-
}
|
|
646
|
-
const audioBytes = Math.max(0, Number(objectSize || bytes.length) - firstFrame.offset);
|
|
647
|
-
const duration = (audioBytes * 8) / firstFrame.bitrate;
|
|
648
|
-
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
function parseWavDuration(bytes) {
|
|
652
|
-
if (bytes.length < 44 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WAVE") {
|
|
653
|
-
return null;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
let offset = 12;
|
|
657
|
-
let byteRate = 0;
|
|
658
|
-
let dataSize = 0;
|
|
659
|
-
while (offset + 8 <= bytes.length) {
|
|
660
|
-
const chunkId = ascii(bytes, offset, 4);
|
|
661
|
-
const chunkSize = readUint32(bytes, offset + 4, true);
|
|
662
|
-
if (chunkId === "fmt " && offset + 20 <= bytes.length) {
|
|
663
|
-
byteRate = readUint32(bytes, offset + 16, true);
|
|
664
|
-
} else if (chunkId === "data") {
|
|
665
|
-
dataSize = chunkSize;
|
|
666
|
-
break;
|
|
667
|
-
}
|
|
668
|
-
offset += 8 + chunkSize + (chunkSize % 2);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (!byteRate || !dataSize) {
|
|
672
|
-
return null;
|
|
673
|
-
}
|
|
674
|
-
return dataSize / byteRate;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
function parseMp4Duration(bytes) {
|
|
678
|
-
for (let offset = 0; offset + 32 < bytes.length; offset += 1) {
|
|
679
|
-
if (ascii(bytes, offset + 4, 4) !== "mvhd") {
|
|
680
|
-
continue;
|
|
681
|
-
}
|
|
682
|
-
const size = readUint32(bytes, offset);
|
|
683
|
-
if (size < 32 || offset + size > bytes.length + 8) {
|
|
684
|
-
continue;
|
|
685
|
-
}
|
|
686
|
-
const version = bytes[offset + 8];
|
|
687
|
-
if (version === 0 && offset + 28 <= bytes.length) {
|
|
688
|
-
const timescale = readUint32(bytes, offset + 20);
|
|
689
|
-
const duration = readUint32(bytes, offset + 24);
|
|
690
|
-
return timescale > 0 ? duration / timescale : null;
|
|
691
|
-
}
|
|
692
|
-
if (version === 1 && offset + 40 <= bytes.length) {
|
|
693
|
-
const timescale = readUint32(bytes, offset + 28);
|
|
694
|
-
const duration = readUint64(bytes, offset + 32);
|
|
695
|
-
return timescale > 0 ? duration / timescale : null;
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function typeFromNameOrContentType(name = "", contentType = "") {
|
|
702
|
-
const lowerName = String(name || "").toLowerCase();
|
|
703
|
-
const lowerType = String(contentType || "").toLowerCase();
|
|
704
|
-
if (lowerType.includes("wav") || lowerName.endsWith(".wav")) {
|
|
705
|
-
return "wav";
|
|
706
|
-
}
|
|
707
|
-
if (
|
|
708
|
-
lowerType.includes("mp4")
|
|
709
|
-
|| lowerType.includes("m4a")
|
|
710
|
-
|| lowerType.includes("audio/x-m4a")
|
|
711
|
-
|| lowerName.endsWith(".m4a")
|
|
712
|
-
) {
|
|
713
|
-
return "m4a";
|
|
714
|
-
}
|
|
715
|
-
return "mp3";
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
async function estimateDurationFromFile(file) {
|
|
719
|
-
const headerLength = Math.min(Number(file?.size || 0), 2 * 1024 * 1024);
|
|
720
|
-
if (!headerLength) {
|
|
721
|
-
return estimateDurationFromSize(file?.size);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
try {
|
|
725
|
-
let bytes;
|
|
726
|
-
if (typeof file?.readSlice === "function") {
|
|
727
|
-
bytes = new Uint8Array(await file.readSlice(0, headerLength));
|
|
728
|
-
} else if (typeof file?.slice === "function") {
|
|
729
|
-
bytes = new Uint8Array(await file.slice(0, headerLength).arrayBuffer());
|
|
730
|
-
} else {
|
|
731
|
-
return estimateDurationFromSize(file?.size);
|
|
732
|
-
}
|
|
733
|
-
const type = typeFromNameOrContentType(file?.name, file?.type || contentTypeFromName(file?.name));
|
|
734
|
-
const duration = type === "wav"
|
|
735
|
-
? parseWavDuration(bytes)
|
|
736
|
-
: type === "m4a"
|
|
737
|
-
? parseMp4Duration(bytes)
|
|
738
|
-
: parseMp3Duration(bytes, file.size);
|
|
739
|
-
if (Number.isFinite(duration) && duration > 0) {
|
|
740
|
-
return Math.max(1, Math.ceil(duration));
|
|
741
|
-
}
|
|
742
|
-
} catch {
|
|
743
|
-
// Fall back to the coarse size heuristic when metadata parsing fails.
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
return estimateDurationFromSize(file?.size);
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
async function fileFromPath(pathValue) {
|
|
750
|
-
const fs = await import("node:fs/promises");
|
|
751
|
-
const path = await import("node:path");
|
|
752
|
-
const stats = await fs.stat(pathValue);
|
|
753
|
-
const name = path.basename(pathValue);
|
|
754
|
-
return {
|
|
755
|
-
name,
|
|
756
|
-
size: stats.size,
|
|
757
|
-
type: contentTypeFromName(name),
|
|
758
|
-
path: pathValue,
|
|
759
|
-
async readSlice(start, end) {
|
|
760
|
-
const handle = await fs.open(pathValue, "r");
|
|
761
|
-
try {
|
|
762
|
-
const length = Math.max(0, end - start);
|
|
763
|
-
const buffer = Buffer.alloc(length);
|
|
764
|
-
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
765
|
-
return buffer.subarray(0, bytesRead);
|
|
766
|
-
} finally {
|
|
767
|
-
await handle.close();
|
|
768
|
-
}
|
|
769
|
-
},
|
|
770
|
-
};
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
function makeFile(parts, name, type) {
|
|
774
|
-
if (typeof File !== "undefined") {
|
|
775
|
-
return new File(parts, name, { type });
|
|
776
|
-
}
|
|
777
|
-
const blob = new Blob(parts, { type });
|
|
778
|
-
Object.defineProperty(blob, "name", { value: name });
|
|
779
|
-
return blob;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async function normalizeFile(input, fallbackName = "audio.mp3") {
|
|
783
|
-
if (
|
|
784
|
-
input
|
|
785
|
-
&& typeof input === "object"
|
|
786
|
-
&& typeof input.name === "string"
|
|
787
|
-
&& Number.isFinite(Number(input.size))
|
|
788
|
-
&& typeof input.type === "string"
|
|
789
|
-
&& (typeof input.readSlice === "function" || typeof input.slice === "function")
|
|
790
|
-
) {
|
|
791
|
-
return input;
|
|
792
|
-
}
|
|
793
|
-
if (typeof input === "string") {
|
|
794
|
-
return fileFromPath(input);
|
|
795
|
-
}
|
|
796
|
-
if (typeof File !== "undefined" && input instanceof File) {
|
|
797
|
-
return input;
|
|
798
|
-
}
|
|
799
|
-
if (input instanceof Blob) {
|
|
800
|
-
return input.name ? input : makeFile([input], fallbackName, input.type || contentTypeFromName(fallbackName));
|
|
801
|
-
}
|
|
802
|
-
if (input?.data instanceof Uint8Array || input?.data instanceof ArrayBuffer) {
|
|
803
|
-
return makeFile([input.data], input.name || fallbackName, input.type || contentTypeFromName(input.name || fallbackName));
|
|
804
|
-
}
|
|
805
|
-
throw new TranscribeAPIError("Invalid file. Provide a path, File, Blob, or { data, name, type }.");
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
async function parseApiResponse(response) {
|
|
809
|
-
const text = await response.text();
|
|
810
|
-
let data = {};
|
|
811
|
-
try {
|
|
812
|
-
data = text ? JSON.parse(text) : {};
|
|
813
|
-
} catch {
|
|
814
|
-
data =
|
|
815
|
-
}
|
|
425
|
+
|
|
426
|
+
function assertSupportedBatchFormat(file) {
|
|
427
|
+
const lowerName = String(file?.name || "").toLowerCase();
|
|
428
|
+
const lowerType = String(file?.type || contentTypeFromName(file?.name)).toLowerCase();
|
|
429
|
+
|
|
430
|
+
if (lowerName.endsWith(".mp4") || lowerType === "video/mp4") {
|
|
431
|
+
throw new TranscribeAPIError(BATCH_MP4_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (
|
|
435
|
+
lowerName.endsWith(".mp3")
|
|
436
|
+
|| lowerName.endsWith(".mpeg")
|
|
437
|
+
|| lowerName.endsWith(".mpga")
|
|
438
|
+
|| lowerName.endsWith(".m4a")
|
|
439
|
+
|| lowerName.endsWith(".wav")
|
|
440
|
+
|| lowerName.endsWith(".webm")
|
|
441
|
+
|| lowerType.includes("audio/mpeg")
|
|
442
|
+
|| lowerType.includes("mpga")
|
|
443
|
+
|| lowerType.includes("audio/mp4")
|
|
444
|
+
|| lowerType.includes("audio/x-m4a")
|
|
445
|
+
|| lowerType.includes("audio/wav")
|
|
446
|
+
|| lowerType.includes("audio/wave")
|
|
447
|
+
|| lowerType.includes("audio/webm")
|
|
448
|
+
|| lowerType.includes("video/webm")
|
|
449
|
+
) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
throw new TranscribeAPIError(BATCH_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function estimateDurationFromSize(sizeBytes) {
|
|
457
|
+
return Math.max(1, Math.ceil(Number(sizeBytes || 0) / 16000));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function ascii(bytes, offset, length) {
|
|
461
|
+
return Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function readUint32(bytes, offset, littleEndian = false) {
|
|
465
|
+
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, littleEndian);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function readUint64(bytes, offset) {
|
|
469
|
+
const high = readUint32(bytes, offset);
|
|
470
|
+
const low = readUint32(bytes, offset + 4);
|
|
471
|
+
return high * 2 ** 32 + low;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function syncSafeInteger(bytes, offset) {
|
|
475
|
+
return ((bytes[offset] & 0x7f) << 21)
|
|
476
|
+
| ((bytes[offset + 1] & 0x7f) << 14)
|
|
477
|
+
| ((bytes[offset + 2] & 0x7f) << 7)
|
|
478
|
+
| (bytes[offset + 3] & 0x7f);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function id3Offset(bytes) {
|
|
482
|
+
if (bytes.length >= 10 && ascii(bytes, 0, 3) === "ID3") {
|
|
483
|
+
return 10 + syncSafeInteger(bytes, 6);
|
|
484
|
+
}
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const MP3_BITRATES = {
|
|
489
|
+
V1L3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
|
|
490
|
+
V2L3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const MP3_SAMPLE_RATES = {
|
|
494
|
+
3: [44100, 48000, 32000],
|
|
495
|
+
2: [22050, 24000, 16000],
|
|
496
|
+
0: [11025, 12000, 8000],
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
function mp3FrameInfoAt(bytes, offset) {
|
|
500
|
+
if (offset + 4 > bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const versionBits = (bytes[offset + 1] >> 3) & 0x03;
|
|
505
|
+
const layerBits = (bytes[offset + 1] >> 1) & 0x03;
|
|
506
|
+
const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
|
|
507
|
+
const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
|
|
508
|
+
const padding = (bytes[offset + 2] >> 1) & 0x01;
|
|
509
|
+
if (versionBits === 1 || layerBits !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const sampleRate = MP3_SAMPLE_RATES[versionBits]?.[sampleRateIndex];
|
|
514
|
+
const bitrateKbps = versionBits === 3
|
|
515
|
+
? MP3_BITRATES.V1L3[bitrateIndex]
|
|
516
|
+
: MP3_BITRATES.V2L3[bitrateIndex];
|
|
517
|
+
if (!sampleRate || !bitrateKbps) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const bitrate = bitrateKbps * 1000;
|
|
522
|
+
const frameLength = Math.floor(((versionBits === 3 ? 144 : 72) * bitrate) / sampleRate + padding);
|
|
523
|
+
if (frameLength <= 0) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
bitrate,
|
|
529
|
+
sampleRate,
|
|
530
|
+
frameLength,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function findMp3Frame(bytes, startOffset = 0) {
|
|
535
|
+
for (let offset = Math.max(0, startOffset); offset < bytes.length - 4; offset += 1) {
|
|
536
|
+
const info = mp3FrameInfoAt(bytes, offset);
|
|
537
|
+
if (info) {
|
|
538
|
+
return { offset, ...info };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function parseMp3Duration(bytes, objectSize) {
|
|
545
|
+
const offset = id3Offset(bytes);
|
|
546
|
+
const firstFrame = findMp3Frame(bytes, offset);
|
|
547
|
+
if (!firstFrame) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
const audioBytes = Math.max(0, Number(objectSize || bytes.length) - firstFrame.offset);
|
|
551
|
+
const duration = (audioBytes * 8) / firstFrame.bitrate;
|
|
552
|
+
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function parseWavDuration(bytes) {
|
|
556
|
+
if (bytes.length < 44 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WAVE") {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let offset = 12;
|
|
561
|
+
let byteRate = 0;
|
|
562
|
+
let dataSize = 0;
|
|
563
|
+
while (offset + 8 <= bytes.length) {
|
|
564
|
+
const chunkId = ascii(bytes, offset, 4);
|
|
565
|
+
const chunkSize = readUint32(bytes, offset + 4, true);
|
|
566
|
+
if (chunkId === "fmt " && offset + 20 <= bytes.length) {
|
|
567
|
+
byteRate = readUint32(bytes, offset + 16, true);
|
|
568
|
+
} else if (chunkId === "data") {
|
|
569
|
+
dataSize = chunkSize;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
offset += 8 + chunkSize + (chunkSize % 2);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!byteRate || !dataSize) {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
return dataSize / byteRate;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function parseMp4Duration(bytes) {
|
|
582
|
+
for (let offset = 0; offset + 32 < bytes.length; offset += 1) {
|
|
583
|
+
if (ascii(bytes, offset + 4, 4) !== "mvhd") {
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const size = readUint32(bytes, offset);
|
|
587
|
+
if (size < 32 || offset + size > bytes.length + 8) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const version = bytes[offset + 8];
|
|
591
|
+
if (version === 0 && offset + 28 <= bytes.length) {
|
|
592
|
+
const timescale = readUint32(bytes, offset + 20);
|
|
593
|
+
const duration = readUint32(bytes, offset + 24);
|
|
594
|
+
return timescale > 0 ? duration / timescale : null;
|
|
595
|
+
}
|
|
596
|
+
if (version === 1 && offset + 40 <= bytes.length) {
|
|
597
|
+
const timescale = readUint32(bytes, offset + 28);
|
|
598
|
+
const duration = readUint64(bytes, offset + 32);
|
|
599
|
+
return timescale > 0 ? duration / timescale : null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function typeFromNameOrContentType(name = "", contentType = "") {
|
|
606
|
+
const lowerName = String(name || "").toLowerCase();
|
|
607
|
+
const lowerType = String(contentType || "").toLowerCase();
|
|
608
|
+
if (lowerType.includes("wav") || lowerName.endsWith(".wav")) {
|
|
609
|
+
return "wav";
|
|
610
|
+
}
|
|
611
|
+
if (
|
|
612
|
+
lowerType.includes("mp4")
|
|
613
|
+
|| lowerType.includes("m4a")
|
|
614
|
+
|| lowerType.includes("audio/x-m4a")
|
|
615
|
+
|| lowerName.endsWith(".m4a")
|
|
616
|
+
) {
|
|
617
|
+
return "m4a";
|
|
618
|
+
}
|
|
619
|
+
return "mp3";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function estimateDurationFromFile(file) {
|
|
623
|
+
const headerLength = Math.min(Number(file?.size || 0), 2 * 1024 * 1024);
|
|
624
|
+
if (!headerLength) {
|
|
625
|
+
return estimateDurationFromSize(file?.size);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
let bytes;
|
|
630
|
+
if (typeof file?.readSlice === "function") {
|
|
631
|
+
bytes = new Uint8Array(await file.readSlice(0, headerLength));
|
|
632
|
+
} else if (typeof file?.slice === "function") {
|
|
633
|
+
bytes = new Uint8Array(await file.slice(0, headerLength).arrayBuffer());
|
|
634
|
+
} else {
|
|
635
|
+
return estimateDurationFromSize(file?.size);
|
|
636
|
+
}
|
|
637
|
+
const type = typeFromNameOrContentType(file?.name, file?.type || contentTypeFromName(file?.name));
|
|
638
|
+
const duration = type === "wav"
|
|
639
|
+
? parseWavDuration(bytes)
|
|
640
|
+
: type === "m4a"
|
|
641
|
+
? parseMp4Duration(bytes)
|
|
642
|
+
: parseMp3Duration(bytes, file.size);
|
|
643
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
644
|
+
return Math.max(1, Math.ceil(duration));
|
|
645
|
+
}
|
|
646
|
+
} catch {
|
|
647
|
+
// Fall back to the coarse size heuristic when metadata parsing fails.
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return estimateDurationFromSize(file?.size);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function fileFromPath(pathValue) {
|
|
654
|
+
const fs = await import("node:fs/promises");
|
|
655
|
+
const path = await import("node:path");
|
|
656
|
+
const stats = await fs.stat(pathValue);
|
|
657
|
+
const name = path.basename(pathValue);
|
|
658
|
+
return {
|
|
659
|
+
name,
|
|
660
|
+
size: stats.size,
|
|
661
|
+
type: contentTypeFromName(name),
|
|
662
|
+
path: pathValue,
|
|
663
|
+
async readSlice(start, end) {
|
|
664
|
+
const handle = await fs.open(pathValue, "r");
|
|
665
|
+
try {
|
|
666
|
+
const length = Math.max(0, end - start);
|
|
667
|
+
const buffer = Buffer.alloc(length);
|
|
668
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
669
|
+
return buffer.subarray(0, bytesRead);
|
|
670
|
+
} finally {
|
|
671
|
+
await handle.close();
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function makeFile(parts, name, type) {
|
|
678
|
+
if (typeof File !== "undefined") {
|
|
679
|
+
return new File(parts, name, { type });
|
|
680
|
+
}
|
|
681
|
+
const blob = new Blob(parts, { type });
|
|
682
|
+
Object.defineProperty(blob, "name", { value: name });
|
|
683
|
+
return blob;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function normalizeFile(input, fallbackName = "audio.mp3") {
|
|
687
|
+
if (
|
|
688
|
+
input
|
|
689
|
+
&& typeof input === "object"
|
|
690
|
+
&& typeof input.name === "string"
|
|
691
|
+
&& Number.isFinite(Number(input.size))
|
|
692
|
+
&& typeof input.type === "string"
|
|
693
|
+
&& (typeof input.readSlice === "function" || typeof input.slice === "function")
|
|
694
|
+
) {
|
|
695
|
+
return input;
|
|
696
|
+
}
|
|
697
|
+
if (typeof input === "string") {
|
|
698
|
+
return fileFromPath(input);
|
|
699
|
+
}
|
|
700
|
+
if (typeof File !== "undefined" && input instanceof File) {
|
|
701
|
+
return input;
|
|
702
|
+
}
|
|
703
|
+
if (input instanceof Blob) {
|
|
704
|
+
return input.name ? input : makeFile([input], fallbackName, input.type || contentTypeFromName(fallbackName));
|
|
705
|
+
}
|
|
706
|
+
if (input?.data instanceof Uint8Array || input?.data instanceof ArrayBuffer) {
|
|
707
|
+
return makeFile([input.data], input.name || fallbackName, input.type || contentTypeFromName(input.name || fallbackName));
|
|
708
|
+
}
|
|
709
|
+
throw new TranscribeAPIError("Invalid file. Provide a path, File, Blob, or { data, name, type }.");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function parseApiResponse(response) {
|
|
713
|
+
const text = await response.text();
|
|
714
|
+
let data = {};
|
|
715
|
+
try {
|
|
716
|
+
data = text ? JSON.parse(text) : {};
|
|
717
|
+
} catch {
|
|
718
|
+
data = text;
|
|
719
|
+
}
|
|
816
720
|
if (!response.ok) {
|
|
817
|
-
const
|
|
818
|
-
const
|
|
721
|
+
const body = data && typeof data === "object" ? data : {};
|
|
722
|
+
const message = body.message || body.error || text || `HTTP ${response.status}`;
|
|
723
|
+
const extra = { ...body };
|
|
819
724
|
delete extra.message;
|
|
820
725
|
delete extra.error;
|
|
821
726
|
delete extra.code;
|
|
822
727
|
throw new TranscribeAPIError(message, {
|
|
823
728
|
status: response.status,
|
|
824
|
-
code:
|
|
729
|
+
code: body.code || null,
|
|
825
730
|
extra,
|
|
826
731
|
response: {
|
|
827
732
|
message,
|
|
828
|
-
...(
|
|
733
|
+
...(body.code ? { code: body.code } : {}),
|
|
829
734
|
...extra,
|
|
830
735
|
},
|
|
831
736
|
});
|
|
832
737
|
}
|
|
833
738
|
return data;
|
|
834
739
|
}
|
|
835
|
-
|
|
836
|
-
function normalizeHeaders(headers = {}) {
|
|
837
|
-
return Object.fromEntries(
|
|
838
|
-
Object.entries(headers)
|
|
839
|
-
.filter(([, value]) => value !== undefined && value !== null)
|
|
840
|
-
.map(([key, value]) => [key, String(value)]),
|
|
841
|
-
);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
function isNodeStreamBody(body) {
|
|
845
|
-
return Boolean(body && typeof body === "object" && typeof body.pipe === "function");
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
async function putNodeStream(upload, streamBody, headers) {
|
|
849
|
-
const http = await import("node:http");
|
|
850
|
-
const https = await import("node:https");
|
|
851
|
-
const { pipeline } = await import("node:stream/promises");
|
|
852
|
-
const target = new URL(upload.url || upload);
|
|
853
|
-
const transport = target.protocol === "https:" ? https : http;
|
|
854
|
-
|
|
855
|
-
return new Promise((resolve, reject) => {
|
|
856
|
-
let settled = false;
|
|
857
|
-
const finishResolve = (value) => {
|
|
858
|
-
if (settled) {
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
settled = true;
|
|
862
|
-
resolve(value);
|
|
863
|
-
};
|
|
864
|
-
const finishReject = (error) => {
|
|
865
|
-
if (settled) {
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
settled = true;
|
|
869
|
-
reject(error);
|
|
870
|
-
};
|
|
871
|
-
const request = transport.request(target, {
|
|
872
|
-
method: "PUT",
|
|
873
|
-
headers,
|
|
874
|
-
agent: false,
|
|
875
|
-
}, (response) => {
|
|
876
|
-
response.resume();
|
|
877
|
-
response.on("error", finishReject);
|
|
878
|
-
response.on("end", () => {
|
|
879
|
-
finishResolve({
|
|
880
|
-
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
881
|
-
status: response.statusCode || 0,
|
|
882
|
-
headers: {
|
|
883
|
-
get(name) {
|
|
884
|
-
const value = response.headers[String(name || "").toLowerCase()];
|
|
885
|
-
return Array.isArray(value) ? value[0] : value || null;
|
|
886
|
-
},
|
|
887
|
-
},
|
|
888
|
-
});
|
|
889
|
-
});
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
request.on("error", finishReject);
|
|
893
|
-
request.on("socket", (socket) => {
|
|
894
|
-
socket.on("error", finishReject);
|
|
895
|
-
});
|
|
896
|
-
request.setTimeout(120000, () => {
|
|
897
|
-
request.destroy(new Error("stream_upload_timeout"));
|
|
898
|
-
});
|
|
899
|
-
if (typeof streamBody.on === "function") {
|
|
900
|
-
streamBody.on("error", finishReject);
|
|
901
|
-
}
|
|
902
|
-
pipeline(streamBody, request).catch(finishReject);
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
async function putObjectWithRetry(upload, body, {
|
|
907
|
-
onProgress,
|
|
908
|
-
loadedOffset = 0,
|
|
909
|
-
totalBytes = 0,
|
|
910
|
-
contentLength = null,
|
|
911
|
-
progressMeta = null,
|
|
912
|
-
debugContext = null,
|
|
913
|
-
} = {}) {
|
|
914
|
-
const response = await retry(async (attemptIndex) => {
|
|
915
|
-
const resolvedBody = typeof body === "function" ? await body() : body;
|
|
916
|
-
const headers = normalizeHeaders({
|
|
917
|
-
...(upload.headers || {}),
|
|
918
|
-
...(contentLength !== null ? { "Content-Length": contentLength } : {}),
|
|
919
|
-
...(typeof body === "function" ? { Connection: "close" } : {}),
|
|
920
|
-
});
|
|
921
|
-
const putResponse = isNodeStreamBody(resolvedBody)
|
|
922
|
-
? await putNodeStream(upload, resolvedBody, headers)
|
|
923
|
-
: await fetch(upload.url || upload, {
|
|
924
|
-
method: "PUT",
|
|
925
|
-
headers,
|
|
926
|
-
body: resolvedBody,
|
|
927
|
-
});
|
|
928
|
-
if (!putResponse.ok) {
|
|
929
|
-
throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
|
|
930
|
-
status: putResponse.status,
|
|
931
|
-
code: "upload_failed",
|
|
932
|
-
extra: {
|
|
933
|
-
...(progressMeta || {}),
|
|
934
|
-
...(debugContext || {}),
|
|
935
|
-
content_length: contentLength,
|
|
936
|
-
},
|
|
937
|
-
});
|
|
938
|
-
}
|
|
939
|
-
return putResponse;
|
|
940
|
-
}, {
|
|
941
|
-
attempts: 5,
|
|
942
|
-
baseDelayMs: 1000,
|
|
943
|
-
});
|
|
944
|
-
const transferredBytes = Number(
|
|
945
|
-
contentLength
|
|
946
|
-
?? body?.size
|
|
947
|
-
?? body?.byteLength
|
|
948
|
-
?? 0,
|
|
949
|
-
);
|
|
950
|
-
const loaded = loadedOffset + transferredBytes;
|
|
951
|
-
if (onProgress) {
|
|
952
|
-
onProgress({
|
|
953
|
-
loaded,
|
|
954
|
-
total: totalBytes || loaded,
|
|
955
|
-
...(progressMeta || {}),
|
|
956
|
-
});
|
|
957
|
-
}
|
|
958
|
-
return response;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
async function openFileBody(file, start = 0, end = null) {
|
|
962
|
-
if (!file?.path) {
|
|
963
|
-
return file;
|
|
964
|
-
}
|
|
965
|
-
const fs = await import("node:fs");
|
|
966
|
-
return fs.createReadStream(file.path, end === null ? { start } : { start, end: end - 1 });
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
async function runWithConcurrency(items, concurrency, worker) {
|
|
970
|
-
let index = 0;
|
|
971
|
-
const workerCount = Math.max(1, Math.min(items.length || 1, concurrency));
|
|
972
|
-
const workers = Array.from({ length: workerCount }, async () => {
|
|
973
|
-
while (true) {
|
|
974
|
-
const current = index;
|
|
975
|
-
index += 1;
|
|
976
|
-
if (current >= items.length) {
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
await worker(items[current], current);
|
|
980
|
-
}
|
|
981
|
-
});
|
|
982
|
-
await Promise.all(workers);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
function multipartCompleteXml(parts) {
|
|
986
|
-
const rows = parts
|
|
987
|
-
.sort((a, b) => a.partNumber - b.partNumber)
|
|
988
|
-
.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`)
|
|
989
|
-
.join("");
|
|
990
|
-
return `<CompleteMultipartUpload>${rows}</CompleteMultipartUpload>`;
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
async function uploadMultipart(upload, file, {
|
|
994
|
-
onProgress,
|
|
995
|
-
multipartConcurrency = DEFAULT_MULTIPART_CONCURRENCY,
|
|
996
|
-
resumeState = null,
|
|
997
|
-
onPartComplete = null,
|
|
998
|
-
onConcurrencyChange = null,
|
|
999
|
-
} = {}) {
|
|
1000
|
-
const completed = normalizeCompletedParts(resumeState?.completed_parts).map((part) => ({
|
|
1001
|
-
partNumber: part.part_number,
|
|
1002
|
-
etag: part.etag,
|
|
1003
|
-
}));
|
|
1004
|
-
const totalParts = upload.parts.length;
|
|
1005
|
-
const completedPartNumbers = new Set(completed.map((part) => part.partNumber));
|
|
1006
|
-
let completedBytes = completed.reduce((total, part) => {
|
|
1007
|
-
const start = (part.partNumber - 1) * upload.part_size;
|
|
1008
|
-
const end = Math.min(file.size, start + upload.part_size);
|
|
1009
|
-
return total + Math.max(0, end - start);
|
|
1010
|
-
}, 0);
|
|
1011
|
-
const concurrency = normalizeMultipartConcurrency(multipartConcurrency);
|
|
1012
|
-
let targetConcurrency = concurrency;
|
|
1013
|
-
let activeWorkers = 0;
|
|
1014
|
-
let fatalError = null;
|
|
1015
|
-
const pendingParts = upload.parts.filter((part) => !completedPartNumbers.has(part.part_number));
|
|
1016
|
-
const partAttempts = new Map();
|
|
1017
|
-
|
|
1018
|
-
if (onProgress && completedBytes > 0) {
|
|
1019
|
-
onProgress({
|
|
1020
|
-
loaded: completedBytes,
|
|
1021
|
-
total: file.size,
|
|
1022
|
-
uploadType: "multipart",
|
|
1023
|
-
totalParts,
|
|
1024
|
-
multipartConcurrency: targetConcurrency,
|
|
1025
|
-
resumed: true,
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
|
|
1030
|
-
while (true) {
|
|
1031
|
-
if (fatalError) {
|
|
1032
|
-
return;
|
|
1033
|
-
}
|
|
1034
|
-
if (completedPartNumbers.size >= totalParts) {
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
if (workerIndex >= targetConcurrency) {
|
|
1038
|
-
await sleep(MULTIPART_IDLE_WAIT_MS);
|
|
1039
|
-
continue;
|
|
1040
|
-
}
|
|
1041
|
-
const part = pendingParts.shift();
|
|
1042
|
-
if (!part) {
|
|
1043
|
-
if (!activeWorkers) {
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
await sleep(MULTIPART_IDLE_WAIT_MS);
|
|
1047
|
-
continue;
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
activeWorkers += 1;
|
|
1051
|
-
const start = (part.part_number - 1) * upload.part_size;
|
|
1052
|
-
const end = Math.min(file.size, start + upload.part_size);
|
|
1053
|
-
const chunkSize = end - start;
|
|
1054
|
-
try {
|
|
1055
|
-
const response = await putObjectWithRetry(
|
|
1056
|
-
{ url: part.url },
|
|
1057
|
-
file.path ? (() => openFileBody(file, start, end)) : file.slice(start, end),
|
|
1058
|
-
{
|
|
1059
|
-
onProgress: null,
|
|
1060
|
-
loadedOffset: 0,
|
|
1061
|
-
totalBytes: file.size,
|
|
1062
|
-
contentLength: chunkSize,
|
|
1063
|
-
progressMeta: {
|
|
1064
|
-
uploadType: "multipart",
|
|
1065
|
-
partNumber: part.part_number,
|
|
1066
|
-
totalParts,
|
|
1067
|
-
chunkBytes: chunkSize,
|
|
1068
|
-
},
|
|
1069
|
-
debugContext: {
|
|
1070
|
-
file_name: file?.name || null,
|
|
1071
|
-
file_size: file?.size || null,
|
|
1072
|
-
range_start: start,
|
|
1073
|
-
range_end_exclusive: end,
|
|
1074
|
-
},
|
|
1075
|
-
},
|
|
1076
|
-
);
|
|
1077
|
-
const etag = response.headers.get("ETag") || response.headers.get("etag");
|
|
1078
|
-
completed.push({
|
|
1079
|
-
partNumber: part.part_number,
|
|
1080
|
-
etag,
|
|
1081
|
-
});
|
|
1082
|
-
completedPartNumbers.add(part.part_number);
|
|
1083
|
-
completedBytes += chunkSize;
|
|
1084
|
-
if (onPartComplete) {
|
|
1085
|
-
try {
|
|
1086
|
-
await onPartComplete({
|
|
1087
|
-
partNumber: part.part_number,
|
|
1088
|
-
etag,
|
|
1089
|
-
completedParts: completed,
|
|
1090
|
-
});
|
|
1091
|
-
} catch {
|
|
1092
|
-
// Resume-state persistence is best-effort.
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
if (onProgress) {
|
|
1096
|
-
onProgress({
|
|
1097
|
-
loaded: completedBytes,
|
|
1098
|
-
total: file.size,
|
|
1099
|
-
uploadType: "multipart",
|
|
1100
|
-
partNumber: part.part_number,
|
|
1101
|
-
totalParts,
|
|
1102
|
-
chunkBytes: chunkSize,
|
|
1103
|
-
multipartConcurrency: targetConcurrency,
|
|
1104
|
-
});
|
|
1105
|
-
}
|
|
1106
|
-
} catch (error) {
|
|
1107
|
-
const attempts = (partAttempts.get(part.part_number) || 0) + 1;
|
|
1108
|
-
partAttempts.set(part.part_number, attempts);
|
|
1109
|
-
const retryable = isRetryableError(error);
|
|
1110
|
-
if (retryable && attempts < MAX_MULTIPART_ADAPTIVE_ATTEMPTS) {
|
|
1111
|
-
const previousConcurrency = targetConcurrency;
|
|
1112
|
-
targetConcurrency = Math.max(1, Math.floor(targetConcurrency / 2));
|
|
1113
|
-
pendingParts.push(part);
|
|
1114
|
-
if (onConcurrencyChange && targetConcurrency !== previousConcurrency) {
|
|
1115
|
-
try {
|
|
1116
|
-
onConcurrencyChange({
|
|
1117
|
-
previousConcurrency,
|
|
1118
|
-
nextConcurrency: targetConcurrency,
|
|
1119
|
-
partNumber: part.part_number,
|
|
1120
|
-
attempts,
|
|
1121
|
-
error: extractErrorInfo(error),
|
|
1122
|
-
});
|
|
1123
|
-
} catch {
|
|
1124
|
-
// Ignore observer failures.
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
activeWorkers -= 1;
|
|
1128
|
-
await sleep(Math.min(10000, 1000 * (2 ** Math.min(attempts - 1, 3))));
|
|
1129
|
-
continue;
|
|
1130
|
-
}
|
|
1131
|
-
fatalError = new TranscribeAPIError(
|
|
1132
|
-
`Multipart upload failed for part ${part.part_number}/${totalParts}.`,
|
|
1133
|
-
{
|
|
1134
|
-
status: error?.status || null,
|
|
1135
|
-
code: error?.code || "multipart_part_upload_failed",
|
|
1136
|
-
extra: {
|
|
1137
|
-
cause_error: error?.error || error?.message || String(error),
|
|
1138
|
-
cause_code: error?.code || error?.cause?.code || null,
|
|
1139
|
-
cause_status: error?.status || null,
|
|
1140
|
-
upload_type: "multipart",
|
|
1141
|
-
part_number: part.part_number,
|
|
1142
|
-
total_parts: totalParts,
|
|
1143
|
-
chunk_bytes: chunkSize,
|
|
1144
|
-
completed_bytes_before_failure: completedBytes,
|
|
1145
|
-
multipart_concurrency: targetConcurrency,
|
|
1146
|
-
file_name: file?.name || null,
|
|
1147
|
-
file_size: file?.size || null,
|
|
1148
|
-
range_start: start,
|
|
1149
|
-
range_end_exclusive: end,
|
|
1150
|
-
attempts,
|
|
1151
|
-
},
|
|
1152
|
-
},
|
|
1153
|
-
);
|
|
1154
|
-
activeWorkers -= 1;
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
activeWorkers -= 1;
|
|
1158
|
-
}
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
await Promise.all(workers);
|
|
1162
|
-
if (fatalError) {
|
|
1163
|
-
throw fatalError;
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
const completeResponse = await retry(async () => {
|
|
1167
|
-
const response = await fetch(upload.complete_url, {
|
|
1168
|
-
method: "POST",
|
|
1169
|
-
headers: { "Content-Type": "application/xml" },
|
|
1170
|
-
body: multipartCompleteXml(completed),
|
|
1171
|
-
});
|
|
1172
|
-
if (!response.ok) {
|
|
1173
|
-
throw new TranscribeAPIError(`R2 multipart complete failed with HTTP ${response.status}.`, {
|
|
1174
|
-
status: response.status,
|
|
1175
|
-
code: "multipart_complete_failed",
|
|
1176
|
-
});
|
|
1177
|
-
}
|
|
1178
|
-
return response;
|
|
1179
|
-
});
|
|
1180
|
-
return completeResponse;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
async function uploadUsingInstructions(upload, file, options = {}) {
|
|
1184
|
-
if (upload.type === "multipart") {
|
|
1185
|
-
return uploadMultipart(upload, file, options);
|
|
1186
|
-
}
|
|
1187
|
-
const body = file.path ? (() => openFileBody(file)) : file;
|
|
1188
|
-
return putObjectWithRetry(upload, body, {
|
|
1189
|
-
onProgress: options.onProgress,
|
|
1190
|
-
loadedOffset: 0,
|
|
1191
|
-
totalBytes: file.size,
|
|
1192
|
-
contentLength: file.size,
|
|
1193
|
-
progressMeta: {
|
|
1194
|
-
uploadType: "single_put",
|
|
1195
|
-
chunkBytes: file.size,
|
|
1196
|
-
},
|
|
1197
|
-
debugContext: {
|
|
1198
|
-
file_name: file?.name || null,
|
|
1199
|
-
file_size: file?.size || null,
|
|
1200
|
-
},
|
|
1201
|
-
});
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
function assertBatchLimits(totalFiles, totalSizeBytes) {
|
|
1205
|
-
if (totalFiles > MAX_BATCH_FILES) {
|
|
1206
|
-
throw new TranscribeAPIError(`Batch jobs support up to ${MAX_BATCH_FILES} files.`, {
|
|
1207
|
-
code: "too_many_files",
|
|
1208
|
-
});
|
|
1209
|
-
}
|
|
1210
|
-
if (totalSizeBytes > MAX_BATCH_TOTAL_SIZE_BYTES) {
|
|
1211
|
-
throw new TranscribeAPIError("Batch jobs support up to 10GB total.", {
|
|
1212
|
-
code: "batch_too_large",
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
class BigFileJob {
|
|
1218
|
-
constructor(client, file, createResponse, options) {
|
|
1219
|
-
this.client = client;
|
|
1220
|
-
this.file = file;
|
|
1221
|
-
this.createResponse = createResponse;
|
|
1222
|
-
this.referenceId = options.referenceId || defaultReferenceId(0);
|
|
1223
|
-
this.jobId = createResponse.job_id;
|
|
1224
|
-
this.jobStatus = createResponse.job_status;
|
|
1225
|
-
this.model = createResponse.model || options.model || null;
|
|
1226
|
-
this.uploadInfo = uploadFromResponse(createResponse, this.referenceId);
|
|
1227
|
-
this.options = options;
|
|
1228
|
-
this.resumeState = options.resumeState || null;
|
|
1229
|
-
this.resumeWriteChain = Promise.resolve();
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
async persistResumeState(completedParts = this.resumeState?.completed_parts || []) {
|
|
1233
|
-
if (!isNodePathFile(this.file) || this.uploadInfo?.type !== "multipart") {
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
const state = buildMultipartResumeState({
|
|
1237
|
-
baseUrl: this.client.baseUrl,
|
|
1238
|
-
jobId: this.jobId,
|
|
1239
|
-
model: this.model,
|
|
1240
|
-
file: this.file,
|
|
1241
|
-
upload: this.uploadInfo,
|
|
1242
|
-
completedParts,
|
|
1243
|
-
});
|
|
1244
|
-
this.resumeState = state;
|
|
1245
|
-
this.resumeWriteChain = this.resumeWriteChain
|
|
1246
|
-
.catch(() => {})
|
|
1247
|
-
.then(() => writeMultipartResumeState(this.file, state))
|
|
1248
|
-
.catch(() => {});
|
|
1249
|
-
await this.resumeWriteChain;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
async clearResumeState() {
|
|
1253
|
-
await this.resumeWriteChain.catch(() => {});
|
|
1254
|
-
await deleteMultipartResumeState(this.file);
|
|
1255
|
-
this.resumeState = null;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
async upload({ onProgress } = {}) {
|
|
1259
|
-
const progress = onProgress || this.options.onProgress;
|
|
1260
|
-
if (!this.uploadInfo) {
|
|
1261
|
-
return this.createResponse;
|
|
740
|
+
|
|
741
|
+
function normalizeHeaders(headers = {}) {
|
|
742
|
+
return Object.fromEntries(
|
|
743
|
+
Object.entries(headers)
|
|
744
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
745
|
+
.map(([key, value]) => [key, String(value)]),
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function isNodeStreamBody(body) {
|
|
750
|
+
return Boolean(body && typeof body === "object" && typeof body.pipe === "function");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function putNodeStream(upload, streamBody, headers) {
|
|
754
|
+
const http = await import("node:http");
|
|
755
|
+
const https = await import("node:https");
|
|
756
|
+
const { pipeline } = await import("node:stream/promises");
|
|
757
|
+
const target = new URL(upload.url || upload);
|
|
758
|
+
const transport = target.protocol === "https:" ? https : http;
|
|
759
|
+
|
|
760
|
+
return new Promise((resolve, reject) => {
|
|
761
|
+
let settled = false;
|
|
762
|
+
const finishResolve = (value) => {
|
|
763
|
+
if (settled) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
settled = true;
|
|
767
|
+
resolve(value);
|
|
768
|
+
};
|
|
769
|
+
const finishReject = (error) => {
|
|
770
|
+
if (settled) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
settled = true;
|
|
774
|
+
reject(error);
|
|
775
|
+
};
|
|
776
|
+
const request = transport.request(target, {
|
|
777
|
+
method: "PUT",
|
|
778
|
+
headers,
|
|
779
|
+
agent: false,
|
|
780
|
+
}, (response) => {
|
|
781
|
+
response.resume();
|
|
782
|
+
response.on("error", finishReject);
|
|
783
|
+
response.on("end", () => {
|
|
784
|
+
finishResolve({
|
|
785
|
+
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
786
|
+
status: response.statusCode || 0,
|
|
787
|
+
headers: {
|
|
788
|
+
get(name) {
|
|
789
|
+
const value = response.headers[String(name || "").toLowerCase()];
|
|
790
|
+
return Array.isArray(value) ? value[0] : value || null;
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
request.on("error", finishReject);
|
|
798
|
+
request.on("socket", (socket) => {
|
|
799
|
+
socket.on("error", finishReject);
|
|
800
|
+
});
|
|
801
|
+
request.setTimeout(120000, () => {
|
|
802
|
+
request.destroy(new Error("stream_upload_timeout"));
|
|
803
|
+
});
|
|
804
|
+
if (typeof streamBody.on === "function") {
|
|
805
|
+
streamBody.on("error", finishReject);
|
|
1262
806
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
807
|
+
pipeline(streamBody, request).catch(finishReject);
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function putObjectWithRetry(upload, body, {
|
|
812
|
+
onProgress,
|
|
813
|
+
loadedOffset = 0,
|
|
814
|
+
totalBytes = 0,
|
|
815
|
+
contentLength = null,
|
|
816
|
+
progressMeta = null,
|
|
817
|
+
debugContext = null,
|
|
818
|
+
uploadLimiter = null,
|
|
819
|
+
} = {}) {
|
|
820
|
+
const response = await retry(async (attemptIndex) => {
|
|
821
|
+
const executePut = async () => {
|
|
822
|
+
const resolvedBody = typeof body === "function" ? await body() : body;
|
|
823
|
+
const headers = normalizeHeaders({
|
|
824
|
+
...(upload.headers || {}),
|
|
825
|
+
...(contentLength !== null ? { "Content-Length": contentLength } : {}),
|
|
826
|
+
...(typeof body === "function" ? { Connection: "close" } : {}),
|
|
827
|
+
});
|
|
828
|
+
const putResponse = isNodeStreamBody(resolvedBody)
|
|
829
|
+
? await putNodeStream(upload, resolvedBody, headers)
|
|
830
|
+
: await fetch(upload.url || upload, {
|
|
831
|
+
method: "PUT",
|
|
832
|
+
headers,
|
|
833
|
+
body: resolvedBody,
|
|
834
|
+
});
|
|
835
|
+
if (!putResponse.ok) {
|
|
836
|
+
throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
|
|
837
|
+
status: putResponse.status,
|
|
838
|
+
code: "upload_failed",
|
|
839
|
+
extra: {
|
|
840
|
+
...(progressMeta || {}),
|
|
841
|
+
...(debugContext || {}),
|
|
842
|
+
content_length: contentLength,
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return putResponse;
|
|
847
|
+
};
|
|
848
|
+
const putResponse = uploadLimiter?.withToken
|
|
849
|
+
? await uploadLimiter.withToken(executePut)
|
|
850
|
+
: await executePut();
|
|
851
|
+
if (!putResponse.ok) {
|
|
852
|
+
throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
|
|
853
|
+
status: putResponse.status,
|
|
854
|
+
code: "upload_failed",
|
|
855
|
+
extra: {
|
|
856
|
+
...(progressMeta || {}),
|
|
857
|
+
...(debugContext || {}),
|
|
858
|
+
content_length: contentLength,
|
|
859
|
+
},
|
|
860
|
+
});
|
|
1291
861
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
event: "adaptive_concurrency",
|
|
1310
|
-
previousConcurrency: event.previousConcurrency,
|
|
1311
|
-
multipartConcurrency: event.nextConcurrency,
|
|
1312
|
-
partNumber: event.partNumber,
|
|
1313
|
-
attempts: event.attempts,
|
|
1314
|
-
error: event.error,
|
|
1315
|
-
});
|
|
1316
|
-
}
|
|
1317
|
-
},
|
|
862
|
+
return putResponse;
|
|
863
|
+
}, {
|
|
864
|
+
attempts: 5,
|
|
865
|
+
baseDelayMs: 1000,
|
|
866
|
+
});
|
|
867
|
+
const transferredBytes = Number(
|
|
868
|
+
contentLength
|
|
869
|
+
?? body?.size
|
|
870
|
+
?? body?.byteLength
|
|
871
|
+
?? 0,
|
|
872
|
+
);
|
|
873
|
+
const loaded = loadedOffset + transferredBytes;
|
|
874
|
+
if (onProgress) {
|
|
875
|
+
onProgress({
|
|
876
|
+
loaded,
|
|
877
|
+
total: totalBytes || loaded,
|
|
878
|
+
...(progressMeta || {}),
|
|
1318
879
|
});
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
880
|
+
}
|
|
881
|
+
return response;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function openFileBody(file, start = 0, end = null) {
|
|
885
|
+
if (!file?.path) {
|
|
886
|
+
return file;
|
|
887
|
+
}
|
|
888
|
+
const fs = await import("node:fs");
|
|
889
|
+
return fs.createReadStream(file.path, end === null ? { start } : { start, end: end - 1 });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function runWithConcurrency(items, concurrency, worker) {
|
|
893
|
+
let index = 0;
|
|
894
|
+
const workerCount = Math.max(1, Math.min(items.length || 1, concurrency));
|
|
895
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
896
|
+
while (true) {
|
|
897
|
+
const current = index;
|
|
898
|
+
index += 1;
|
|
899
|
+
if (current >= items.length) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
await worker(items[current], current);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
await Promise.all(workers);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function createUploadConcurrencyLimiter(concurrency) {
|
|
909
|
+
const maxConcurrency = Math.max(1, normalizeUploadConcurrency(concurrency));
|
|
910
|
+
let active = 0;
|
|
911
|
+
const waiters = [];
|
|
912
|
+
|
|
913
|
+
const acquire = async () => {
|
|
914
|
+
if (active < maxConcurrency) {
|
|
915
|
+
active += 1;
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
await new Promise((resolve) => waiters.push(resolve));
|
|
919
|
+
active += 1;
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const release = () => {
|
|
923
|
+
active = Math.max(0, active - 1);
|
|
924
|
+
const next = waiters.shift();
|
|
925
|
+
if (next) {
|
|
926
|
+
next();
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
return {
|
|
931
|
+
concurrency: maxConcurrency,
|
|
932
|
+
async withToken(operation) {
|
|
933
|
+
await acquire();
|
|
934
|
+
try {
|
|
935
|
+
return await operation();
|
|
936
|
+
} finally {
|
|
937
|
+
release();
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function multipartCompleteXml(parts) {
|
|
944
|
+
const rows = parts
|
|
945
|
+
.sort((a, b) => a.partNumber - b.partNumber)
|
|
946
|
+
.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`)
|
|
947
|
+
.join("");
|
|
948
|
+
return `<CompleteMultipartUpload>${rows}</CompleteMultipartUpload>`;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function uploadMultipart(upload, file, {
|
|
952
|
+
onProgress,
|
|
953
|
+
uploadConcurrency = DEFAULT_UPLOAD_CONCURRENCY,
|
|
954
|
+
onConcurrencyChange = null,
|
|
955
|
+
uploadLimiter = null,
|
|
956
|
+
} = {}) {
|
|
957
|
+
const completed = [];
|
|
958
|
+
const totalParts = upload.parts.length;
|
|
959
|
+
const completedPartNumbers = new Set(completed.map((part) => part.partNumber));
|
|
960
|
+
let completedBytes = 0;
|
|
961
|
+
const concurrency = normalizeUploadConcurrency(uploadConcurrency);
|
|
962
|
+
let targetConcurrency = concurrency;
|
|
963
|
+
let activeWorkers = 0;
|
|
964
|
+
let fatalError = null;
|
|
965
|
+
const pendingParts = upload.parts.filter((part) => !completedPartNumbers.has(part.part_number));
|
|
966
|
+
const partAttempts = new Map();
|
|
967
|
+
|
|
968
|
+
const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
|
|
969
|
+
while (true) {
|
|
970
|
+
if (fatalError) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (completedPartNumbers.size >= totalParts) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (workerIndex >= targetConcurrency) {
|
|
977
|
+
await sleep(MULTIPART_IDLE_WAIT_MS);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const part = pendingParts.shift();
|
|
981
|
+
if (!part) {
|
|
982
|
+
if (!activeWorkers) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
await sleep(MULTIPART_IDLE_WAIT_MS);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
activeWorkers += 1;
|
|
990
|
+
const start = (part.part_number - 1) * upload.part_size;
|
|
991
|
+
const end = Math.min(file.size, start + upload.part_size);
|
|
992
|
+
const chunkSize = end - start;
|
|
993
|
+
try {
|
|
994
|
+
const response = await putObjectWithRetry(
|
|
995
|
+
{ url: part.url },
|
|
996
|
+
file.path ? (() => openFileBody(file, start, end)) : file.slice(start, end),
|
|
997
|
+
{
|
|
998
|
+
onProgress: null,
|
|
999
|
+
loadedOffset: 0,
|
|
1000
|
+
totalBytes: file.size,
|
|
1001
|
+
contentLength: chunkSize,
|
|
1002
|
+
progressMeta: {
|
|
1003
|
+
uploadType: "multipart",
|
|
1004
|
+
partNumber: part.part_number,
|
|
1005
|
+
totalParts,
|
|
1006
|
+
chunkBytes: chunkSize,
|
|
1007
|
+
},
|
|
1008
|
+
debugContext: {
|
|
1009
|
+
file_name: file?.name || null,
|
|
1010
|
+
file_size: file?.size || null,
|
|
1011
|
+
range_start: start,
|
|
1012
|
+
range_end_exclusive: end,
|
|
1013
|
+
},
|
|
1014
|
+
uploadLimiter,
|
|
1015
|
+
},
|
|
1016
|
+
);
|
|
1017
|
+
const etag = response.headers.get("ETag") || response.headers.get("etag");
|
|
1018
|
+
completed.push({
|
|
1019
|
+
partNumber: part.part_number,
|
|
1020
|
+
etag,
|
|
1021
|
+
});
|
|
1022
|
+
completedPartNumbers.add(part.part_number);
|
|
1023
|
+
completedBytes += chunkSize;
|
|
1024
|
+
if (onProgress) {
|
|
1025
|
+
onProgress({
|
|
1026
|
+
loaded: completedBytes,
|
|
1027
|
+
total: file.size,
|
|
1028
|
+
uploadType: "multipart",
|
|
1029
|
+
partNumber: part.part_number,
|
|
1030
|
+
totalParts,
|
|
1031
|
+
chunkBytes: chunkSize,
|
|
1032
|
+
multipartConcurrency: targetConcurrency,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
const attempts = (partAttempts.get(part.part_number) || 0) + 1;
|
|
1037
|
+
partAttempts.set(part.part_number, attempts);
|
|
1038
|
+
const retryable = isRetryableError(error);
|
|
1039
|
+
if (retryable && attempts < MAX_MULTIPART_ADAPTIVE_ATTEMPTS) {
|
|
1040
|
+
const previousConcurrency = targetConcurrency;
|
|
1041
|
+
targetConcurrency = Math.max(1, Math.floor(targetConcurrency / 2));
|
|
1042
|
+
pendingParts.push(part);
|
|
1043
|
+
if (onConcurrencyChange && targetConcurrency !== previousConcurrency) {
|
|
1044
|
+
try {
|
|
1045
|
+
onConcurrencyChange({
|
|
1046
|
+
previousConcurrency,
|
|
1047
|
+
nextConcurrency: targetConcurrency,
|
|
1048
|
+
partNumber: part.part_number,
|
|
1049
|
+
attempts,
|
|
1050
|
+
error: extractErrorInfo(error),
|
|
1051
|
+
});
|
|
1052
|
+
} catch {
|
|
1053
|
+
// Ignore observer failures.
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
activeWorkers -= 1;
|
|
1057
|
+
await sleep(Math.min(10000, 1000 * (2 ** Math.min(attempts - 1, 3))));
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
fatalError = new TranscribeAPIError(
|
|
1061
|
+
`Multipart upload failed for part ${part.part_number}/${totalParts}.`,
|
|
1062
|
+
{
|
|
1063
|
+
status: error?.status || null,
|
|
1064
|
+
code: error?.code || "multipart_part_upload_failed",
|
|
1065
|
+
extra: {
|
|
1066
|
+
cause_error: error?.error || error?.message || String(error),
|
|
1067
|
+
cause_code: error?.code || error?.cause?.code || null,
|
|
1068
|
+
cause_status: error?.status || null,
|
|
1069
|
+
upload_type: "multipart",
|
|
1070
|
+
part_number: part.part_number,
|
|
1071
|
+
total_parts: totalParts,
|
|
1072
|
+
chunk_bytes: chunkSize,
|
|
1073
|
+
completed_bytes_before_failure: completedBytes,
|
|
1074
|
+
multipart_concurrency: targetConcurrency,
|
|
1075
|
+
file_name: file?.name || null,
|
|
1076
|
+
file_size: file?.size || null,
|
|
1077
|
+
range_start: start,
|
|
1078
|
+
range_end_exclusive: end,
|
|
1079
|
+
attempts,
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
);
|
|
1083
|
+
activeWorkers -= 1;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
activeWorkers -= 1;
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
await Promise.all(workers);
|
|
1091
|
+
if (fatalError) {
|
|
1092
|
+
throw fatalError;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const completeResponse = await retry(async () => {
|
|
1096
|
+
const response = await fetch(upload.complete_url, {
|
|
1097
|
+
method: "POST",
|
|
1098
|
+
headers: { "Content-Type": "application/xml" },
|
|
1099
|
+
body: multipartCompleteXml(completed),
|
|
1326
1100
|
});
|
|
1327
|
-
|
|
1101
|
+
if (!response.ok) {
|
|
1102
|
+
throw new TranscribeAPIError(`R2 multipart complete failed with HTTP ${response.status}.`, {
|
|
1103
|
+
status: response.status,
|
|
1104
|
+
code: "multipart_complete_failed",
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
return response;
|
|
1108
|
+
});
|
|
1109
|
+
return completeResponse;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function uploadUsingInstructions(upload, file, options = {}) {
|
|
1113
|
+
if (upload.type === "multipart") {
|
|
1114
|
+
return uploadMultipart(upload, file, options);
|
|
1328
1115
|
}
|
|
1116
|
+
const body = file.path ? (() => openFileBody(file)) : file;
|
|
1117
|
+
return putObjectWithRetry(upload, body, {
|
|
1118
|
+
onProgress: options.onProgress,
|
|
1119
|
+
loadedOffset: 0,
|
|
1120
|
+
totalBytes: file.size,
|
|
1121
|
+
contentLength: file.size,
|
|
1122
|
+
progressMeta: {
|
|
1123
|
+
uploadType: "single_put",
|
|
1124
|
+
chunkBytes: file.size,
|
|
1125
|
+
},
|
|
1126
|
+
debugContext: {
|
|
1127
|
+
file_name: file?.name || null,
|
|
1128
|
+
file_size: file?.size || null,
|
|
1129
|
+
},
|
|
1130
|
+
uploadLimiter: options.uploadLimiter || null,
|
|
1131
|
+
});
|
|
1329
1132
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
this.
|
|
1349
|
-
this.
|
|
1350
|
-
this.
|
|
1351
|
-
this.
|
|
1352
|
-
this.
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1133
|
+
|
|
1134
|
+
function assertBatchLimits(totalFiles, totalSizeBytes) {
|
|
1135
|
+
if (totalFiles > MAX_BATCH_FILES) {
|
|
1136
|
+
throw new TranscribeAPIError(`Batch jobs support up to ${MAX_BATCH_FILES} files.`, {
|
|
1137
|
+
code: "too_many_files",
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
if (totalSizeBytes > MAX_BATCH_TOTAL_SIZE_BYTES) {
|
|
1141
|
+
throw new TranscribeAPIError("Batch jobs support up to 10GB total.", {
|
|
1142
|
+
code: "batch_too_large",
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
class BatchJob {
|
|
1148
|
+
constructor(client, files, createResponse, options) {
|
|
1149
|
+
this.client = client;
|
|
1150
|
+
this.files = files;
|
|
1151
|
+
this.createResponse = createResponse;
|
|
1152
|
+
this.jobId = createResponse.job_id;
|
|
1153
|
+
this.jobStatus = createResponse.job_status;
|
|
1154
|
+
this.model = createResponse.model || options.model || null;
|
|
1155
|
+
this.uploadsByReferenceId = new Map(
|
|
1156
|
+
normalizeResponseUploads(createResponse).map((entry) => [entry.reference_id, entry.upload]),
|
|
1157
|
+
);
|
|
1158
|
+
this.options = options;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1360
1161
|
async upload({ onProgress } = {}) {
|
|
1361
1162
|
if (!this.uploadsByReferenceId.size) {
|
|
1362
1163
|
return this.createResponse;
|
|
@@ -1366,6 +1167,7 @@ class BatchJob {
|
|
|
1366
1167
|
const totalBytes = this.files.reduce((sum, item) => sum + Number(item?.file?.size || 0), 0);
|
|
1367
1168
|
const loadedByReferenceId = new Map();
|
|
1368
1169
|
const uploadableFiles = this.files.filter((item) => item.file);
|
|
1170
|
+
const uploadLimiter = this.options.uploadLimiter || createUploadConcurrencyLimiter(this.options.uploadConcurrency);
|
|
1369
1171
|
|
|
1370
1172
|
emitProgress(progress, {
|
|
1371
1173
|
event: "upload_started",
|
|
@@ -1375,17 +1177,16 @@ class BatchJob {
|
|
|
1375
1177
|
totalBytes,
|
|
1376
1178
|
});
|
|
1377
1179
|
|
|
1378
|
-
|
|
1379
|
-
if (!item.file) {
|
|
1380
|
-
continue;
|
|
1381
|
-
}
|
|
1180
|
+
await runWithConcurrency(uploadableFiles, this.options.uploadConcurrency, async (item) => {
|
|
1382
1181
|
const upload = this.uploadsByReferenceId.get(item.referenceId);
|
|
1383
|
-
if (!upload) {
|
|
1384
|
-
throw new TranscribeAPIError(`Missing upload instructions for \`${item.referenceId}\`.`, {
|
|
1385
|
-
code: "missing_upload_instructions",
|
|
1386
|
-
});
|
|
1182
|
+
if (!upload) {
|
|
1183
|
+
throw new TranscribeAPIError(`Missing upload instructions for \`${item.referenceId}\`.`, {
|
|
1184
|
+
code: "missing_upload_instructions",
|
|
1185
|
+
});
|
|
1387
1186
|
}
|
|
1388
1187
|
await uploadUsingInstructions(upload, item.file, {
|
|
1188
|
+
uploadConcurrency: this.options.uploadConcurrency,
|
|
1189
|
+
uploadLimiter,
|
|
1389
1190
|
onProgress: (event) => {
|
|
1390
1191
|
const fileLoaded = Math.max(0, Math.min(Number(event?.loaded || 0), Number(item.file.size || 0)));
|
|
1391
1192
|
loadedByReferenceId.set(item.referenceId, fileLoaded);
|
|
@@ -1404,7 +1205,7 @@ class BatchJob {
|
|
|
1404
1205
|
});
|
|
1405
1206
|
},
|
|
1406
1207
|
});
|
|
1407
|
-
}
|
|
1208
|
+
});
|
|
1408
1209
|
const completion = await this.client.jobs.complete(this.jobId);
|
|
1409
1210
|
emitProgress(progress, {
|
|
1410
1211
|
event: "upload_completed",
|
|
@@ -1414,17 +1215,17 @@ class BatchJob {
|
|
|
1414
1215
|
batchTotal: totalBytes,
|
|
1415
1216
|
totalFiles: this.files.length,
|
|
1416
1217
|
uploadFiles: uploadableFiles.length,
|
|
1417
|
-
suppressLog: Boolean(this.client.polling),
|
|
1218
|
+
suppressLog: Boolean(this.client.polling) && !TERMINAL_JOB_STATUSES.has(String(completion?.job_status || "")),
|
|
1418
1219
|
});
|
|
1419
1220
|
return completion;
|
|
1420
1221
|
}
|
|
1421
1222
|
}
|
|
1422
|
-
|
|
1223
|
+
|
|
1423
1224
|
export class TranscribeAPI {
|
|
1424
1225
|
constructor({
|
|
1425
1226
|
apiKey,
|
|
1426
1227
|
baseUrl = DEFAULT_BASE_URL,
|
|
1427
|
-
|
|
1228
|
+
uploadConcurrency = DEFAULT_UPLOAD_CONCURRENCY,
|
|
1428
1229
|
showLogs = false,
|
|
1429
1230
|
logger = console,
|
|
1430
1231
|
polling = null,
|
|
@@ -1435,13 +1236,10 @@ export class TranscribeAPI {
|
|
|
1435
1236
|
|
|
1436
1237
|
this.apiKey = apiKey;
|
|
1437
1238
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
1438
|
-
this.
|
|
1239
|
+
this.uploadConcurrency = normalizeUploadConcurrency(uploadConcurrency);
|
|
1439
1240
|
this.showLogs = Boolean(showLogs);
|
|
1440
1241
|
this.logger = logger || console;
|
|
1441
1242
|
this.polling = normalizePollingConfig(polling);
|
|
1442
|
-
this.batch = {
|
|
1443
|
-
transcribe: (options) => this.transcribeMany(options),
|
|
1444
|
-
};
|
|
1445
1243
|
this.jobs = {
|
|
1446
1244
|
createBigFile: (options) => this.createBigFileJob(options),
|
|
1447
1245
|
createBatch: (options) => this.createBatchJob(options),
|
|
@@ -1451,29 +1249,20 @@ export class TranscribeAPI {
|
|
|
1451
1249
|
result: (jobId) => this.requestJson(`/transcribe/${jobId}`),
|
|
1452
1250
|
};
|
|
1453
1251
|
}
|
|
1454
|
-
|
|
1455
|
-
async requestJson(path, { method = "GET", body = null, retryable = false } = {}) {
|
|
1456
|
-
const run = async () => {
|
|
1457
|
-
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1458
|
-
method,
|
|
1459
|
-
headers: {
|
|
1460
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
1461
|
-
...(body ? { "Content-Type": "application/json" } : {}),
|
|
1462
|
-
},
|
|
1463
|
-
body: body ? JSON.stringify(body) : null,
|
|
1464
|
-
});
|
|
1465
|
-
return parseApiResponse(response);
|
|
1466
|
-
};
|
|
1467
|
-
return retryable ? retry(run) : run();
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
async refreshBigFileUpload(jobId, { upload } = {}) {
|
|
1471
|
-
const response = await this.requestJson(`/jobs/${jobId}/resume-upload`, {
|
|
1472
|
-
method: "POST",
|
|
1473
|
-
body: { upload },
|
|
1474
|
-
retryable: true,
|
|
1475
|
-
});
|
|
1476
|
-
return response.upload;
|
|
1252
|
+
|
|
1253
|
+
async requestJson(path, { method = "GET", body = null, retryable = false } = {}) {
|
|
1254
|
+
const run = async () => {
|
|
1255
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1256
|
+
method,
|
|
1257
|
+
headers: {
|
|
1258
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
1259
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
1260
|
+
},
|
|
1261
|
+
body: body ? JSON.stringify(body) : null,
|
|
1262
|
+
});
|
|
1263
|
+
return parseApiResponse(response);
|
|
1264
|
+
};
|
|
1265
|
+
return retryable ? retry(run) : run();
|
|
1477
1266
|
}
|
|
1478
1267
|
|
|
1479
1268
|
async waitForJobCompletion(jobId, {
|
|
@@ -1525,120 +1314,104 @@ export class TranscribeAPI {
|
|
|
1525
1314
|
});
|
|
1526
1315
|
return lastJob;
|
|
1527
1316
|
}
|
|
1528
|
-
|
|
1317
|
+
|
|
1529
1318
|
async transcribe({
|
|
1530
|
-
|
|
1319
|
+
files,
|
|
1531
1320
|
webhookUrl,
|
|
1532
1321
|
onProgress,
|
|
1533
1322
|
showLogs,
|
|
1534
1323
|
logger,
|
|
1535
1324
|
language,
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
initialPrompt,
|
|
1539
|
-
vttGranularity,
|
|
1540
|
-
exclude,
|
|
1541
|
-
multipartConcurrency,
|
|
1325
|
+
exclude,
|
|
1326
|
+
uploadConcurrency,
|
|
1542
1327
|
} = {}) {
|
|
1543
1328
|
const progress = composeProgressHandler({
|
|
1544
1329
|
onProgress,
|
|
1545
1330
|
showLogs: showLogs ?? this.showLogs,
|
|
1546
1331
|
logger: logger ?? this.logger,
|
|
1547
1332
|
});
|
|
1548
|
-
if (
|
|
1549
|
-
|
|
1550
|
-
file,
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1333
|
+
if (isFilesInput(files)) {
|
|
1334
|
+
if (files.length === 0) {
|
|
1335
|
+
throw new TranscribeAPIError("Transcribe requires at least one file.", {
|
|
1336
|
+
code: "invalid_files",
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
if (files.length > 1) {
|
|
1340
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
1341
|
+
if (!String(files[index]?.reference_id || "").trim()) {
|
|
1342
|
+
throw new TranscribeAPIError(`files[${index}].reference_id is required when sending multiple files.`, {
|
|
1343
|
+
code: "missing_reference_id",
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return this._transcribeAsync({
|
|
1348
|
+
files,
|
|
1349
|
+
webhookUrl,
|
|
1350
|
+
durationEstimateSec: undefined,
|
|
1351
|
+
onProgress,
|
|
1352
|
+
showLogs,
|
|
1561
1353
|
logger: logger ?? this.logger,
|
|
1562
|
-
|
|
1354
|
+
uploadConcurrency,
|
|
1355
|
+
language,
|
|
1356
|
+
exclude,
|
|
1563
1357
|
});
|
|
1564
1358
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1359
|
+
|
|
1360
|
+
const normalizedItem = normalizeBatchInputItem(files[0], 0);
|
|
1361
|
+
const normalizedSingleFile = normalizedItem.url ? null : await normalizeFile(normalizedItem.file);
|
|
1362
|
+
const estimatedDurationSec = normalizedItem.url
|
|
1363
|
+
? normalizedItem.durationEstimateSec
|
|
1364
|
+
: (normalizedItem.durationEstimateSec || await estimateDurationFromFile(normalizedSingleFile));
|
|
1365
|
+
const effectiveLanguage = normalizedItem.hasLanguage ? normalizedItem.language : normalizeLanguageCode(language);
|
|
1366
|
+
const isAsync = files.length > 1
|
|
1367
|
+
|| Boolean(normalizedItem.url)
|
|
1368
|
+
|| Boolean(webhookUrl)
|
|
1369
|
+
|| (
|
|
1370
|
+
normalizedSingleFile
|
|
1371
|
+
&& (
|
|
1372
|
+
normalizedSingleFile.size > MAX_SYNC_AUDIO_BYTES
|
|
1373
|
+
|| estimatedDurationSec > MAX_SYNC_AUDIO_SECONDS
|
|
1374
|
+
)
|
|
1375
|
+
);
|
|
1376
|
+
if (isAsync) {
|
|
1377
|
+
return this._transcribeAsync({
|
|
1378
|
+
files,
|
|
1379
|
+
webhookUrl,
|
|
1380
|
+
durationEstimateSec: estimatedDurationSec,
|
|
1381
|
+
onProgress,
|
|
1382
|
+
showLogs,
|
|
1584
1383
|
logger: logger ?? this.logger,
|
|
1585
|
-
|
|
1384
|
+
uploadConcurrency,
|
|
1385
|
+
language,
|
|
1386
|
+
exclude,
|
|
1586
1387
|
});
|
|
1587
1388
|
}
|
|
1588
|
-
|
|
1389
|
+
|
|
1390
|
+
return this.transcribeDirect({
|
|
1391
|
+
file: normalizedSingleFile,
|
|
1392
|
+
referenceId: normalizedItem.referenceId,
|
|
1393
|
+
language: effectiveLanguage,
|
|
1394
|
+
exclude,
|
|
1395
|
+
webhookUrl,
|
|
1396
|
+
showLogs: showLogs ?? this.showLogs,
|
|
1397
|
+
logger: logger ?? this.logger,
|
|
1398
|
+
});
|
|
1589
1399
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
initialPrompt,
|
|
1597
|
-
vttGranularity,
|
|
1598
|
-
exclude,
|
|
1599
|
-
});
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
async transcribeDirect({
|
|
1603
|
-
file,
|
|
1604
|
-
language,
|
|
1605
|
-
task,
|
|
1606
|
-
vadFilter,
|
|
1607
|
-
initialPrompt,
|
|
1608
|
-
vttGranularity,
|
|
1609
|
-
exclude,
|
|
1610
|
-
} = {}) {
|
|
1611
|
-
const normalizedFile = await normalizeFile(file);
|
|
1612
|
-
const directFile = normalizedFile.path
|
|
1613
|
-
? makeFile([await normalizedFile.readSlice(0, normalizedFile.size)], normalizedFile.name, normalizedFile.type)
|
|
1614
|
-
: normalizedFile;
|
|
1615
|
-
const form = new FormData();
|
|
1616
|
-
form.set("file", directFile, directFile.name);
|
|
1617
|
-
if (language) form.set("language", language);
|
|
1618
|
-
if (task) form.set("task", task);
|
|
1619
|
-
if (vadFilter !== undefined) form.set("vad_filter", String(Boolean(vadFilter)));
|
|
1620
|
-
if (initialPrompt) form.set("initial_prompt", initialPrompt);
|
|
1621
|
-
if (vttGranularity) form.set("vtt_granularity", vttGranularity);
|
|
1622
|
-
if (exclude) form.set("exclude", Array.isArray(exclude) ? exclude.join(",") : exclude);
|
|
1623
|
-
|
|
1624
|
-
const response = await fetch(`${this.baseUrl}/transcribe`, {
|
|
1625
|
-
method: "POST",
|
|
1626
|
-
headers: {
|
|
1627
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
1628
|
-
},
|
|
1629
|
-
body: form,
|
|
1630
|
-
});
|
|
1631
|
-
return parseApiResponse(response);
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
async transcribeMany({
|
|
1400
|
+
throw new TranscribeAPIError("`transcribe` requires a `files` array.", {
|
|
1401
|
+
code: "invalid_files",
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async _transcribeAsync({
|
|
1635
1406
|
files,
|
|
1636
1407
|
webhookUrl,
|
|
1637
1408
|
durationEstimateSec,
|
|
1638
1409
|
onProgress,
|
|
1639
1410
|
showLogs,
|
|
1640
1411
|
logger,
|
|
1641
|
-
|
|
1412
|
+
uploadConcurrency,
|
|
1413
|
+
language,
|
|
1414
|
+
exclude,
|
|
1642
1415
|
} = {}) {
|
|
1643
1416
|
const progress = composeProgressHandler({
|
|
1644
1417
|
onProgress,
|
|
@@ -1651,7 +1424,9 @@ export class TranscribeAPI {
|
|
|
1651
1424
|
durationEstimateSec,
|
|
1652
1425
|
onProgress: progress,
|
|
1653
1426
|
showLogs: false,
|
|
1654
|
-
|
|
1427
|
+
uploadConcurrency,
|
|
1428
|
+
language,
|
|
1429
|
+
exclude,
|
|
1655
1430
|
});
|
|
1656
1431
|
const result = await job.upload({ onProgress: progress });
|
|
1657
1432
|
if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
|
|
@@ -1662,101 +1437,102 @@ export class TranscribeAPI {
|
|
|
1662
1437
|
initialJob: result,
|
|
1663
1438
|
});
|
|
1664
1439
|
}
|
|
1440
|
+
if ((showLogs ?? this.showLogs) && TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
|
|
1441
|
+
logTerminalAsyncResult(result, logger ?? this.logger);
|
|
1442
|
+
}
|
|
1665
1443
|
return result;
|
|
1666
1444
|
}
|
|
1667
1445
|
|
|
1668
|
-
async
|
|
1446
|
+
async transcribeDirect({
|
|
1669
1447
|
file,
|
|
1448
|
+
referenceId,
|
|
1449
|
+
language,
|
|
1450
|
+
exclude,
|
|
1451
|
+
webhookUrl,
|
|
1452
|
+
showLogs = false,
|
|
1453
|
+
logger = console,
|
|
1454
|
+
} = {}) {
|
|
1455
|
+
const normalizedFile = await normalizeFile(file);
|
|
1456
|
+
const directFile = normalizedFile.path
|
|
1457
|
+
? makeFile([await normalizedFile.readSlice(0, normalizedFile.size)], normalizedFile.name, normalizedFile.type)
|
|
1458
|
+
: normalizedFile;
|
|
1459
|
+
const form = new FormData();
|
|
1460
|
+
if (String(referenceId || "").trim()) form.set("reference_id", String(referenceId).trim());
|
|
1461
|
+
form.set("file", directFile, directFile.name);
|
|
1462
|
+
if (language) form.set("language", language);
|
|
1463
|
+
if (exclude) form.set("exclude", Array.isArray(exclude) ? exclude.join(",") : exclude);
|
|
1464
|
+
if (webhookUrl) form.set("webhook_url", webhookUrl);
|
|
1465
|
+
|
|
1466
|
+
const response = await fetch(`${this.baseUrl}/transcribe`, {
|
|
1467
|
+
method: "POST",
|
|
1468
|
+
headers: {
|
|
1469
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
1470
|
+
},
|
|
1471
|
+
body: form,
|
|
1472
|
+
});
|
|
1473
|
+
const result = await parseApiResponse(response);
|
|
1474
|
+
if (this.polling && result && typeof result === "object" && result.job_status && !TERMINAL_JOB_STATUSES.has(String(result.job_status))) {
|
|
1475
|
+
return this.waitForJobCompletion(result.job_id, {
|
|
1476
|
+
polling: this.polling,
|
|
1477
|
+
showLogs,
|
|
1478
|
+
logger,
|
|
1479
|
+
initialJob: result,
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
if (showLogs && typeof logger?.log === "function") {
|
|
1483
|
+
logger.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
1484
|
+
}
|
|
1485
|
+
return result;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async transcribeMany({
|
|
1489
|
+
files,
|
|
1670
1490
|
webhookUrl,
|
|
1671
1491
|
durationEstimateSec,
|
|
1672
1492
|
onProgress,
|
|
1673
1493
|
showLogs,
|
|
1674
1494
|
logger,
|
|
1675
|
-
|
|
1495
|
+
uploadConcurrency,
|
|
1496
|
+
language,
|
|
1497
|
+
exclude,
|
|
1676
1498
|
} = {}) {
|
|
1677
|
-
|
|
1499
|
+
return this._transcribeAsync({
|
|
1500
|
+
files,
|
|
1501
|
+
webhookUrl,
|
|
1502
|
+
durationEstimateSec,
|
|
1678
1503
|
onProgress,
|
|
1679
|
-
showLogs
|
|
1504
|
+
showLogs,
|
|
1680
1505
|
logger: logger ?? this.logger,
|
|
1506
|
+
uploadConcurrency,
|
|
1507
|
+
language,
|
|
1508
|
+
exclude,
|
|
1681
1509
|
});
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
const refreshedUpload = await this.refreshBigFileUpload(resumeState.job_id, {
|
|
1706
|
-
upload: resumeState.upload,
|
|
1707
|
-
});
|
|
1708
|
-
const existingJob = await this.requestJson(`/transcribe/${resumeState.job_id}`, { retryable: true });
|
|
1709
|
-
const currentStatus = existingJob?.job_status || "uploading";
|
|
1710
|
-
return new BigFileJob(this, normalizedFile, {
|
|
1711
|
-
job_id: resumeState.job_id,
|
|
1712
|
-
job_status: currentStatus,
|
|
1713
|
-
model: existingJob?.model || resumeState.model || null,
|
|
1714
|
-
upload: currentStatus === "uploading" ? refreshedUpload : null,
|
|
1715
|
-
}, {
|
|
1716
|
-
onProgress: progress,
|
|
1717
|
-
multipartConcurrency: normalizedConcurrency,
|
|
1718
|
-
referenceId: defaultReferenceId(0),
|
|
1719
|
-
resumeState,
|
|
1720
|
-
});
|
|
1721
|
-
} catch (error) {
|
|
1722
|
-
if (error?.status === 404 || error?.code === "not_found") {
|
|
1723
|
-
await deleteMultipartResumeState(normalizedFile);
|
|
1724
|
-
} else {
|
|
1725
|
-
throw error;
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
const estimate = durationEstimateSec || await estimateDurationFromFile(normalizedFile);
|
|
1730
|
-
const response = await this.requestJson("/transcribe", {
|
|
1731
|
-
method: "POST",
|
|
1732
|
-
body: {
|
|
1733
|
-
files: [
|
|
1734
|
-
estimate > MAX_SYNC_AUDIO_SECONDS || normalizedFile.size > MAX_SYNC_AUDIO_BYTES
|
|
1735
|
-
? uploadDescriptorForFile(defaultReferenceId(0), normalizedFile)
|
|
1736
|
-
: { reference_id: defaultReferenceId(0) },
|
|
1737
|
-
],
|
|
1738
|
-
...(webhookUrl ? { webhook_url: webhookUrl } : {}),
|
|
1739
|
-
},
|
|
1740
|
-
retryable: true,
|
|
1741
|
-
});
|
|
1742
|
-
emitProgress(progress, {
|
|
1743
|
-
event: "upload_urls_received",
|
|
1744
|
-
jobId: response.job_id,
|
|
1745
|
-
jobStatus: response.job_status,
|
|
1746
|
-
uploadCount: normalizeResponseUploads(response).length,
|
|
1747
|
-
totalFiles: 1,
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
async createBigFileJob({
|
|
1513
|
+
file,
|
|
1514
|
+
webhookUrl,
|
|
1515
|
+
durationEstimateSec,
|
|
1516
|
+
onProgress,
|
|
1517
|
+
showLogs,
|
|
1518
|
+
logger,
|
|
1519
|
+
uploadConcurrency,
|
|
1520
|
+
language,
|
|
1521
|
+
exclude,
|
|
1522
|
+
} = {}) {
|
|
1523
|
+
return this.createBatchJob({
|
|
1524
|
+
files: [file],
|
|
1525
|
+
webhookUrl,
|
|
1526
|
+
durationEstimateSec,
|
|
1527
|
+
onProgress,
|
|
1528
|
+
showLogs,
|
|
1529
|
+
logger: logger ?? this.logger,
|
|
1530
|
+
uploadConcurrency,
|
|
1531
|
+
language,
|
|
1532
|
+
exclude,
|
|
1748
1533
|
});
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
multipartConcurrency: normalizedConcurrency,
|
|
1752
|
-
referenceId: defaultReferenceId(0),
|
|
1753
|
-
});
|
|
1754
|
-
if (uploadFromResponse(response, defaultReferenceId(0))?.type === "multipart" && isNodePathFile(normalizedFile)) {
|
|
1755
|
-
await job.persistResumeState([]);
|
|
1756
|
-
}
|
|
1757
|
-
return job;
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1760
1536
|
async createBatchJob({
|
|
1761
1537
|
files,
|
|
1762
1538
|
webhookUrl,
|
|
@@ -1764,56 +1540,69 @@ export class TranscribeAPI {
|
|
|
1764
1540
|
onProgress,
|
|
1765
1541
|
showLogs,
|
|
1766
1542
|
logger,
|
|
1767
|
-
|
|
1543
|
+
uploadConcurrency,
|
|
1544
|
+
language,
|
|
1545
|
+
exclude,
|
|
1768
1546
|
} = {}) {
|
|
1769
1547
|
const progress = composeProgressHandler({
|
|
1770
1548
|
onProgress,
|
|
1771
1549
|
showLogs: showLogs ?? this.showLogs,
|
|
1772
1550
|
logger: logger ?? this.logger,
|
|
1773
1551
|
});
|
|
1552
|
+
const normalizedLanguage = normalizeLanguageCode(language);
|
|
1774
1553
|
if (!Array.isArray(files) || files.length === 0) {
|
|
1775
1554
|
throw new TranscribeAPIError("Batch upload requires at least one file.", { code: "invalid_files" });
|
|
1776
1555
|
}
|
|
1777
|
-
assertBatchLimits(files.length, 0);
|
|
1778
|
-
const normalizedItems = files.map((item, index) => normalizeBatchInputItem(item, index));
|
|
1779
|
-
|
|
1780
|
-
const normalizedBatchItems = [];
|
|
1781
|
-
for (let index = 0; index < normalizedItems.length; index += 1) {
|
|
1782
|
-
const item = normalizedItems[index];
|
|
1783
|
-
if (isRemoteBatchItem(item)) {
|
|
1784
|
-
normalizedBatchItems.push({
|
|
1785
|
-
file: null,
|
|
1786
|
-
referenceId: item.referenceId,
|
|
1787
|
-
url: item.url,
|
|
1788
|
-
durationEstimateSec: item.durationEstimateSec || durationEstimateSec || null,
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1556
|
+
assertBatchLimits(files.length, 0);
|
|
1557
|
+
const normalizedItems = files.map((item, index) => normalizeBatchInputItem(item, index));
|
|
1558
|
+
|
|
1559
|
+
const normalizedBatchItems = [];
|
|
1560
|
+
for (let index = 0; index < normalizedItems.length; index += 1) {
|
|
1561
|
+
const item = normalizedItems[index];
|
|
1562
|
+
if (isRemoteBatchItem(item)) {
|
|
1563
|
+
normalizedBatchItems.push({
|
|
1564
|
+
file: null,
|
|
1565
|
+
referenceId: item.referenceId,
|
|
1566
|
+
url: item.url,
|
|
1567
|
+
durationEstimateSec: item.durationEstimateSec || durationEstimateSec || null,
|
|
1568
|
+
hasLanguage: item.hasLanguage,
|
|
1569
|
+
language: item.language,
|
|
1570
|
+
});
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
const file = await normalizeFile(item.file, `file_${String(index + 1).padStart(6, "0")}.mp3`);
|
|
1575
|
+
assertSupportedBatchFormat(file);
|
|
1576
|
+
normalizedBatchItems.push({
|
|
1577
|
+
file,
|
|
1578
|
+
referenceId: item.referenceId || defaultReferenceId(index),
|
|
1579
|
+
url: null,
|
|
1580
|
+
durationEstimateSec: item.durationEstimateSec || await estimateDurationFromFile(file),
|
|
1581
|
+
hasLanguage: item.hasLanguage,
|
|
1582
|
+
language: item.language,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
const totalSizeBytes = normalizedBatchItems.reduce((total, item) => total + Number(item.file?.size || 0), 0);
|
|
1587
|
+
assertBatchLimits(normalizedBatchItems.length, totalSizeBytes);
|
|
1805
1588
|
const response = await this.requestJson("/transcribe", {
|
|
1806
1589
|
method: "POST",
|
|
1807
1590
|
body: {
|
|
1808
|
-
files: normalizedBatchItems.map((item) => (
|
|
1809
|
-
item.url
|
|
1810
|
-
? {
|
|
1811
|
-
reference_id: item.referenceId,
|
|
1812
|
-
url: item.url,
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1591
|
+
files: normalizedBatchItems.map((item) => (
|
|
1592
|
+
item.url
|
|
1593
|
+
? {
|
|
1594
|
+
...(item.referenceId ? { reference_id: item.referenceId } : {}),
|
|
1595
|
+
url: item.url,
|
|
1596
|
+
...(item.hasLanguage ? { language: item.language } : {}),
|
|
1597
|
+
}
|
|
1598
|
+
: {
|
|
1599
|
+
...uploadDescriptorForFile(item.referenceId, item.file),
|
|
1600
|
+
...(item.hasLanguage ? { language: item.language } : {}),
|
|
1601
|
+
}
|
|
1602
|
+
)),
|
|
1603
|
+
...(normalizedLanguage ? { language: normalizedLanguage } : {}),
|
|
1604
|
+
...(webhookUrl ? { webhook_url: webhookUrl } : {}),
|
|
1605
|
+
...(exclude ? { exclude: Array.isArray(exclude) ? exclude.join(",") : exclude } : {}),
|
|
1817
1606
|
},
|
|
1818
1607
|
retryable: true,
|
|
1819
1608
|
});
|
|
@@ -1827,9 +1616,9 @@ export class TranscribeAPI {
|
|
|
1827
1616
|
return new BatchJob(this, normalizedBatchItems, response, {
|
|
1828
1617
|
onProgress: progress,
|
|
1829
1618
|
webhookUrl,
|
|
1830
|
-
|
|
1619
|
+
uploadConcurrency: normalizeUploadConcurrency(uploadConcurrency ?? this.uploadConcurrency),
|
|
1831
1620
|
});
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
export default TranscribeAPI;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
export default TranscribeAPI;
|