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
2 changes: 2 additions & 0 deletions examples/multi-client-video-automation/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# https://dashboard.shotstack.io/register
SHOTSTACK_API_KEY=
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
84 changes: 84 additions & 0 deletions examples/multi-client-video-automation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Multi-client video automation

One master template renders branded promo videos for three fictional clients, in three aspect
ratios each: nine videos from one loop. Each client is a record with their own 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.

Companion code for [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
(dashboard menu under your account name, top right, under **API Keys**)
- Node.js 20 or later

Sandbox renders are watermarked, and your account needs at least one credit to use the environment.

## 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`.

```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 and keep the returned template id:

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

```bash
export SHOTSTACK_TEMPLATE_ID="your_template_id"
```

## Run

Submit all nine renders:

```bash
node render.mjs
```

Then check them:

```bash
node status.mjs
```

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

## What happens

`render.mjs` expands each client record into merge fields, submits one template render per client
and aspect ratio (nine in total), and 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.

The scripts render in the sandbox by default. To render in production, set `SHOTSTACK_ENV=v1` and
put your production key in `.env`. Re-create the template with that key. Templates belong to the
environment they were created in, and the ids differ.
67 changes: 67 additions & 0 deletions examples/multi-client-video-automation/clients.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/35tqpmb0ya/zzz01m08-qxa25-864e6-zty3t-3sttne/source.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-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/35tqpmb0ya/zzz01m08-qxkz1-xz4yp-pfbj3-14r3s3/source.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-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/35tqpmb0ya/zzz01m08-qy60n-yb4w2-ks55h-tej73h/source.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. */
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: variant.width },
{ find: 'HEIGHT', replace: variant.height }
];
}
28 changes: 28 additions & 0 deletions examples/multi-client-video-automation/db.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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) {
await appendFile(FILE, JSON.stringify(row) + '\n');
},

async all() {
try {
const text = await readFile(FILE, 'utf8');
return text
.trim()
.split('\n')
.filter(Boolean)
.map(line => JSON.parse(line));
} catch {
return [];
}
}
}
};
88 changes: 88 additions & 0 deletions examples/multi-client-video-automation/render.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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';
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) {
const 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)
})
});

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;
}

const results = await renderAll();

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;
42 changes: 42 additions & 0 deletions examples/multi-client-video-automation/status.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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';
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}`;

for (const row of await db.renders.all()) {
let res;
try {
res = await fetch(`${API}/render/${row.renderId}`, {
headers: { 'x-api-key': process.env.SHOTSTACK_API_KEY }
});
} 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