videodubber 0.2.2
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 +35 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +247 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.js +227 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +42 -0
- package/dist/format-api-error.d.ts +1 -0
- package/dist/format-api-error.js +92 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +40 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# videodubber (npm)
|
|
2
|
+
|
|
3
|
+
Official JavaScript/TypeScript client for the [VideoDubber.ai](https://videodubber.ai) video translation API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install videodubber
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Full documentation: [videodubber_client.md](../videodubber_client.md) · [Developer portal](https://videodubber.ai/developers/)
|
|
10
|
+
|
|
11
|
+
## Quick example
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
import { VideoDubberClient } from "videodubber";
|
|
15
|
+
|
|
16
|
+
const client = new VideoDubberClient({ apiKey: process.env.VIDEODUBBER_API_KEY });
|
|
17
|
+
const status = await client.translateFromUrl({
|
|
18
|
+
fileUrl: "https://example.com/video.mp4",
|
|
19
|
+
targetLanguage: "Spanish",
|
|
20
|
+
selectedVoices: ["Elvira"],
|
|
21
|
+
speakers: ["Speaker 1"],
|
|
22
|
+
filetype: "mp4",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
console.log(status.translatedMediaUrl);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Development
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install
|
|
32
|
+
npm run build
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Version is synced from the repo root [`VERSION`](../VERSION) file on build.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { DEFAULT_BASE_URL } from "./constants.js";
|
|
6
|
+
import { VideoDubberClient } from "./client.js";
|
|
7
|
+
import { ApiError, RateLimitError } from "./errors.js";
|
|
8
|
+
import { formatApiError } from "./format-api-error.js";
|
|
9
|
+
function inferFiletype(fileUrl) {
|
|
10
|
+
const pathname = fileUrl.split("?")[0] ?? fileUrl;
|
|
11
|
+
const ext = path.extname(pathname).replace(/^\./, "").toLowerCase();
|
|
12
|
+
return ext || "mp4";
|
|
13
|
+
}
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const options = {
|
|
16
|
+
voices: [],
|
|
17
|
+
speakers: [],
|
|
18
|
+
apiKey: process.env.VIDEODUBBER_API_KEY ?? "",
|
|
19
|
+
baseUrl: process.env.VIDEODUBBER_API_BASE ?? DEFAULT_BASE_URL,
|
|
20
|
+
originalLanguage: "unknown",
|
|
21
|
+
numSpeakers: "1",
|
|
22
|
+
projectName: "API Project",
|
|
23
|
+
voiceCloning: false,
|
|
24
|
+
filetype: "",
|
|
25
|
+
audioshift: "0",
|
|
26
|
+
translator: "auto",
|
|
27
|
+
glossaryId: "",
|
|
28
|
+
bg1: "Auto (Not recommended)",
|
|
29
|
+
pollInterval: 15,
|
|
30
|
+
maxWait: 3600,
|
|
31
|
+
json: false,
|
|
32
|
+
quiet: false,
|
|
33
|
+
debug: false,
|
|
34
|
+
};
|
|
35
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
const next = argv[i + 1];
|
|
38
|
+
switch (arg) {
|
|
39
|
+
case "--file-url":
|
|
40
|
+
options.fileUrl = next;
|
|
41
|
+
i += 1;
|
|
42
|
+
break;
|
|
43
|
+
case "--api-key":
|
|
44
|
+
options.apiKey = next ?? "";
|
|
45
|
+
i += 1;
|
|
46
|
+
break;
|
|
47
|
+
case "--base-url":
|
|
48
|
+
options.baseUrl = next ?? DEFAULT_BASE_URL;
|
|
49
|
+
i += 1;
|
|
50
|
+
break;
|
|
51
|
+
case "--original-language":
|
|
52
|
+
options.originalLanguage = next ?? "unknown";
|
|
53
|
+
i += 1;
|
|
54
|
+
break;
|
|
55
|
+
case "--target-language":
|
|
56
|
+
options.targetLanguage = next ?? "";
|
|
57
|
+
i += 1;
|
|
58
|
+
break;
|
|
59
|
+
case "--num-speakers":
|
|
60
|
+
options.numSpeakers = next ?? "1";
|
|
61
|
+
i += 1;
|
|
62
|
+
break;
|
|
63
|
+
case "--project-name":
|
|
64
|
+
options.projectName = next ?? "API Project";
|
|
65
|
+
i += 1;
|
|
66
|
+
break;
|
|
67
|
+
case "--voice":
|
|
68
|
+
options.voices.push(next ?? "");
|
|
69
|
+
i += 1;
|
|
70
|
+
break;
|
|
71
|
+
case "--speaker":
|
|
72
|
+
options.speakers.push(next ?? "");
|
|
73
|
+
i += 1;
|
|
74
|
+
break;
|
|
75
|
+
case "--voice-cloning":
|
|
76
|
+
options.voiceCloning = true;
|
|
77
|
+
break;
|
|
78
|
+
case "--filetype":
|
|
79
|
+
options.filetype = next ?? "";
|
|
80
|
+
i += 1;
|
|
81
|
+
break;
|
|
82
|
+
case "--audioshift":
|
|
83
|
+
options.audioshift = next ?? "0";
|
|
84
|
+
i += 1;
|
|
85
|
+
break;
|
|
86
|
+
case "--translator":
|
|
87
|
+
options.translator = next ?? "auto";
|
|
88
|
+
i += 1;
|
|
89
|
+
break;
|
|
90
|
+
case "--glossary-id":
|
|
91
|
+
options.glossaryId = next ?? "";
|
|
92
|
+
i += 1;
|
|
93
|
+
break;
|
|
94
|
+
case "--bg1":
|
|
95
|
+
options.bg1 = next ?? "Auto (Not recommended)";
|
|
96
|
+
i += 1;
|
|
97
|
+
break;
|
|
98
|
+
case "--poll-interval":
|
|
99
|
+
options.pollInterval = Number(next ?? 15);
|
|
100
|
+
i += 1;
|
|
101
|
+
break;
|
|
102
|
+
case "--max-wait":
|
|
103
|
+
options.maxWait = Number(next ?? 3600);
|
|
104
|
+
i += 1;
|
|
105
|
+
break;
|
|
106
|
+
case "--output":
|
|
107
|
+
options.output = next;
|
|
108
|
+
i += 1;
|
|
109
|
+
break;
|
|
110
|
+
case "--json":
|
|
111
|
+
options.json = true;
|
|
112
|
+
break;
|
|
113
|
+
case "--quiet":
|
|
114
|
+
options.quiet = true;
|
|
115
|
+
break;
|
|
116
|
+
case "--debug":
|
|
117
|
+
options.debug = true;
|
|
118
|
+
break;
|
|
119
|
+
case "-h":
|
|
120
|
+
case "--help":
|
|
121
|
+
printHelp();
|
|
122
|
+
process.exit(0);
|
|
123
|
+
break;
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!options.fileUrl) {
|
|
129
|
+
throw new Error("--file-url is required");
|
|
130
|
+
}
|
|
131
|
+
if (!options.targetLanguage) {
|
|
132
|
+
throw new Error("--target-language is required");
|
|
133
|
+
}
|
|
134
|
+
if (!options.voices.length) {
|
|
135
|
+
throw new Error("At least one --voice is required");
|
|
136
|
+
}
|
|
137
|
+
if (!options.apiKey) {
|
|
138
|
+
throw new Error("Set --api-key or VIDEODUBBER_API_KEY");
|
|
139
|
+
}
|
|
140
|
+
const speakers = options.speakers.length > 0
|
|
141
|
+
? options.speakers
|
|
142
|
+
: options.voices.map((_, index) => `Speaker ${index + 1}`);
|
|
143
|
+
if (speakers.length !== options.voices.length) {
|
|
144
|
+
throw new Error("Provide the same number of --voice and --speaker values");
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
...options,
|
|
148
|
+
speakers,
|
|
149
|
+
filetype: options.filetype || inferFiletype(options.fileUrl),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function printHelp() {
|
|
153
|
+
console.log(`Translate video/audio with the VideoDubber API.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
videodubber --file-url URL --target-language LANG --voice NAME [options]
|
|
157
|
+
|
|
158
|
+
Environment:
|
|
159
|
+
VIDEODUBBER_API_KEY API key (required unless --api-key)
|
|
160
|
+
VIDEODUBBER_API_BASE Override base URL (default: ${DEFAULT_BASE_URL})
|
|
161
|
+
`);
|
|
162
|
+
}
|
|
163
|
+
async function downloadFile(url, dest, quiet) {
|
|
164
|
+
const response = await fetch(url);
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
throw new Error(`Download failed: HTTP ${response.status}`);
|
|
167
|
+
}
|
|
168
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
169
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
170
|
+
await writeFile(dest, buffer);
|
|
171
|
+
if (!quiet) {
|
|
172
|
+
console.error(`Saved ${dest} (${(buffer.length / (1024 * 1024)).toFixed(1)} MiB)`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
176
|
+
const args = parseArgs(argv);
|
|
177
|
+
const params = {
|
|
178
|
+
fileUrl: args.fileUrl,
|
|
179
|
+
targetLanguage: args.targetLanguage,
|
|
180
|
+
originalLanguage: args.originalLanguage,
|
|
181
|
+
numSpeakers: args.numSpeakers,
|
|
182
|
+
projectName: args.projectName,
|
|
183
|
+
filetype: args.filetype,
|
|
184
|
+
selectedVoices: args.voices,
|
|
185
|
+
speakers: args.speakers,
|
|
186
|
+
audioshift: args.audioshift,
|
|
187
|
+
translator: args.translator,
|
|
188
|
+
glossaryId: args.glossaryId,
|
|
189
|
+
bg1: args.bg1,
|
|
190
|
+
voiceCloning: args.voiceCloning,
|
|
191
|
+
};
|
|
192
|
+
const client = new VideoDubberClient({
|
|
193
|
+
apiKey: args.apiKey,
|
|
194
|
+
baseUrl: args.baseUrl,
|
|
195
|
+
verbose: !args.quiet,
|
|
196
|
+
debug: args.debug,
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
if (!args.quiet) {
|
|
200
|
+
console.error(`VideoDubber translate: ${args.originalLanguage} → ${args.targetLanguage}`);
|
|
201
|
+
}
|
|
202
|
+
const status = await client.translateFromUrl(params, {
|
|
203
|
+
pollInterval: args.pollInterval,
|
|
204
|
+
maxWait: args.maxWait,
|
|
205
|
+
});
|
|
206
|
+
if (args.json) {
|
|
207
|
+
console.log(JSON.stringify(status.raw, null, 2));
|
|
208
|
+
}
|
|
209
|
+
else if (!args.quiet) {
|
|
210
|
+
console.log(`status=${status.status} pid=${status.pid}`);
|
|
211
|
+
if (status.translatedMediaUrl) {
|
|
212
|
+
console.log(`translated_media=${status.translatedMediaUrl}`);
|
|
213
|
+
}
|
|
214
|
+
if (status.availableMinutes !== undefined) {
|
|
215
|
+
console.log(`available_minutes=${status.availableMinutes}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (args.output && status.translatedMediaUrl) {
|
|
219
|
+
await downloadFile(status.translatedMediaUrl, args.output, args.quiet);
|
|
220
|
+
if (!args.quiet && !args.json) {
|
|
221
|
+
console.log(`saved=${args.output}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!args.quiet) {
|
|
225
|
+
console.error("Done.");
|
|
226
|
+
}
|
|
227
|
+
return status.isComplete ? 0 : 1;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error instanceof RateLimitError) {
|
|
231
|
+
const suffix = error.retryAfter !== undefined ? ` (retry after ~${error.retryAfter}s)` : "";
|
|
232
|
+
console.error(`rate limit: ${error.message}${suffix}`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
if (error instanceof ApiError) {
|
|
236
|
+
console.error(formatApiError(error.body));
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
244
|
+
runCli().then((code) => {
|
|
245
|
+
process.exit(code);
|
|
246
|
+
});
|
|
247
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CreateJobResponse, JobStatus, TranslationJobParams, VideoDubberClientOptions, WaitForJobOptions } from "./types.js";
|
|
2
|
+
export declare class VideoDubberClient {
|
|
3
|
+
private readonly apiKey;
|
|
4
|
+
private readonly baseUrl;
|
|
5
|
+
private readonly timeoutMs;
|
|
6
|
+
private readonly rateLimitMaxRetries;
|
|
7
|
+
private readonly rateLimitBackoffBase;
|
|
8
|
+
private readonly partnerMinInterval;
|
|
9
|
+
private readonly verbose;
|
|
10
|
+
private readonly debug;
|
|
11
|
+
private readonly fetchFn;
|
|
12
|
+
private lastPartnerRequestAt;
|
|
13
|
+
constructor(options: VideoDubberClientOptions);
|
|
14
|
+
private progress;
|
|
15
|
+
private debugStatusRaw;
|
|
16
|
+
private throttleBeforeRequest;
|
|
17
|
+
private markRequestSent;
|
|
18
|
+
private parseRetryAfter;
|
|
19
|
+
private request;
|
|
20
|
+
private raiseApiError;
|
|
21
|
+
health(): Promise<Record<string, unknown>>;
|
|
22
|
+
createJobFromUrl(params: TranslationJobParams): Promise<CreateJobResponse>;
|
|
23
|
+
getJobStatus(pid: string): Promise<JobStatus>;
|
|
24
|
+
waitForJob(pid: string, options?: WaitForJobOptions): Promise<JobStatus>;
|
|
25
|
+
translateFromUrl(params: TranslationJobParams, options?: WaitForJobOptions): Promise<JobStatus>;
|
|
26
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { DEFAULT_BASE_URL, DEFAULT_PARTNER_POLL_INTERVAL, DEFAULT_RATE_LIMIT_BACKOFF_BASE, DEFAULT_RATE_LIMIT_MAX_RETRIES, PARTNER_MIN_REQUEST_INTERVAL, } from "./constants.js";
|
|
2
|
+
import { ApiError, JobFailedError, JobTimeoutError, RateLimitError } from "./errors.js";
|
|
3
|
+
import { parseJobStatus, toJobPayload, } from "./types.js";
|
|
4
|
+
function sleep(seconds) {
|
|
5
|
+
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
6
|
+
}
|
|
7
|
+
function joinUrl(baseUrl, path) {
|
|
8
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
9
|
+
return new URL(normalizedPath, `${baseUrl.replace(/\/$/, "")}/`).toString();
|
|
10
|
+
}
|
|
11
|
+
export class VideoDubberClient {
|
|
12
|
+
apiKey;
|
|
13
|
+
baseUrl;
|
|
14
|
+
timeoutMs;
|
|
15
|
+
rateLimitMaxRetries;
|
|
16
|
+
rateLimitBackoffBase;
|
|
17
|
+
partnerMinInterval;
|
|
18
|
+
verbose;
|
|
19
|
+
debug;
|
|
20
|
+
fetchFn;
|
|
21
|
+
lastPartnerRequestAt = 0;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
if (!options.apiKey) {
|
|
24
|
+
throw new Error("apiKey is required");
|
|
25
|
+
}
|
|
26
|
+
this.apiKey = options.apiKey;
|
|
27
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
28
|
+
this.timeoutMs = options.timeoutMs ?? 120_000;
|
|
29
|
+
this.rateLimitMaxRetries =
|
|
30
|
+
options.rateLimitMaxRetries ?? DEFAULT_RATE_LIMIT_MAX_RETRIES;
|
|
31
|
+
this.rateLimitBackoffBase =
|
|
32
|
+
options.rateLimitBackoffBase ?? DEFAULT_RATE_LIMIT_BACKOFF_BASE;
|
|
33
|
+
this.partnerMinInterval =
|
|
34
|
+
options.partnerMinInterval ?? PARTNER_MIN_REQUEST_INTERVAL;
|
|
35
|
+
this.verbose = options.verbose ?? true;
|
|
36
|
+
this.debug = options.debug ?? false;
|
|
37
|
+
this.fetchFn = options.fetch ?? fetch;
|
|
38
|
+
}
|
|
39
|
+
progress(message) {
|
|
40
|
+
if (this.verbose) {
|
|
41
|
+
console.error(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
debugStatusRaw(status) {
|
|
45
|
+
if (!this.debug) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.progress(" raw status JSON:");
|
|
49
|
+
for (const line of JSON.stringify(status.raw, null, 2).split("\n")) {
|
|
50
|
+
this.progress(` ${line}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async throttleBeforeRequest(path) {
|
|
54
|
+
if (!path.includes("/api/p/")) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const elapsed = (performance.now() - this.lastPartnerRequestAt) / 1000;
|
|
58
|
+
if (elapsed < this.partnerMinInterval) {
|
|
59
|
+
await sleep(this.partnerMinInterval - elapsed);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
markRequestSent(path) {
|
|
63
|
+
if (path.includes("/api/p/")) {
|
|
64
|
+
this.lastPartnerRequestAt = performance.now();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async parseRetryAfter(response, attempt) {
|
|
68
|
+
const header = response.headers.get("Retry-After");
|
|
69
|
+
if (header) {
|
|
70
|
+
const parsed = Number(header);
|
|
71
|
+
if (!Number.isNaN(parsed)) {
|
|
72
|
+
return Math.max(parsed, 0.5);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const body = (await response.clone().json());
|
|
77
|
+
for (const key of ["retry_after", "retryAfter", "Retry-After"]) {
|
|
78
|
+
const value = body[key];
|
|
79
|
+
if (value !== undefined) {
|
|
80
|
+
return Math.max(Number(value), 0.5);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// ignore JSON parse errors
|
|
86
|
+
}
|
|
87
|
+
return Math.min(this.rateLimitBackoffBase * 2 ** attempt, 120);
|
|
88
|
+
}
|
|
89
|
+
async request(method, path, jsonBody) {
|
|
90
|
+
const url = joinUrl(this.baseUrl, path);
|
|
91
|
+
let lastResponse = null;
|
|
92
|
+
for (let attempt = 0; attempt <= this.rateLimitMaxRetries; attempt += 1) {
|
|
93
|
+
await this.throttleBeforeRequest(path);
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
96
|
+
try {
|
|
97
|
+
lastResponse = await this.fetchFn(url, {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
"x-api-key": this.apiKey,
|
|
101
|
+
...(jsonBody ? { "Content-Type": "application/json" } : {}),
|
|
102
|
+
},
|
|
103
|
+
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
}
|
|
110
|
+
this.markRequestSent(path);
|
|
111
|
+
if (lastResponse.status !== 429) {
|
|
112
|
+
return lastResponse;
|
|
113
|
+
}
|
|
114
|
+
if (attempt >= this.rateLimitMaxRetries) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
const wait = await this.parseRetryAfter(lastResponse, attempt);
|
|
118
|
+
this.progress(`Rate limited on ${method} ${path}; retrying in ${wait.toFixed(1)}s ` +
|
|
119
|
+
`(attempt ${attempt + 1}/${this.rateLimitMaxRetries})`);
|
|
120
|
+
await sleep(wait);
|
|
121
|
+
}
|
|
122
|
+
if (!lastResponse) {
|
|
123
|
+
throw new RateLimitError(`Rate limited on ${method} ${path}`);
|
|
124
|
+
}
|
|
125
|
+
throw new RateLimitError(`Rate limited on ${method} ${path} after ${this.rateLimitMaxRetries} retries`, {
|
|
126
|
+
retryAfter: await this.parseRetryAfter(lastResponse, this.rateLimitMaxRetries),
|
|
127
|
+
statusCode: 429,
|
|
128
|
+
body: await lastResponse.text(),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async raiseApiError(response) {
|
|
132
|
+
let body;
|
|
133
|
+
try {
|
|
134
|
+
body = await response.json();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
body = await response.text();
|
|
138
|
+
}
|
|
139
|
+
if (response.status === 429) {
|
|
140
|
+
throw new RateLimitError(`HTTP 429: ${JSON.stringify(body)}`, {
|
|
141
|
+
statusCode: 429,
|
|
142
|
+
body,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
throw new ApiError(response.status, body);
|
|
146
|
+
}
|
|
147
|
+
async health() {
|
|
148
|
+
const response = await this.fetchFn(joinUrl(this.baseUrl, "/"), {
|
|
149
|
+
headers: { "x-api-key": this.apiKey },
|
|
150
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
await this.raiseApiError(response);
|
|
154
|
+
}
|
|
155
|
+
return (await response.json());
|
|
156
|
+
}
|
|
157
|
+
async createJobFromUrl(params) {
|
|
158
|
+
if (!params.fileUrl) {
|
|
159
|
+
throw new Error("fileUrl is required");
|
|
160
|
+
}
|
|
161
|
+
if (!params.selectedVoices?.length) {
|
|
162
|
+
throw new Error("selectedVoices is required (voice display names for target language)");
|
|
163
|
+
}
|
|
164
|
+
this.progress("Submitting job (POST /api/p/jobs) …");
|
|
165
|
+
this.progress(` source: ${params.fileUrl}`);
|
|
166
|
+
this.progress(` ${params.originalLanguage ?? "unknown"} → ${params.targetLanguage}, ` +
|
|
167
|
+
`voices=${JSON.stringify(params.selectedVoices)}`);
|
|
168
|
+
const response = await this.request("POST", "/api/p/jobs", toJobPayload(params));
|
|
169
|
+
if (response.status >= 400) {
|
|
170
|
+
await this.raiseApiError(response);
|
|
171
|
+
}
|
|
172
|
+
const data = (await response.json());
|
|
173
|
+
const pid = String(data.pid ?? "?");
|
|
174
|
+
this.progress(`Job accepted — pid=${pid} (server is downloading media and starting job0)`);
|
|
175
|
+
return data;
|
|
176
|
+
}
|
|
177
|
+
async getJobStatus(pid) {
|
|
178
|
+
const response = await this.request("GET", `/api/p/jobs/${pid}/status`);
|
|
179
|
+
let data = {};
|
|
180
|
+
try {
|
|
181
|
+
data = (await response.json());
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
data = {};
|
|
185
|
+
}
|
|
186
|
+
return parseJobStatus(pid, response.status, data);
|
|
187
|
+
}
|
|
188
|
+
async waitForJob(pid, options = {}) {
|
|
189
|
+
const pollInterval = Math.max(options.pollInterval ?? DEFAULT_PARTNER_POLL_INTERVAL, this.partnerMinInterval);
|
|
190
|
+
const maxWait = options.maxWait ?? 3600;
|
|
191
|
+
const deadline = Date.now() + maxWait * 1000;
|
|
192
|
+
const started = Date.now();
|
|
193
|
+
let pollNum = 0;
|
|
194
|
+
let last = null;
|
|
195
|
+
this.progress(`Waiting for job ${pid} — polling every ${pollInterval.toFixed(0)}s ` +
|
|
196
|
+
`(timeout ${maxWait.toFixed(0)}s)`);
|
|
197
|
+
while (Date.now() < deadline) {
|
|
198
|
+
pollNum += 1;
|
|
199
|
+
last = await this.getJobStatus(pid);
|
|
200
|
+
const elapsed = (Date.now() - started) / 1000;
|
|
201
|
+
const extra = last.availableMinutes !== undefined
|
|
202
|
+
? `, available_minutes=${last.availableMinutes.toFixed(1)}`
|
|
203
|
+
: "";
|
|
204
|
+
this.progress(` [${elapsed.toFixed(0)}s] poll #${pollNum}: status=${last.status}${extra}`);
|
|
205
|
+
this.debugStatusRaw(last);
|
|
206
|
+
if (last.isComplete) {
|
|
207
|
+
this.progress(`Job ${pid} complete in ${elapsed.toFixed(0)}s`);
|
|
208
|
+
if (last.translatedMediaUrl) {
|
|
209
|
+
this.progress(` translated_media=${last.translatedMediaUrl}`);
|
|
210
|
+
}
|
|
211
|
+
return last;
|
|
212
|
+
}
|
|
213
|
+
if (last.status === "failed") {
|
|
214
|
+
throw new JobFailedError(pid, last.raw);
|
|
215
|
+
}
|
|
216
|
+
if (!last.isProcessing && last.httpStatus >= 500) {
|
|
217
|
+
throw new Error(`Job ${pid} error: ${JSON.stringify(last.raw)}`);
|
|
218
|
+
}
|
|
219
|
+
await sleep(pollInterval);
|
|
220
|
+
}
|
|
221
|
+
throw new JobTimeoutError(pid, maxWait, last?.raw ?? null);
|
|
222
|
+
}
|
|
223
|
+
async translateFromUrl(params, options = {}) {
|
|
224
|
+
const created = await this.createJobFromUrl(params);
|
|
225
|
+
return this.waitForJob(String(created.pid), options);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const DEFAULT_BASE_URL = "https://api.videodubber.ai";
|
|
2
|
+
export declare const PARTNER_MIN_REQUEST_INTERVAL = 12;
|
|
3
|
+
export declare const DEFAULT_PARTNER_POLL_INTERVAL = 15;
|
|
4
|
+
export declare const DEFAULT_RATE_LIMIT_MAX_RETRIES = 8;
|
|
5
|
+
export declare const DEFAULT_RATE_LIMIT_BACKOFF_BASE = 2;
|
|
6
|
+
export declare const VERSION = "0.2.2";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const DEFAULT_BASE_URL = "https://api.videodubber.ai";
|
|
2
|
+
export const PARTNER_MIN_REQUEST_INTERVAL = 12.0;
|
|
3
|
+
export const DEFAULT_PARTNER_POLL_INTERVAL = 15.0;
|
|
4
|
+
export const DEFAULT_RATE_LIMIT_MAX_RETRIES = 8;
|
|
5
|
+
export const DEFAULT_RATE_LIMIT_BACKOFF_BASE = 2.0;
|
|
6
|
+
export const VERSION = "0.2.2";
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare class RateLimitError extends Error {
|
|
2
|
+
readonly retryAfter?: number;
|
|
3
|
+
readonly statusCode: number;
|
|
4
|
+
readonly body: unknown;
|
|
5
|
+
constructor(message: string, options?: {
|
|
6
|
+
retryAfter?: number;
|
|
7
|
+
statusCode?: number;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export declare class ApiError extends Error {
|
|
12
|
+
readonly statusCode: number;
|
|
13
|
+
readonly body: unknown;
|
|
14
|
+
constructor(statusCode: number, body: unknown);
|
|
15
|
+
}
|
|
16
|
+
export declare class JobFailedError extends Error {
|
|
17
|
+
readonly pid: string;
|
|
18
|
+
readonly raw: Record<string, unknown>;
|
|
19
|
+
constructor(pid: string, raw: Record<string, unknown>);
|
|
20
|
+
}
|
|
21
|
+
export declare class JobTimeoutError extends Error {
|
|
22
|
+
readonly pid: string;
|
|
23
|
+
readonly last: Record<string, unknown> | null;
|
|
24
|
+
constructor(pid: string, maxWait: number, last: Record<string, unknown> | null);
|
|
25
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class RateLimitError extends Error {
|
|
2
|
+
retryAfter;
|
|
3
|
+
statusCode;
|
|
4
|
+
body;
|
|
5
|
+
constructor(message, options = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "RateLimitError";
|
|
8
|
+
this.retryAfter = options.retryAfter;
|
|
9
|
+
this.statusCode = options.statusCode ?? 429;
|
|
10
|
+
this.body = options.body;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class ApiError extends Error {
|
|
14
|
+
statusCode;
|
|
15
|
+
body;
|
|
16
|
+
constructor(statusCode, body) {
|
|
17
|
+
super(`HTTP ${statusCode}`);
|
|
18
|
+
this.name = "ApiError";
|
|
19
|
+
this.statusCode = statusCode;
|
|
20
|
+
this.body = body;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class JobFailedError extends Error {
|
|
24
|
+
pid;
|
|
25
|
+
raw;
|
|
26
|
+
constructor(pid, raw) {
|
|
27
|
+
super(`Job ${pid} failed: ${JSON.stringify(raw)}`);
|
|
28
|
+
this.name = "JobFailedError";
|
|
29
|
+
this.pid = pid;
|
|
30
|
+
this.raw = raw;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class JobTimeoutError extends Error {
|
|
34
|
+
pid;
|
|
35
|
+
last;
|
|
36
|
+
constructor(pid, maxWait, last) {
|
|
37
|
+
super(`Job ${pid} did not complete within ${maxWait}s`);
|
|
38
|
+
this.name = "JobTimeoutError";
|
|
39
|
+
this.pid = pid;
|
|
40
|
+
this.last = last;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function formatApiError(body: unknown): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export function formatApiError(body) {
|
|
2
|
+
if (!body || typeof body !== "object") {
|
|
3
|
+
return String(body);
|
|
4
|
+
}
|
|
5
|
+
const record = body;
|
|
6
|
+
const lines = [];
|
|
7
|
+
const error = record.error ?? record.alert;
|
|
8
|
+
if (error) {
|
|
9
|
+
lines.push(`error: ${String(error)}`);
|
|
10
|
+
}
|
|
11
|
+
if (record.received !== undefined) {
|
|
12
|
+
lines.push(`received: ${String(record.received)}`);
|
|
13
|
+
}
|
|
14
|
+
const details = Array.isArray(record.details) ? record.details : [];
|
|
15
|
+
if (details.length) {
|
|
16
|
+
if (lines.length) {
|
|
17
|
+
lines.push("");
|
|
18
|
+
}
|
|
19
|
+
for (const detail of details) {
|
|
20
|
+
lines.push(` - ${String(detail)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const acceptedLangs = Array.isArray(record.accepted_target_languages)
|
|
24
|
+
? record.accepted_target_languages
|
|
25
|
+
: [];
|
|
26
|
+
if (acceptedLangs.length) {
|
|
27
|
+
if (lines.length) {
|
|
28
|
+
lines.push("");
|
|
29
|
+
}
|
|
30
|
+
lines.push(`accepted target languages (${acceptedLangs.length}):`);
|
|
31
|
+
lines.push("");
|
|
32
|
+
for (const lang of acceptedLangs) {
|
|
33
|
+
lines.push(` - ${String(lang)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const acceptedVoices = Array.isArray(record.accepted_voices)
|
|
37
|
+
? record.accepted_voices
|
|
38
|
+
: [];
|
|
39
|
+
if (acceptedVoices.length) {
|
|
40
|
+
const targetLanguage = record.target_language ?? "target language";
|
|
41
|
+
if (lines.length) {
|
|
42
|
+
lines.push("");
|
|
43
|
+
}
|
|
44
|
+
lines.push(`accepted voices for ${String(targetLanguage)} (${acceptedVoices.length}):`);
|
|
45
|
+
const byGender = { MALE: [], FEMALE: [] };
|
|
46
|
+
const other = [];
|
|
47
|
+
for (const voice of acceptedVoices) {
|
|
48
|
+
if (voice && typeof voice === "object") {
|
|
49
|
+
const voiceRecord = voice;
|
|
50
|
+
const name = String(voiceRecord.name ?? "");
|
|
51
|
+
const gender = String(voiceRecord.gender ?? "").toUpperCase();
|
|
52
|
+
if (gender === "MALE" || gender === "FEMALE") {
|
|
53
|
+
byGender[gender].push(name);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
other.push(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
other.push(String(voice));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [genderKey, title] of [
|
|
64
|
+
["MALE", "male voices"],
|
|
65
|
+
["FEMALE", "female voices"],
|
|
66
|
+
]) {
|
|
67
|
+
const names = byGender[genderKey];
|
|
68
|
+
if (names.length) {
|
|
69
|
+
lines.push("");
|
|
70
|
+
lines.push(`${title}:`);
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
lines.push(` - ${name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (other.length) {
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push("other voices:");
|
|
79
|
+
for (const name of other) {
|
|
80
|
+
lines.push(` - ${name}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (lines.length === 1 && error) {
|
|
85
|
+
const extra = Object.fromEntries(Object.entries(record).filter(([key]) => key !== "error" && key !== "alert"));
|
|
86
|
+
if (Object.keys(extra).length) {
|
|
87
|
+
lines.push("");
|
|
88
|
+
lines.push(JSON.stringify(extra, null, 2));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { VERSION } from "./constants.js";
|
|
2
|
+
export { DEFAULT_BASE_URL, DEFAULT_PARTNER_POLL_INTERVAL, DEFAULT_RATE_LIMIT_BACKOFF_BASE, DEFAULT_RATE_LIMIT_MAX_RETRIES, PARTNER_MIN_REQUEST_INTERVAL, } from "./constants.js";
|
|
3
|
+
export { VideoDubberClient } from "./client.js";
|
|
4
|
+
export { ApiError, JobFailedError, JobTimeoutError, RateLimitError, } from "./errors.js";
|
|
5
|
+
export { formatApiError } from "./format-api-error.js";
|
|
6
|
+
export type { CreateJobResponse, JobStatus, TranslationJobParams, VideoDubberClientOptions, WaitForJobOptions, } from "./types.js";
|
|
7
|
+
export { parseJobStatus, toJobPayload } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { VERSION } from "./constants.js";
|
|
2
|
+
export { DEFAULT_BASE_URL, DEFAULT_PARTNER_POLL_INTERVAL, DEFAULT_RATE_LIMIT_BACKOFF_BASE, DEFAULT_RATE_LIMIT_MAX_RETRIES, PARTNER_MIN_REQUEST_INTERVAL, } from "./constants.js";
|
|
3
|
+
export { VideoDubberClient } from "./client.js";
|
|
4
|
+
export { ApiError, JobFailedError, JobTimeoutError, RateLimitError, } from "./errors.js";
|
|
5
|
+
export { formatApiError } from "./format-api-error.js";
|
|
6
|
+
export { parseJobStatus, toJobPayload } from "./types.js";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface TranslationJobParams {
|
|
2
|
+
fileUrl: string;
|
|
3
|
+
targetLanguage: string;
|
|
4
|
+
numSpeakers?: string;
|
|
5
|
+
projectName?: string;
|
|
6
|
+
filetype?: string;
|
|
7
|
+
originalLanguage?: string;
|
|
8
|
+
selectedVoices: string[];
|
|
9
|
+
speakers?: string[];
|
|
10
|
+
audioshift?: string;
|
|
11
|
+
translator?: string;
|
|
12
|
+
hasSubtitleFile?: boolean;
|
|
13
|
+
subtitleLanguageType?: string;
|
|
14
|
+
glossaryId?: string;
|
|
15
|
+
bg1?: string;
|
|
16
|
+
bg2?: string;
|
|
17
|
+
voiceCloning?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface JobStatus {
|
|
20
|
+
pid: string;
|
|
21
|
+
status: string;
|
|
22
|
+
httpStatus: number;
|
|
23
|
+
raw: Record<string, unknown>;
|
|
24
|
+
outputUrls: Record<string, unknown>;
|
|
25
|
+
availableMinutes?: number;
|
|
26
|
+
isComplete: boolean;
|
|
27
|
+
isProcessing: boolean;
|
|
28
|
+
translatedMediaUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface VideoDubberClientOptions {
|
|
31
|
+
apiKey: string;
|
|
32
|
+
baseUrl?: string;
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
rateLimitMaxRetries?: number;
|
|
35
|
+
rateLimitBackoffBase?: number;
|
|
36
|
+
partnerMinInterval?: number;
|
|
37
|
+
verbose?: boolean;
|
|
38
|
+
debug?: boolean;
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
}
|
|
41
|
+
export interface WaitForJobOptions {
|
|
42
|
+
pollInterval?: number;
|
|
43
|
+
maxWait?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface CreateJobResponse {
|
|
46
|
+
status: string;
|
|
47
|
+
pid: string;
|
|
48
|
+
project_path?: string;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
export declare function toJobPayload(params: TranslationJobParams): Record<string, unknown>;
|
|
52
|
+
export declare function parseJobStatus(pid: string, httpStatus: number, data: Record<string, unknown>): JobStatus;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function toJobPayload(params) {
|
|
2
|
+
return {
|
|
3
|
+
file_url: params.fileUrl,
|
|
4
|
+
filetype: params.filetype ?? "mp4",
|
|
5
|
+
OriginalLanguage: params.originalLanguage ?? "unknown",
|
|
6
|
+
TargetLanguage: params.targetLanguage,
|
|
7
|
+
NumSpeakers: params.numSpeakers ?? "1",
|
|
8
|
+
projectname: params.projectName ?? "API Project",
|
|
9
|
+
selectedvoices: params.selectedVoices,
|
|
10
|
+
speakers: params.speakers ?? ["Speaker 1"],
|
|
11
|
+
audioshift: params.audioshift ?? "0",
|
|
12
|
+
translator: params.translator ?? "auto",
|
|
13
|
+
has_subtitle_file: String(params.hasSubtitleFile ?? false).toLowerCase(),
|
|
14
|
+
subtitle_language_type: params.subtitleLanguageType ?? "source",
|
|
15
|
+
glossary_id: params.glossaryId ?? "",
|
|
16
|
+
bg1: params.bg1 ?? "Auto (Not recommended)",
|
|
17
|
+
bg2: params.bg2 ?? "0",
|
|
18
|
+
voice_cloning: params.voiceCloning ?? false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function parseJobStatus(pid, httpStatus, data) {
|
|
22
|
+
const status = String(data.status ?? "unknown");
|
|
23
|
+
const outputUrls = data.output_urls ?? {};
|
|
24
|
+
const translatedMediaUrl = typeof outputUrls.translated_media === "string"
|
|
25
|
+
? outputUrls.translated_media
|
|
26
|
+
: undefined;
|
|
27
|
+
return {
|
|
28
|
+
pid,
|
|
29
|
+
status,
|
|
30
|
+
httpStatus,
|
|
31
|
+
raw: data,
|
|
32
|
+
outputUrls,
|
|
33
|
+
availableMinutes: typeof data.available_minutes === "number"
|
|
34
|
+
? data.available_minutes
|
|
35
|
+
: undefined,
|
|
36
|
+
isComplete: status === "complete",
|
|
37
|
+
isProcessing: ["processing", "Need", "Taken_up"].includes(status),
|
|
38
|
+
translatedMediaUrl,
|
|
39
|
+
};
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "videodubber",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Official JavaScript/TypeScript client for the VideoDubber.ai video translation API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"videodubber": "./dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"sync-version": "node scripts/sync-version.mjs",
|
|
22
|
+
"build": "npm run sync-version && tsc",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"videodubber",
|
|
27
|
+
"videodubber.ai",
|
|
28
|
+
"video",
|
|
29
|
+
"translation",
|
|
30
|
+
"dubbing",
|
|
31
|
+
"voice-cloning",
|
|
32
|
+
"api",
|
|
33
|
+
"api-client"
|
|
34
|
+
],
|
|
35
|
+
"author": "VideoDubber <contact@videodubber.ai>",
|
|
36
|
+
"license": "GPL-3.0-or-later",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/Souvic/VideoDubber.git",
|
|
40
|
+
"directory": "js"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/Souvic/VideoDubber/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://videodubber.ai/developers/",
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.15.21",
|
|
51
|
+
"typescript": "^5.8.3"
|
|
52
|
+
}
|
|
53
|
+
}
|