Skip to content

Commit 0855b5f

Browse files
authored
Merge pull request #25 from soniasriv/add-rime-multilingual-dub
Add rime-multilingual-dub example
2 parents f9d383f + 1088b4e commit 0855b5f

6 files changed

Lines changed: 498 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Your Shotstack API key: https://dashboard.shotstack.io
2+
SHOTSTACK_API_KEY=
3+
4+
# Your Rime API key: https://rime.ai
5+
RIME_API_KEY=
6+
7+
# A public link to the video you want to dub
8+
SOURCE_VIDEO=
9+
10+
# Optional. Another Rime model: https://docs.rime.ai
11+
RIME_MODEL=
12+
13+
# Optional. Set to stage for free watermarked test renders.
14+
SHOTSTACK_ENV=
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.env
2+
output/
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Rime multilingual dub
2+
3+
Make one video speak several languages. The script reads `translations.csv`,
4+
asks Rime for a voice-over in each language, then renders the same source video
5+
with the original audio silenced, the new voice in its place, and captions
6+
burned in. You get one MP4 per language in `output/`.
7+
8+
You supply the translated lines. The script does not translate anything, so
9+
nothing is guessed on your behalf.
10+
11+
## Requirements
12+
13+
- A [Shotstack account](https://dashboard.shotstack.io/register) and your
14+
**production** API key
15+
- A [Rime account](https://rime.ai) and an API key
16+
- A public link to the video you want to dub
17+
- Node.js 20 or later
18+
19+
Each row generates one voice-over at Rime and one Shotstack render. The captions
20+
are transcribed from the new voice-over, which consumes generation credits.
21+
Remove the first track in `template.json` to render without captions.
22+
23+
## Setup
24+
25+
```bash
26+
git clone https://github.com/shotstack/shotstack-cookbook.git
27+
cd shotstack-cookbook/examples/rime-multilingual-dub
28+
cp .env.example .env
29+
```
30+
31+
Load the file into your shell. Do this in each new terminal:
32+
33+
```bash
34+
set -a
35+
source .env
36+
set +a
37+
```
38+
39+
Set `SOURCE_VIDEO` to your video. Edit `translations.csv`: one row per language,
40+
with the Rime `speaker` voice and the translated `text`. The voices are listed
41+
in the [Rime documentation](https://docs.rime.ai).
42+
43+
## Run
44+
45+
```bash
46+
node multilingual-dub.mjs
47+
```
48+
49+
## What happens
50+
51+
For each row the script sends the text to Rime and receives an MP3. It uploads
52+
the MP3 to the Shotstack Ingest API and waits until the source is ready. It puts
53+
the audio URL and the source video into the merge fields of `template.json`,
54+
then renders a 1080p video where the original audio is set to zero volume and
55+
the new voice plays over it. Each MP4 is downloaded to `output/`.
56+
57+
Keep the voice-over close to the length of the original video. The voice track
58+
is capped to the length of the source video, so a line that runs long is cut
59+
off at the end of the picture rather than extending the render.
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
2+
import { setTimeout as delay } from 'node:timers/promises';
3+
import { createHash } from 'node:crypto';
4+
5+
// The production environment. For free watermarked test renders, set
6+
// SHOTSTACK_ENV=stage in .env and use your sandbox API key.
7+
const STAGE = process.env.SHOTSTACK_ENV === 'stage' ? 'stage' : 'v1';
8+
const EDIT_URL = `https://api.shotstack.io/edit/${STAGE}`;
9+
const INGEST_URL = `https://api.shotstack.io/ingest/${STAGE}`;
10+
const POLL_INTERVAL_MS = 5_000;
11+
const MAX_WAIT_MS = 15 * 60 * 1_000;
12+
13+
function requireEnv(name, hint) {
14+
const value = process.env[name];
15+
16+
if (!value) {
17+
console.error(`Set the ${name} environment variable first. ${hint}`);
18+
process.exit(1);
19+
}
20+
21+
return value;
22+
}
23+
24+
async function request(url, options, service) {
25+
let response;
26+
27+
try {
28+
response = await fetch(url, {
29+
signal: AbortSignal.timeout(120_000),
30+
...options
31+
});
32+
} catch (error) {
33+
throw new Error(
34+
`Could not reach the ${service} API. ` +
35+
'Check your network connection and try again.',
36+
{ cause: error }
37+
);
38+
}
39+
40+
if (!response.ok) {
41+
const detail = (await response.text()).slice(0, 300);
42+
throw new Error(`${service} returned ${response.status}: ${detail}`);
43+
}
44+
45+
return response;
46+
}
47+
48+
// A small CSV reader. It accepts quoted fields and commas inside quotes.
49+
function parseCsv(text) {
50+
const rows = [];
51+
let row = [];
52+
let field = '';
53+
let quoted = false;
54+
55+
for (let i = 0; i < text.length; i += 1) {
56+
const char = text[i];
57+
58+
if (quoted) {
59+
if (char === '"' && text[i + 1] === '"') {
60+
field += '"';
61+
i += 1;
62+
} else if (char === '"') {
63+
quoted = false;
64+
} else {
65+
field += char;
66+
}
67+
} else if (char === '"') {
68+
quoted = true;
69+
} else if (char === ',') {
70+
row.push(field.trim());
71+
field = '';
72+
} else if (char === '\n' || char === '\r') {
73+
if (field !== '' || row.length > 0) {
74+
row.push(field.trim());
75+
rows.push(row);
76+
row = [];
77+
field = '';
78+
}
79+
} else {
80+
field += char;
81+
}
82+
}
83+
84+
if (field !== '' || row.length > 0) {
85+
row.push(field.trim());
86+
rows.push(row);
87+
}
88+
89+
const header = rows.shift();
90+
91+
if (!header) {
92+
throw new Error('The input file is empty.');
93+
}
94+
95+
return rows.map(cells =>
96+
Object.fromEntries(header.map((name, i) => [name, cells[i] ?? '']))
97+
);
98+
}
99+
100+
function fingerprint(value) {
101+
return createHash('sha1')
102+
.update(JSON.stringify(value))
103+
.digest('hex')
104+
.slice(0, 12);
105+
}
106+
107+
// The manifest makes the script resumable. Work already done is skipped, and
108+
// media already generated is reused, so a second run costs nothing.
109+
async function readManifest(path) {
110+
try {
111+
return JSON.parse(await readFile(path, 'utf8'));
112+
} catch {
113+
return { jobs: {}, cache: {} };
114+
}
115+
}
116+
117+
async function writeManifest(path, manifest) {
118+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
119+
}
120+
121+
async function uploadToShotstack(key, body, contentType, label) {
122+
const signed = await request(
123+
`${INGEST_URL}/upload`,
124+
{ method: 'POST', headers: { 'x-api-key': key } },
125+
'Shotstack Ingest'
126+
);
127+
const { data } = await signed.json();
128+
await request(
129+
data.attributes.url,
130+
{ method: 'PUT', headers: { 'Content-Type': contentType }, body },
131+
'Shotstack Ingest'
132+
);
133+
const started = Date.now();
134+
135+
while (Date.now() - started < MAX_WAIT_MS) {
136+
const poll = await request(
137+
`${INGEST_URL}/sources/${data.id}`,
138+
{ headers: { 'x-api-key': key } },
139+
'Shotstack Ingest'
140+
);
141+
const source = (await poll.json()).data.attributes;
142+
143+
if (source.status === 'ready') {
144+
return source.source;
145+
}
146+
147+
if (source.status === 'failed') {
148+
throw new Error(`Shotstack could not read the ${label}.`);
149+
}
150+
151+
await delay(POLL_INTERVAL_MS);
152+
}
153+
154+
throw new Error(`Shotstack did not finish reading the ${label} in time.`);
155+
}
156+
157+
async function renderEdit(key, edit, label) {
158+
const submitted = await request(
159+
`${EDIT_URL}/render`,
160+
{
161+
method: 'POST',
162+
headers: { 'x-api-key': key, 'Content-Type': 'application/json' },
163+
body: JSON.stringify(edit)
164+
},
165+
'Shotstack'
166+
);
167+
const renderId = (await submitted.json()).response.id;
168+
const started = Date.now();
169+
170+
while (Date.now() - started < MAX_WAIT_MS) {
171+
await delay(POLL_INTERVAL_MS);
172+
const poll = await request(
173+
`${EDIT_URL}/render/${renderId}`,
174+
{ headers: { 'x-api-key': key } },
175+
'Shotstack'
176+
);
177+
const status = (await poll.json()).response;
178+
179+
if (status.status === 'done') {
180+
return status.url;
181+
}
182+
183+
if (status.status === 'failed') {
184+
throw new Error(
185+
`The ${label} render failed: ${status.error ?? 'no reason given'}`
186+
);
187+
}
188+
}
189+
190+
throw new Error(`The ${label} render did not finish in time.`);
191+
}
192+
193+
async function download(url, path) {
194+
const response = await request(url, {}, 'Shotstack CDN');
195+
await writeFile(path, Buffer.from(await response.arrayBuffer()));
196+
}
197+
198+
function mergeFields(values) {
199+
return Object.entries(values).map(([find, replace]) => ({
200+
find,
201+
replace: String(replace)
202+
}));
203+
}
204+
205+
const RIME_URL = 'https://users.rime.ai/v1/rime-tts';
206+
const RIME_MODEL = process.env.RIME_MODEL || 'arcana';
207+
208+
const shotstackKey = requireEnv(
209+
'SHOTSTACK_API_KEY',
210+
'Get it from https://dashboard.shotstack.io.'
211+
);
212+
const rimeKey = requireEnv('RIME_API_KEY', 'Get it from https://rime.ai.');
213+
const sourceVideo = requireEnv(
214+
'SOURCE_VIDEO',
215+
'It must be a public link to the video you want to dub.'
216+
);
217+
218+
async function generateVoiceover(text, speaker) {
219+
const response = await request(
220+
RIME_URL,
221+
{
222+
method: 'POST',
223+
headers: {
224+
Authorization: `Bearer ${rimeKey}`,
225+
Accept: 'audio/mpeg',
226+
'Content-Type': 'application/json'
227+
},
228+
body: JSON.stringify({ speaker, text, modelId: RIME_MODEL })
229+
},
230+
'Rime'
231+
);
232+
233+
return Buffer.from(await response.arrayBuffer());
234+
}
235+
236+
async function main() {
237+
const rows = parseCsv(await readFile('translations.csv', 'utf8'));
238+
239+
if (rows.length === 0) {
240+
throw new Error('translations.csv has a header but no rows.');
241+
}
242+
243+
await mkdir('output', { recursive: true });
244+
const manifestPath = 'output/manifest.json';
245+
const manifest = await readManifest(manifestPath);
246+
const template = JSON.parse(await readFile('template.json', 'utf8'));
247+
248+
for (const row of rows) {
249+
if (manifest.jobs[row.locale]?.status === 'done') {
250+
console.log(`${row.locale}: already rendered, skipping`);
251+
continue;
252+
}
253+
254+
const cacheKey = fingerprint([row.text, row.speaker, RIME_MODEL]);
255+
let dubUrl = manifest.cache[cacheKey];
256+
257+
if (dubUrl) {
258+
console.log(`${row.locale}: reusing the voice-over from an earlier run`);
259+
} else {
260+
console.log(`${row.locale}: generating the voice-over with Rime`);
261+
const audio = await generateVoiceover(row.text, row.speaker);
262+
dubUrl = await uploadToShotstack(
263+
shotstackKey,
264+
audio,
265+
'audio/mpeg',
266+
'voice-over'
267+
);
268+
manifest.cache[cacheKey] = dubUrl;
269+
await writeManifest(manifestPath, manifest);
270+
}
271+
272+
const edit = structuredClone(template);
273+
edit.merge = mergeFields({
274+
DUB_URL: dubUrl,
275+
SOURCE_VIDEO: sourceVideo,
276+
LOCALE_LABEL: row.label || row.locale,
277+
ACCENT: process.env.ACCENT || '#0CB5B2'
278+
});
279+
280+
console.log(`${row.locale}: rendering`);
281+
const url = await renderEdit(shotstackKey, edit, row.locale);
282+
const file = `output/${row.locale}.mp4`;
283+
await download(url, file);
284+
manifest.jobs[row.locale] = {
285+
status: 'done',
286+
file,
287+
url,
288+
speaker: row.speaker,
289+
renderedAt: new Date().toISOString()
290+
};
291+
await writeManifest(manifestPath, manifest);
292+
console.log(`${row.locale}: saved to ${file}`);
293+
}
294+
295+
console.log('\nDone. One dubbed video per locale in output/.');
296+
}
297+
298+
try {
299+
await main();
300+
} catch (error) {
301+
console.error(error instanceof Error ? error.message : error);
302+
process.exit(1);
303+
}

0 commit comments

Comments
 (0)