Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions examples/elevenlabs-voiceover-video/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Your Shotstack production API key: https://dashboard.shotstack.io/register
SHOTSTACK_API_KEY=

# Your ElevenLabs API key: https://elevenlabs.io/app/settings/api-keys
ELEVENLABS_API_KEY=

# Optional. Path to a voice recording (MP3 or WAV, one to three minutes).
# When set, the script clones this voice and narrates with the clone.
# Voice cloning needs a paid ElevenLabs plan.
ELEVENLABS_VOICE_SAMPLE=

# Optional. A voice you already have: https://elevenlabs.io/app/voice-library
# Ignored when ELEVENLABS_VOICE_SAMPLE is set. The default is George.
ELEVENLABS_VOICE_ID=
1 change: 1 addition & 0 deletions examples/elevenlabs-voiceover-video/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
68 changes: 68 additions & 0 deletions examples/elevenlabs-voiceover-video/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# ElevenLabs voice-over video

Narrate a video in a cloned voice. The script clones a voice from a short audio sample with
the ElevenLabs Voice Cloning API, generates the voice-over with that voice, uploads it to
Shotstack, and renders a captioned video. You get one MP4 with your voice, background footage,
and captions transcribed from the voice. The script moves the audio between the two APIs.
You do not handle a file.

## Requirements

- A [Shotstack account](https://dashboard.shotstack.io/register) and your **production** API key
(dashboard menu under your account name, top right, under **API Keys**)
- An [ElevenLabs account](https://elevenlabs.io/app/settings/api-keys) and an API key.
Voice cloning needs a paid ElevenLabs plan (Starter or above). Without one, skip the
cloning step and use a voice from the ElevenLabs voice library.
- Node.js 20 or later

Production renders consume Shotstack credits. For free watermarked test renders, change `v1`
to `stage` in `voiceover.mjs` and use your sandbox key. The caption track transcribes the
voice-over, which consumes generation credits. Remove the first track in `edit.json` to render
without captions and without generation credits.

## Setup

```bash
git clone https://github.com/shotstack/shotstack-cookbook.git
cd shotstack-cookbook/examples/elevenlabs-voiceover-video
```

Copy the environment file. Add your keys to `.env`.

```bash
cp .env.example .env
```

Choose the voice. Do one of these:

- Set `ELEVENLABS_VOICE_SAMPLE` in `.env` to the path of a clean voice recording,
one to three minutes of MP3 or WAV. The script clones it and uses the clone.
- Set `ELEVENLABS_VOICE_ID` in `.env` to a voice you already have, cloned or from the
[voice library](https://elevenlabs.io/app/voice-library).
- Set neither. The script uses George from the ElevenLabs default library.

Load the file into your shell. Do this in each new terminal:

```bash
set -a
source .env
set +a
```

## Run

```bash
node voiceover.mjs
```

## What happens

If `ELEVENLABS_VOICE_SAMPLE` is set, the script first sends the sample to the ElevenLabs
Voice Cloning API and receives a new voice ID. Then it sends the script text to ElevenLabs
and receives an MP3 voice-over in the chosen voice. It uploads the MP3 to the Shotstack
Ingest API and waits until the source is ready. It puts the source URL into `edit.json`,
submits the render, and polls every five seconds until the render reaches `done` or `failed`.
Then it prints the temporary output URL. The full run takes two to four minutes. The URL
expires after 24 hours.

To change the spoken text, edit the `SCRIPT` constant at the top of `voiceover.mjs`.
53 changes: 53 additions & 0 deletions examples/elevenlabs-voiceover-video/edit.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"timeline": {
"tracks": [
{
"clips": [
{
"asset": {
"type": "rich-caption",
"src": "alias://voiceover",
"font": { "size": 36, "color": "#ffffff" },
"background": {
"color": "#000000",
"opacity": 0.6,
"padding": 16,
"borderRadius": 8
}
},
"start": 0,
"length": "end",
"width": 960,
"height": 150,
"fit": "none",
"position": "bottom",
"offset": { "y": 0.05 }
}
]
},
{
"clips": [
{
"alias": "voiceover",
"asset": { "type": "audio", "src": "{{VOICEOVER_URL}}" },
"start": 0,
"length": "auto"
}
]
},
{
"clips": [
{
"asset": {
"type": "video",
"src": "https://shotstack-assets.s3.amazonaws.com/footage/city-timelapse.mp4"
},
"start": 0,
"length": "end"
}
]
}
]
},
"output": { "format": "mp4", "resolution": "hd" }
}
231 changes: 231 additions & 0 deletions examples/elevenlabs-voiceover-video/voiceover.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { readFile } from 'node:fs/promises';
import { setTimeout as delay } from 'node:timers/promises';

// The production environment. For free watermarked test renders,
// change 'v1' to 'stage' and use your sandbox API key.
const SHOTSTACK_EDIT_URL = 'https://api.shotstack.io/edit/v1';
const SHOTSTACK_INGEST_URL = 'https://api.shotstack.io/ingest/v1';
const ELEVENLABS_URL = 'https://api.elevenlabs.io/v1';
const POLL_INTERVAL_MS = 5_000;
const MAX_WAIT_MS = 10 * 60 * 1_000;

const SCRIPT =
'Welcome to 12 Seaview Road. Three bedrooms of morning light, a kitchen ' +
'that opens to the garden, and the beach a five-minute walk away. Booked ' +
'inspections are filling fast. Come see it Saturday.';

const shotstackKey = process.env.SHOTSTACK_API_KEY;
const elevenlabsKey = process.env.ELEVENLABS_API_KEY;
const presetVoiceId = process.env.ELEVENLABS_VOICE_ID || 'JBFqnCBsd6RMkjVDRZzb';
const voiceSample = process.env.ELEVENLABS_VOICE_SAMPLE;

if (!shotstackKey) {
console.error('Set the SHOTSTACK_API_KEY environment variable first.');
process.exit(1);
}

if (!elevenlabsKey) {
console.error('Set the ELEVENLABS_API_KEY environment variable first.');
process.exit(1);
}

async function request(url, options, service) {
let response;

try {
response = await fetch(url, {
signal: AbortSignal.timeout(60_000),
...options
});
} catch (error) {
throw new Error(
`Could not reach the ${service} API. ` +
'Check your network connection and try again.',
{ cause: error }
);
}

if (!response.ok) {
const detail = await response.text();
throw new Error(`${service} returned ${response.status}: ${detail}`);
}

return response;
}

async function cloneVoice(samplePath) {
const sample = await readFile(samplePath).catch(() => {
throw new Error(`Could not read the voice sample at ${samplePath}.`);
});
const form = new FormData();
form.append('name', 'Cookbook cloned voice');
form.append('files', new Blob([sample]), 'sample.mp3');

const response = await request(
`${ELEVENLABS_URL}/voices/add`,
{ method: 'POST', headers: { 'xi-api-key': elevenlabsKey }, body: form },
'ElevenLabs'
);
const voice = await response.json();

if (!voice.voice_id) {
throw new Error('ElevenLabs did not return a voice ID for the clone.');
}

return voice.voice_id;
}

async function generateVoiceover(voiceId) {
const response = await request(
`${ELEVENLABS_URL}/text-to-speech/${voiceId}?output_format=mp3_44100_128`,
{
method: 'POST',
headers: {
'xi-api-key': elevenlabsKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: SCRIPT,
model_id: 'eleven_multilingual_v2'
})
},
'ElevenLabs'
);

return Buffer.from(await response.arrayBuffer());
}

async function uploadToShotstack(audio) {
const uploadResponse = await request(
`${SHOTSTACK_INGEST_URL}/upload`,
{
method: 'POST',
headers: { Accept: 'application/json', 'x-api-key': shotstackKey }
},
'Shotstack'
);
const upload = (await uploadResponse.json()).data;

if (!upload?.attributes?.url || !upload?.id) {
throw new Error('The upload response did not contain a signed URL.');
}

await request(
upload.attributes.url,
{ method: 'PUT', body: audio },
'Shotstack'
);

const startedAt = Date.now();

while (Date.now() - startedAt < MAX_WAIT_MS) {
const sourceResponse = await request(
`${SHOTSTACK_INGEST_URL}/sources/${upload.id}`,
{ headers: { Accept: 'application/json', 'x-api-key': shotstackKey } },
'Shotstack'
);
const attributes = (await sourceResponse.json()).data?.attributes || {};

if (attributes.status === 'failed') {
throw new Error('Shotstack could not ingest the voice-over file.');
}

if (attributes.source) {
return attributes.source;
}

console.log('Waiting for the voice-over upload to be ready...');
await delay(POLL_INTERVAL_MS);
}

throw new Error('The voice-over upload did not finish in time.');
}

async function submitRender(voiceoverUrl) {
const template = await readFile(
new URL('./edit.json', import.meta.url),
'utf8'
);
const edit = JSON.parse(template.replace('{{VOICEOVER_URL}}', voiceoverUrl));

const response = await request(
`${SHOTSTACK_EDIT_URL}/render`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'x-api-key': shotstackKey
},
body: JSON.stringify(edit)
},
'Shotstack'
);
const renderId = (await response.json()).response?.id;

