diff --git a/examples/elevenlabs-voiceover-video/.env.example b/examples/elevenlabs-voiceover-video/.env.example new file mode 100644 index 0000000..55111ae --- /dev/null +++ b/examples/elevenlabs-voiceover-video/.env.example @@ -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= diff --git a/examples/elevenlabs-voiceover-video/.gitignore b/examples/elevenlabs-voiceover-video/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/examples/elevenlabs-voiceover-video/.gitignore @@ -0,0 +1 @@ +.env diff --git a/examples/elevenlabs-voiceover-video/README.md b/examples/elevenlabs-voiceover-video/README.md new file mode 100644 index 0000000..c3339dd --- /dev/null +++ b/examples/elevenlabs-voiceover-video/README.md @@ -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`. diff --git a/examples/elevenlabs-voiceover-video/edit.json b/examples/elevenlabs-voiceover-video/edit.json new file mode 100644 index 0000000..bcf48a0 --- /dev/null +++ b/examples/elevenlabs-voiceover-video/edit.json @@ -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" } +} diff --git a/examples/elevenlabs-voiceover-video/voiceover.mjs b/examples/elevenlabs-voiceover-video/voiceover.mjs new file mode 100644 index 0000000..2d1d2e6 --- /dev/null +++ b/examples/elevenlabs-voiceover-video/voiceover.mjs @@ -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; +}