Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Clone this repository, or open the directory of the example you want. Each examp
- [bulk-csv-videos](examples/bulk-csv-videos) renders one video per row of a CSV from a single template with merge fields, tracked in a resumable manifest, with an optional AI step where Claude writes each row's headline and image prompt. Companion code for [Generate videos in bulk with an API and an AI agent](https://shotstack.io/learn/bulk-create-videos-from-csv-and-ai/).
- [first-render](examples/first-render) the very basics: submit an Edit, poll the render status, and print the output URL, in Node.js and Python. Start here if you are new to the API. Companion code for [Render your first video with the Shotstack API](https://shotstack.io/learn/render-your-first-video-shotstack-api/).
- [instagram-ai-video](examples/instagram-ai-video) generates a script, voiceover and background image with AI, renders a 1080x1920 video, and publishes it as an Instagram Reel. Companion code for [How to automate Instagram posts with AI video](https://shotstack.io/learn/automate-instagram-posts-with-ai-video/).
- [multi-client-video-automation](examples/multi-client-video-automation) renders branded promo videos for three clients in three aspect ratios from one master template, and records which render belongs to which client. Companion code for [A guide to automating video content production for multiple clients](https://shotstack.io/learn/automating-video-production-multiple-clients/).
- [rapidreels](examples/rapidreels) creates faceless short-form videos using generative AI. [View demo](https://shotstack.io/demos/social-media-video-maker/).
- [reelestate](examples/reelestate) turns static real estate images into fully edited video slideshows. [View demo](https://shotstack.io/demos/real-estate-video-listing-maker/).

Expand Down
10 changes: 10 additions & 0 deletions examples/multi-client-video-automation/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# https://dashboard.shotstack.io/register
SHOTSTACK_API_KEY=

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STANDARDS — API keys: also document SHOTSTACK_TEMPLATE_ID (required) and SHOTSTACK_ENV (optional, stage|v1) with empty values and a comment above each. Empty placeholders so a missing var fails with your message, not a vague API error.


# The id that the template request in the README returns.
# https://shotstack.io/docs/api/#tag/Edit/operation/postTemplate
SHOTSTACK_TEMPLATE_ID=

# Optional. stage (sandbox, default) or v1 (production). Use the key and template from the same environment.
# Both keys are in the dashboard under API Keys: https://dashboard.shotstack.io/
SHOTSTACK_ENV=
2 changes: 2 additions & 0 deletions examples/multi-client-video-automation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
renders.jsonl
89 changes: 89 additions & 0 deletions examples/multi-client-video-automation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Multi-client video automation

One master template renders a branded promo video for three fictional clients, in three aspect
ratios each: nine videos from one loop. Each client is a record with a headline, font, footage,
music and brand mark. A JSON Lines file records which render belongs to which client, because the
API has no endpoint that lists renders.

Related guide: [A guide to automating video content production for multiple clients](https://shotstack.io/learn/automating-video-production-multiple-clients/).

## Requirements

- A [Shotstack account](https://dashboard.shotstack.io/register) and your **sandbox** API key
- Node.js 20 or later
- curl

Sandbox renders are watermarked. Your account needs at least one credit to use the sandbox.

## Setup

```bash
git clone https://github.com/shotstack/shotstack-cookbook.git
cd shotstack-cookbook/examples/multi-client-video-automation
```

Copy the environment file. Add your sandbox key to `.env`. Leave `SHOTSTACK_ENV` empty to use the
sandbox.

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

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

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

Create the template in the same environment as `SHOTSTACK_ENV`. The command uses the sandbox when
`SHOTSTACK_ENV` is empty:

```bash
curl --fail-with-body \
--request POST \
"https://api.shotstack.io/edit/${SHOTSTACK_ENV:-stage}/templates" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--header "x-api-key: ${SHOTSTACK_API_KEY}" \
--data-binary @template.json
```

Copy the `id` from the response into `SHOTSTACK_TEMPLATE_ID` in `.env`. Then load the file again:

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

## Run

Submit all nine renders:

```bash
node render.mjs
```

Then check them:

```bash
node status.mjs
```

Run `status.mjs` again until each render shows `done`.

## What happens

`render.mjs` expands each client record into merge fields. It submits one template render per client
and aspect ratio, nine in total. It appends one line per render to `renders.jsonl` with the render id,
client and variant. It ends with the count of submitted renders.

`status.mjs` reads `renders.jsonl` and checks each render once. A `done` render prints its video URL.
A sandbox render finishes in under a minute.

`renders.jsonl` only appends. Delete the file to start a new batch.

To render in production, set `SHOTSTACK_ENV=v1` and put your production key in `.env`. Create the
template again with that key. A template belongs to the environment that created it.
73 changes: 73 additions & 0 deletions examples/multi-client-video-automation/clients.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
const TEMPLATE_V1 = process.env.SHOTSTACK_TEMPLATE_ID;

export const clients = {
'meridian-realty': {
name: 'Meridian Realty',
templateId: TEMPLATE_V1,
headline: 'Twelve new listings this week.',
font: 'Montserrat',
footage:
'https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/footage/city-timelapse.mp4',
music:
'https://s3-ap-southeast-2.amazonaws.com/shotstack-assets/music/moment.mp3',
brandMark:
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><rect x="10" y="10" width="100" height="100" rx="16" fill="#1b6ca8"/></svg>'
},

'driftwood-retreats': {
name: 'Driftwood Retreats',
templateId: TEMPLATE_V1,
headline: 'Off-season rates end Sunday.',
font: 'Open Sans',
footage:
'https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/footage/beach-overhead.mp4',
music:
'https://s3-ap-southeast-2.amazonaws.com/shotstack-assets/music/spirit.mp3',
brandMark:
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><circle cx="60" cy="60" r="50" fill="#c1701e"/></svg>'
},

'apex-skate': {
name: 'Apex Skate Co.',
templateId: TEMPLATE_V1,
headline: 'New deck drop. Friday.',
font: 'Permanent Marker',
footage:
'https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/footage/skater.hd.mp4',
music:
'https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/music/unminus/lit.mp3',
brandMark:
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><polygon points="60,10 110,105 10,105" fill="#b7f32b"/></svg>'
}
};

/**
* Aspect ratio variants.
*
* Explicit width/height rather than output.aspectRatio: numeric fields accept
* "{{ PLACEHOLDER }}" strings, but aspectRatio is an enum and may reject one.
*/
export const variants = [
{ name: '16x9', width: 1920, height: 1080 },
{ name: '9x16', width: 1080, height: 1920 },
{ name: '1x1', width: 1080, height: 1080 }
];

/**
* Expand a client + variant into the merge array the render endpoint expects.
*
* Every placeholder in the template is a string ("{{ WIDTH }}"), so every
* replace value is a string too. The engine converts "1920" back to a number
* where the schema needs one.
*/
export function mergeFieldsFor(client, variant) {
return [
{ find: 'HEADLINE', replace: client.headline },
{ find: 'FONT', replace: client.font },
{ find: 'BRAND_MARK', replace: client.brandMark },
{ find: 'FOOTAGE', replace: client.footage },
{ find: 'MUSIC', replace: client.music },
{ find: 'WIDTH', replace: String(variant.width) },
{ find: 'HEIGHT', replace: String(variant.height) }
];
}
44 changes: 44 additions & 0 deletions examples/multi-client-video-automation/db.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { appendFile, readFile } from 'node:fs/promises';

const FILE = new URL('./renders.jsonl', import.meta.url);

export const db = {
renders: {
// One JSON object per line, appended. Nine renders submit concurrently, so
// reading the whole file, pushing a row and writing it back would lose
// rows: two writers read the same state and the second overwrites the
// first. Appends don't interleave.
async insert(row) {
try {
await appendFile(FILE, JSON.stringify(row) + '\n');
} catch (err) {
throw new Error(`Could not write renders.jsonl: ${err.message}`);
}
},

async all() {
let text;
try {
text = await readFile(FILE, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return []; // no batch submitted yet
throw new Error(`Could not read renders.jsonl: ${err.message}`);
}

// A line that is not JSON means the file was edited or a write was cut
// short. Say so, rather than report an empty batch.
return text
.split('\n')
.filter(line => line.trim())
.map((line, i) => {
try {
return JSON.parse(line);
} catch {
throw new Error(
`renders.jsonl line ${i + 1} is not valid JSON. Fix or delete the file and submit the batch again.`
);
}
});
}
}
};
108 changes: 108 additions & 0 deletions examples/multi-client-video-automation/render.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { clients, variants, mergeFieldsFor } from './clients.mjs';
import { db } from './db.mjs';

if (!process.env.SHOTSTACK_API_KEY || !process.env.SHOTSTACK_TEMPLATE_ID) {
console.error(
'Set SHOTSTACK_API_KEY and SHOTSTACK_TEMPLATE_ID before rendering.'
);
process.exit(1);
}

const ENV = process.env.SHOTSTACK_ENV || 'stage'; // '' from .env falls back too
if (!['stage', 'v1'].includes(ENV)) {
console.error('SHOTSTACK_ENV must be stage or v1.');
process.exit(1);
}
const API = `https://api.shotstack.io/edit/${ENV}`;

/** Reduce an API error response to one line the user can act on. */
async function apiError(res) {
const text = await res.text();
try {
const body = JSON.parse(text);
return (
body.errors?.[0]?.detail ?? body.response?.error ?? body.message ?? text
);
} catch {
return text;
}
}

async function renderVariant(clientId, client, variant) {
let res;
try {
res = await fetch(`${API}/templates/render`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.SHOTSTACK_API_KEY
},
body: JSON.stringify({
id: client.templateId,
merge: mergeFieldsFor(client, variant)
}),
signal: AbortSignal.timeout(30_000)
});
} catch {
throw new Error(
`${clientId}/${variant.name}: the network request failed. Check your connection and run again.`
);
}

if (res.status === 401 || res.status === 403)
throw new Error(
`${clientId}/${variant.name}: the API rejected the key (${res.status}). Check SHOTSTACK_API_KEY and SHOTSTACK_ENV.`
);

if (!res.ok)
throw new Error(
`${clientId}/${variant.name}: ${res.status} ${await apiError(res)}`
);

const { response } = await res.json();

await db.renders.insert({
renderId: response.id,
clientId,
variant: variant.name,
submittedAt: new Date().toISOString()
});

return response.id;
}

async function renderAll(concurrency = 10) {
// Every client × every variant, flattened into one work queue.
const jobs = Object.entries(clients).flatMap(([id, client]) =>
variants.map(variant => ({ id, client, variant }))
);

const results = [];

for (let i = 0; i < jobs.length; i += concurrency) {
const batch = jobs.slice(i, i + concurrency);
results.push(
...(await Promise.allSettled(
batch.map(j => renderVariant(j.id, j.client, j.variant))
))
);
}

return results;
}

let results;
try {
results = await renderAll();
} catch (err) {
// Only db.mjs throws here: renders.jsonl could not be written.
console.error(err.message);
process.exit(1);
}

const rejected = results.filter(r => r.status === 'rejected');
for (const r of rejected) console.error(r.reason.message);

console.log(`${results.length - rejected.length}/${results.length} submitted`);

if (rejected.length > 0) process.exitCode = 1;
56 changes: 56 additions & 0 deletions examples/multi-client-video-automation/status.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { db } from './db.mjs';

if (!process.env.SHOTSTACK_API_KEY) {
console.error('Set SHOTSTACK_API_KEY before checking render status.');
process.exit(1);
}

const ENV = process.env.SHOTSTACK_ENV || 'stage'; // '' from .env falls back too
if (!['stage', 'v1'].includes(ENV)) {
console.error('SHOTSTACK_ENV must be stage or v1.');
process.exit(1);
}
const API = `https://api.shotstack.io/edit/${ENV}`;

let rows;
try {
rows = await db.renders.all();
} catch (err) {
console.error(err.message);
process.exit(1);
}

if (rows.length === 0) {
console.log('No renders recorded yet. Run node render.mjs first.');
process.exit(0);
}

for (const row of rows) {
let res;
try {
res = await fetch(`${API}/render/${row.renderId}`, {
headers: { 'x-api-key': process.env.SHOTSTACK_API_KEY },
signal: AbortSignal.timeout(30_000)
});
} catch {
console.error(
`${row.clientId} ${row.variant ?? ''}: the network request failed. Check your connection and run again.`
);
process.exitCode = 1;
continue;
}

if (!res.ok) {
console.error(
`${row.clientId} ${row.variant ?? ''}: status check failed (${res.status}). Check SHOTSTACK_API_KEY.`
);
process.exitCode = 1;
continue;
}

const { response } = await res.json();
console.log(`${row.clientId} ${row.variant ?? ''} → ${response.status}`);

if (response.status === 'done') console.log(` ${response.url}`);
if (response.status === 'failed') console.log(` error: ${response.error}`);
}
Loading
Loading