if (!renderId) {
throw new Error('The render response did not contain a render ID.');
}

return renderId;
}

async function waitForRender(renderId) {
const startedAt = Date.now();

while (Date.now() - startedAt < MAX_WAIT_MS) {
const response = await request(
`${SHOTSTACK_EDIT_URL}/render/${renderId}`,
{ headers: { Accept: 'application/json', 'x-api-key': shotstackKey } },
'Shotstack'
);
const render = (await response.json()).response || {};

if (!render.status) {
throw new Error('Shotstack returned an unexpected status response.');
}

console.log(`Render status: ${render.status}`);

if (render.status === 'done') {
if (!render.url) {
throw new Error('The render finished without an output URL.');
}
return render;
}

if (render.status === 'failed') {
throw new Error(
render.error || 'The render failed without an error message.'
);
}

await delay(POLL_INTERVAL_MS);
}

throw new Error(`Render ${renderId} did not finish in time.`);
}

try {
let voiceId = presetVoiceId;

if (voiceSample) {
console.log('Cloning the voice with ElevenLabs...');
voiceId = await cloneVoice(voiceSample);
}

console.log('Generating the voice-over with ElevenLabs...');
const audio = await generateVoiceover(voiceId);

console.log('Uploading the voice-over to Shotstack...');
const voiceoverUrl = await uploadToShotstack(audio);

const renderId = await submitRender(voiceoverUrl);
console.log(`Queued render: ${renderId}`);

const render = await waitForRender(renderId);
console.log(`Temporary output URL: ${render.url}`);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
Loading