n8n-nodes-camb-ai 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 [Sisay Tadewos]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # n8n-nodes-camb-ai
2
+
3
+ An n8n community node for [Camb.ai](https://camb.ai), covering end-to-end video dubbing across 20+ locales, including Amharic.
4
+
5
+ ## Installation
6
+
7
+ Follow the [n8n community nodes installation guide](https://docs.n8n.io/integrations/community-nodes/installation/), or install manually:
8
+
9
+ ```bash
10
+ npm install n8n-nodes-camb-ai
11
+ ```
12
+
13
+ ## Credentials
14
+
15
+ Requires a **Camb.ai API** credential:
16
+
17
+ | Field | Description |
18
+ |---|---|
19
+ | API Key | Your Camb.ai API key |
20
+
21
+ ## Operations
22
+
23
+ The **Camb.ai Dubbing** node supports:
24
+
25
+ - **Submit & Wait for Result** — submits a dub task and polls internally until it finishes, then returns the dubbed video/audio URLs. Simplest option for most workflows.
26
+ - **Submit Dub Task** — submits a task and returns immediately with a `task_id`, for workflows that poll separately.
27
+ - **Get Dub Status** — checks the status of a previously submitted `task_id`.
28
+ - **Get Dub Result** — fetches the final output for a completed `run_id`.
29
+ - **List Source Languages** / **List Target Languages** — returns the authoritative, currently supported locale list directly from Camb.ai (locales change over time, so this is safer than hardcoding).
30
+
31
+ Supports common locales including English (US/UK), Spanish (Spain/Mexico), Amharic, French, German, Italian, Portuguese (BR/PT), Hindi, Japanese, Korean, Chinese (Simplified/Traditional), Arabic, Russian, Turkish, Dutch, Polish, Indonesian, Vietnamese, Thai, and a custom locale-tag field for anything else Camb.ai adds.
32
+
33
+ ## License
34
+
35
+ MIT
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CambAiApi Credential
5
+ *
6
+ * Camb.ai authenticates every request with a single `x-api-key` header
7
+ * (not Bearer auth). Find your key under the "API" section of the
8
+ * Camb.ai studio dashboard.
9
+ */
10
+ class CambAiApi {
11
+ constructor() {
12
+ this.name = 'cambAiApi';
13
+ this.displayName = 'Camb.ai API';
14
+ this.documentationUrl = 'https://docs.camb.ai/getting-started';
15
+ this.properties = [
16
+ {
17
+ displayName: 'API Key',
18
+ name: 'apiKey',
19
+ type: 'string',
20
+ typeOptions: { password: true },
21
+ default: '',
22
+ required: true,
23
+ description: 'Sent as the x-api-key header on every request.',
24
+ },
25
+ ];
26
+ }
27
+ }
28
+
29
+ module.exports = { CambAiApi };
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const { CambAiDub } = require('./nodes/CambAiDub/CambAiDub.node');
4
+ const { CambAiApi } = require('./credentials/CambAiApi.credentials');
5
+
6
+ module.exports = {
7
+ nodes: [CambAiDub],
8
+ credentials: [CambAiApi],
9
+ };
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+
3
+ const { NodeConnectionTypes, NodeOperationError } = require('n8n-workflow');
4
+
5
+ const BASE_URL = 'https://client.camb.ai/apis';
6
+
7
+ // Common locale tags, mirroring the `Languages.EN_US` style enum from Camb.ai's
8
+ // SDKs. This list is NOT exhaustive — Camb.ai supports 140+ languages/dialects.
9
+ // Use the "List Source Languages" / "List Target Languages" operations below
10
+ // to pull the full, authoritative, up-to-date list directly from Camb.ai;
11
+ // use "Custom" here to type any locale tag that isn't in this shortlist.
12
+ const LANGUAGE_OPTIONS = [
13
+ { name: 'English (US) — en-us', value: 'en-us' },
14
+ { name: 'English (UK) — en-gb', value: 'en-gb' },
15
+ { name: 'Spanish (Spain) — es-es', value: 'es-es' },
16
+ { name: 'Spanish (Mexico) — es-mx', value: 'es-mx' },
17
+ { name: 'Amharic — am-et', value: 'am-et' },
18
+ { name: 'French — fr-fr', value: 'fr-fr' },
19
+ { name: 'German — de-de', value: 'de-de' },
20
+ { name: 'Italian — it-it', value: 'it-it' },
21
+ { name: 'Portuguese (Brazil) — pt-br', value: 'pt-br' },
22
+ { name: 'Portuguese (Portugal) — pt-pt', value: 'pt-pt' },
23
+ { name: 'Hindi — hi-in', value: 'hi-in' },
24
+ { name: 'Japanese — ja-jp', value: 'ja-jp' },
25
+ { name: 'Korean — ko-kr', value: 'ko-kr' },
26
+ { name: 'Chinese (Simplified) — zh-cn', value: 'zh-cn' },
27
+ { name: 'Chinese (Traditional) — zh-tw', value: 'zh-tw' },
28
+ { name: 'Arabic — ar-sa', value: 'ar-sa' },
29
+ { name: 'Russian — ru-ru', value: 'ru-ru' },
30
+ { name: 'Turkish — tr-tr', value: 'tr-tr' },
31
+ { name: 'Dutch — nl-nl', value: 'nl-nl' },
32
+ { name: 'Polish — pl-pl', value: 'pl-pl' },
33
+ { name: 'Indonesian — id-id', value: 'id-id' },
34
+ { name: 'Vietnamese — vi-vn', value: 'vi-vn' },
35
+ { name: 'Thai — th-th', value: 'th-th' },
36
+ { name: 'Custom (type a locale tag below)', value: 'custom' },
37
+ ];
38
+
39
+ async function cambRequest(ctx, { method, url, body, qs }) {
40
+ const credentials = await ctx.getCredentials('cambAiApi');
41
+ const options = {
42
+ method,
43
+ url: `${BASE_URL}${url}`,
44
+ headers: {
45
+ 'x-api-key': credentials.apiKey,
46
+ 'Content-Type': 'application/json',
47
+ },
48
+ json: true,
49
+ };
50
+ if (body) options.body = body;
51
+ if (qs) options.qs = qs;
52
+
53
+ try {
54
+ return await ctx.helpers.httpRequest(options);
55
+ } catch (err) {
56
+ const status = err.response && err.response.statusCode;
57
+ const data = err.response && err.response.body;
58
+ const detail = data ? (typeof data === 'string' ? data : JSON.stringify(data)) : err.message;
59
+ throw new Error(`Camb.ai request failed (${method} ${url}${status ? `, status ${status}` : ''}): ${detail}`);
60
+ }
61
+ }
62
+
63
+ function parseCsvInts(raw) {
64
+ if (!raw) return undefined;
65
+ const arr = String(raw)
66
+ .split(',')
67
+ .map((s) => s.trim())
68
+ .filter(Boolean)
69
+ .map((s) => Number(s));
70
+ return arr.length ? arr : undefined;
71
+ }
72
+
73
+ async function pollDubStatus(ctx, taskId, { pollIntervalSeconds, maxWaitMinutes }) {
74
+ const deadline = Date.now() + maxWaitMinutes * 60 * 1000;
75
+ while (true) {
76
+ const status = await cambRequest(ctx, { method: 'GET', url: `/dub/${taskId}` });
77
+ if (status.status === 'SUCCESS') return status;
78
+ if (status.status === 'FAILED' || status.status === 'ERROR') {
79
+ throw new Error(`Camb.ai dubbing task ${taskId} failed: ${JSON.stringify(status)}`);
80
+ }
81
+ if (Date.now() > deadline) {
82
+ throw new Error(
83
+ `Timed out waiting for Camb.ai dubbing task ${taskId} after ${maxWaitMinutes} minutes (last status: ${status.status})`
84
+ );
85
+ }
86
+ await new Promise((r) => setTimeout(r, pollIntervalSeconds * 1000));
87
+ }
88
+ }
89
+
90
+ class CambAiDub {
91
+ constructor() {
92
+ this.description = {
93
+ displayName: 'Camb.ai Dubbing',
94
+ name: 'cambAiDub',
95
+ icon: 'file:cambai.svg',
96
+ group: ['transform'],
97
+ version: 1,
98
+ description: 'Submit end-to-end video dubbing tasks to Camb.ai and retrieve results',
99
+ defaults: { name: 'Camb.ai Dubbing' },
100
+ inputs: [NodeConnectionTypes.Main],
101
+ outputs: [NodeConnectionTypes.Main],
102
+ credentials: [{ name: 'cambAiApi', required: true }],
103
+ properties: [
104
+ {
105
+ displayName: 'Operation',
106
+ name: 'operation',
107
+ type: 'options',
108
+ options: [
109
+ {
110
+ name: 'Submit & Wait for Result',
111
+ value: 'submitAndWait',
112
+ description: 'Submits a dub task and polls internally until it finishes, then returns the dubbed video/audio URLs',
113
+ },
114
+ { name: 'Submit Dub Task', value: 'submit', description: 'Submits a dub task and returns immediately with a task_id' },
115
+ { name: 'Get Dub Status', value: 'getStatus', description: 'Checks the status of a previously submitted task_id' },
116
+ { name: 'Get Dub Result', value: 'getResult', description: 'Fetches the final output for a completed run_id' },
117
+ { name: 'List Source Languages', value: 'listSourceLanguages', description: 'Full authoritative list of source language locale tags supported right now' },
118
+ { name: 'List Target Languages', value: 'listTargetLanguages', description: 'Full authoritative list of target language locale tags supported right now' },
119
+ ],
120
+ default: 'submitAndWait',
121
+ },
122
+
123
+ // ── submit / submitAndWait fields ──────────────────────────
124
+ {
125
+ displayName: 'Video URL',
126
+ name: 'videoUrl',
127
+ type: 'string',
128
+ default: '',
129
+ required: true,
130
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
131
+ description: 'Publicly accessible URL of the source video (e.g. the secure_url from Cloudinary Uploader)',
132
+ },
133
+ {
134
+ displayName: 'Source Language',
135
+ name: 'sourceLanguage',
136
+ type: 'options',
137
+ options: LANGUAGE_OPTIONS,
138
+ default: 'en-us',
139
+ required: true,
140
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
141
+ },
142
+ {
143
+ displayName: 'Source Language (Custom Locale Tag)',
144
+ name: 'sourceLanguageCustom',
145
+ type: 'string',
146
+ default: '',
147
+ placeholder: 'e.g. sw-ke',
148
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'], sourceLanguage: ['custom'] } },
149
+ description: 'Used only when Source Language above is set to "Custom"',
150
+ },
151
+ {
152
+ displayName: 'Target Languages',
153
+ name: 'targetLanguages',
154
+ type: 'multiOptions',
155
+ options: LANGUAGE_OPTIONS.filter((o) => o.value !== 'custom'),
156
+ default: [],
157
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
158
+ description: 'Pick one or more from the shortlist. Add anything not listed in the field below.',
159
+ },
160
+ {
161
+ displayName: 'Additional Target Languages (Comma-Separated Locale Tags)',
162
+ name: 'targetLanguagesCustom',
163
+ type: 'string',
164
+ default: '',
165
+ placeholder: 'e.g. sw-ke,am-et',
166
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
167
+ description: 'Any extra locale tags not in the dropdown above (comma-separated). Combined with the dropdown selection.',
168
+ },
169
+ {
170
+ displayName: 'Transcription Mode',
171
+ name: 'transcriptionMode',
172
+ type: 'options',
173
+ options: [
174
+ { name: 'Fast (default)', value: 'fast' },
175
+ { name: 'Slow (more accurate)', value: 'slow' },
176
+ ],
177
+ default: 'fast',
178
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
179
+ description: 'Trade speed for transcript accuracy',
180
+ },
181
+ {
182
+ displayName: 'Project Name',
183
+ name: 'projectName',
184
+ type: 'string',
185
+ default: '',
186
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
187
+ },
188
+ {
189
+ displayName: 'Project Description',
190
+ name: 'projectDescription',
191
+ type: 'string',
192
+ default: '',
193
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
194
+ },
195
+ {
196
+ displayName: 'Chosen Dictionary IDs (Comma-Separated, Optional)',
197
+ name: 'chosenDictionaries',
198
+ type: 'string',
199
+ default: '',
200
+ displayOptions: { show: { operation: ['submit', 'submitAndWait'] } },
201
+ description: 'Custom terminology dictionaries configured in your Camb.ai workspace, if any (keeps specified words unchanged across languages)',
202
+ },
203
+
204
+ // ── submitAndWait-only polling controls ────────────────────
205
+ {
206
+ displayName: 'Poll Interval (Seconds)',
207
+ name: 'pollIntervalSeconds',
208
+ type: 'number',
209
+ default: 10,
210
+ displayOptions: { show: { operation: ['submitAndWait'] } },
211
+ },
212
+ {
213
+ displayName: 'Max Wait (Minutes)',
214
+ name: 'maxWaitMinutes',
215
+ type: 'number',
216
+ default: 20,
217
+ displayOptions: { show: { operation: ['submitAndWait'] } },
218
+ description: 'Fails the node if dubbing has not finished within this many minutes',
219
+ },
220
+
221
+ // ── getStatus fields ────────────────────────────────────────
222
+ {
223
+ displayName: 'Task ID',
224
+ name: 'taskId',
225
+ type: 'string',
226
+ default: '',
227
+ required: true,
228
+ displayOptions: { show: { operation: ['getStatus'] } },
229
+ },
230
+
231
+ // ── getResult fields ────────────────────────────────────────
232
+ {
233
+ displayName: 'Run ID',
234
+ name: 'runId',
235
+ type: 'string',
236
+ default: '',
237
+ required: true,
238
+ displayOptions: { show: { operation: ['getResult'] } },
239
+ },
240
+ ],
241
+ };
242
+ }
243
+
244
+ async execute() {
245
+ const items = this.getInputData();
246
+ const operation = this.getNodeParameter('operation', 0);
247
+ const results = [];
248
+
249
+ for (let i = 0; i < items.length; i++) {
250
+ try {
251
+ let output;
252
+
253
+ if (operation === 'listSourceLanguages') {
254
+ output = await cambRequest(this, { method: 'GET', url: '/source-languages' });
255
+ } else if (operation === 'listTargetLanguages') {
256
+ output = await cambRequest(this, { method: 'GET', url: '/target-languages' });
257
+ } else if (operation === 'getStatus') {
258
+ const taskId = this.getNodeParameter('taskId', i, '');
259
+ output = await cambRequest(this, { method: 'GET', url: `/dub/${taskId}` });
260
+ } else if (operation === 'getResult') {
261
+ const runId = this.getNodeParameter('runId', i, '');
262
+ output = await cambRequest(this, { method: 'GET', url: `/dub-result/${runId}` });
263
+ } else if (operation === 'submit' || operation === 'submitAndWait') {
264
+ const videoUrl = this.getNodeParameter('videoUrl', i);
265
+ const sourceLanguageChoice = this.getNodeParameter('sourceLanguage', i);
266
+ const sourceLanguageCustom = this.getNodeParameter('sourceLanguageCustom', i, '');
267
+ const targetLanguagesChoice = this.getNodeParameter('targetLanguages', i, []);
268
+ const targetLanguagesCustom = this.getNodeParameter('targetLanguagesCustom', i, '');
269
+ const transcriptionMode = this.getNodeParameter('transcriptionMode', i, 'fast');
270
+ const projectName = this.getNodeParameter('projectName', i, '');
271
+ const projectDescription = this.getNodeParameter('projectDescription', i, '');
272
+ const chosenDictionariesRaw = this.getNodeParameter('chosenDictionaries', i, '');
273
+
274
+ const sourceLanguage =
275
+ sourceLanguageChoice === 'custom' ? sourceLanguageCustom.trim() : sourceLanguageChoice;
276
+ if (!sourceLanguage) {
277
+ throw new NodeOperationError(this.getNode(), 'Source Language is required (custom locale tag was empty)', {
278
+ itemIndex: i,
279
+ });
280
+ }
281
+
282
+ const extraTargets = String(targetLanguagesCustom || '')
283
+ .split(',')
284
+ .map((s) => s.trim())
285
+ .filter(Boolean);
286
+ const targetLanguages = Array.from(new Set([...(targetLanguagesChoice || []), ...extraTargets]));
287
+ if (targetLanguages.length === 0) {
288
+ throw new NodeOperationError(
289
+ this.getNode(),
290
+ 'At least one Target Language is required (dropdown selection or custom field)',
291
+ { itemIndex: i }
292
+ );
293
+ }
294
+
295
+ const body = {
296
+ video_url: videoUrl,
297
+ source_language: sourceLanguage,
298
+ target_languages: targetLanguages,
299
+ transcription_mode: transcriptionMode,
300
+ };
301
+ if (projectName) body.project_name = projectName;
302
+ if (projectDescription) body.project_description = projectDescription;
303
+ const chosenDictionaries = parseCsvInts(chosenDictionariesRaw);
304
+ if (chosenDictionaries) body.chosen_dictionaries = chosenDictionaries;
305
+
306
+ const submitResponse = await cambRequest(this, { method: 'POST', url: '/dub', body });
307
+
308
+ if (operation === 'submit') {
309
+ output = submitResponse;
310
+ } else {
311
+ const pollIntervalSeconds = this.getNodeParameter('pollIntervalSeconds', i, 10);
312
+ const maxWaitMinutes = this.getNodeParameter('maxWaitMinutes', i, 20);
313
+ const finalStatus = await pollDubStatus(this, submitResponse.task_id, {
314
+ pollIntervalSeconds,
315
+ maxWaitMinutes,
316
+ });
317
+ const runId = finalStatus.run_id;
318
+ const result = await cambRequest(this, { method: 'GET', url: `/dub-result/${runId}` });
319
+ output = { task_id: submitResponse.task_id, run_id: runId, ...result };
320
+ }
321
+ } else {
322
+ throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`, { itemIndex: i });
323
+ }
324
+
325
+ results.push({ json: output });
326
+ } catch (err) {
327
+ if (this.continueOnFail()) {
328
+ results.push({ json: { error: String(err && err.message ? err.message : err) } });
329
+ continue;
330
+ }
331
+ throw err instanceof NodeOperationError
332
+ ? err
333
+ : new NodeOperationError(this.getNode(), String(err && err.message ? err.message : err), { itemIndex: i });
334
+ }
335
+ }
336
+
337
+ return this.prepareOutputData(results);
338
+ }
339
+ }
340
+
341
+ module.exports = { CambAiDub };
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
2
+ <circle cx="12" cy="12" r="11" fill="#12121a"/>
3
+ <path d="M7 8v8M11 8v8M15 8c2.2 0 3.5 1.5 3.5 4S17.2 16 15 16" stroke="#5EF2C1" stroke-width="1.6" fill="none" stroke-linecap="round"/>
4
+ </svg>
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "n8n-nodes-camb-ai",
3
+ "version": "1.0.0",
4
+ "description": "n8n community node for Camb AI \u2014 dubbing (Amharic / English) and Text-to-Speech (\u12a0\u1288\u134d-Audio engine) with speaker and pacing control, chainable into an English-script-to-Amharic-speech pipeline.",
5
+ "main": "index.js",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "n8n-community-node-package",
9
+ "n8n",
10
+ "camb-ai",
11
+ "amharic",
12
+ "dubbing",
13
+ "translation",
14
+ "text-to-speech",
15
+ "tts",
16
+ "ethiopia"
17
+ ],
18
+ "author": {
19
+ "name": "Sisay Tadewos"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/Sisyetad/n8n-nodes-Camb-ai"
24
+ },
25
+ "n8n": {
26
+ "n8nNodesApiVersion": 1,
27
+ "strict": true,
28
+ "nodes": [
29
+ "nodes/CambAiDub/CambAiDub.node.js"
30
+ ],
31
+ "credentials": [
32
+ "credentials/CambAiApi.credentials.js"
33
+ ]
34
+ },
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "peerDependencies": {
39
+ "n8n-workflow": "*"
40
+ },
41
+ "files": [
42
+ "index.js",
43
+ "credentials",
44
+ "nodes",
45
+ "LICENSE",
46
+ "README.md"
47
+ ]
48
+ }