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 @@ -6,6 +6,7 @@ Clone this repository, or open the directory of the example you want. Each examp

## Examples

- [automotive-inventory-videos](examples/automotive-inventory-videos) renders one vertical video per vehicle in a dealer inventory from a JSON feed: the photos in sequence with a pan or zoom on each, and an animated specification card. The Edit JSON is built in code, one clip per photo.
- [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/).
- [in-app-video-creation](examples/in-app-video-creation) lets a user create the same promo video three ways: an embedded Studio SDK editor, a quick form, and a one-click headless render, all through one render proxy that keeps the API key server-side. Companion code for [Add video creation to your app without building an editor](https://shotstack.io/learn/add-video-creation-to-your-app/).
Expand Down
6 changes: 6 additions & 0 deletions examples/automotive-inventory-videos/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Your Shotstack sandbox API key: https://dashboard.shotstack.io/register
SHOTSTACK_API_KEY=

# Optional. stage (sandbox, default) or v1 (production). Use the key 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/automotive-inventory-videos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
renders.jsonl
72 changes: 72 additions & 0 deletions examples/automotive-inventory-videos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Automotive inventory videos

Render one vertical video for each vehicle in a dealer inventory. Each vehicle record has a stock
id, specifications, a price and a list of photos. The script builds the Edit JSON in code, with
one clip for each photo, so the video is as long as the vehicle has photos. You get one
1080 x 1920 MP4 per vehicle, and a file that records which render belongs to which stock id.

## Requirements

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

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/automotive-inventory-videos
```

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

## Run

Submit one render per vehicle:

```bash
node render.mjs
```

Then check them:

```bash
node status.mjs
```

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

## What happens

`render.mjs` reads `vehicles.json` and checks that each vehicle has every field and at least one
photo. It reports every problem it finds, then stops. For each vehicle it builds an Edit JSON and
submits one render. The video shows the photos in sequence, 3.2 seconds each, cropped to fill the
vertical frame. Each photo fades in, and the pan or zoom changes from one photo to the next. A
vehicle with one or two photos gets at least six seconds. A card with the year, make, model, trim,
price, odometer, transmission and fuel type slides in at the bottom. The dealer name is at the
top. A music track plays under the whole video. The script appends one line per render to
`renders.jsonl` and ends with the count of submitted renders.

`status.mjs` reads `renders.jsonl` and checks each render once. A `done` render prints its video
URL.

The card and the dealer name are `html5` assets. The script writes the vehicle data into their
HTML before the render. To render your own inventory, replace the records in `vehicles.json`.
Photo URLs must be public HTTPS URLs. To change the layout, edit `edit.mjs`.

`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`.
88 changes: 88 additions & 0 deletions examples/automotive-inventory-videos/api.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const API_KEY = process.env.SHOTSTACK_API_KEY;
const ENV = process.env.SHOTSTACK_ENV || 'stage';
const API = `https://api.shotstack.io/edit/${ENV}`;
const TIMEOUT_MS = 30_000;

export function fail(message) {
console.error(message);
process.exit(1);
}

export function requireConfig() {
if (!API_KEY) {
fail('Set SHOTSTACK_API_KEY before you run this script.');
}

if (!['stage', 'v1'].includes(ENV)) {
fail('SHOTSTACK_ENV must be stage or v1.');
}
}

// A rejected key or a dead network fails every record the same way. The
// caller stops the batch at the first one instead of printing a line per record.
function stopBatch(message) {
return Object.assign(new Error(message), { fatal: true });
}

async function apiError(response) {
const text = await response.text();

try {
const body = JSON.parse(text);
return (
body.errors?.[0]?.detail ?? body.response?.error ?? body.message ?? text
);
} catch {
return text;
}
}

async function request(path, options, label) {
let response;

try {
response = await fetch(`${API}${path}`, {
...options,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'x-api-key': API_KEY
},
signal: AbortSignal.timeout(TIMEOUT_MS)
});
} catch {
throw stopBatch(
`${label}: the network request failed. Check your connection and run again.`
);
}

if (response.status === 401 || response.status === 403) {
throw stopBatch(
`${label}: the API rejected the key (${response.status}). Check SHOTSTACK_API_KEY and SHOTSTACK_ENV.`
);
}

if (!response.ok) {
throw new Error(`${label}: ${response.status} ${await apiError(response)}`);
}

return (await response.json()).response;
}

export async function submitRender(edit, label) {
const { id } = await request(
'/render',
{ method: 'POST', body: JSON.stringify(edit) },
label
);

if (!id) {
throw new Error(`${label}: the render response did not contain an id.`);
}

return id;
}

export function getRender(renderId, label) {
return request(`/render/${renderId}`, { method: 'GET' }, label);
}
109 changes: 109 additions & 0 deletions examples/automotive-inventory-videos/edit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
const MUSIC_URL =
'https://shotstack-assets.s3.amazonaws.com/music/unminus/ambition.mp3';
const SECONDS_PER_PHOTO = 3.2;
const MIN_LENGTH = 6;
const EFFECTS = ['zoomIn', 'slideUp', 'zoomOut', 'slideDown'];

const escapeHtml = value =>
String(value).replace(
/[&<>"']/g,
character =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[character]
);

// The specification card is an html5 asset. The vehicle data is written into
// the markup here, so the card can change shape per dealer without a template.
function specCard(vehicle, length) {
const title = escapeHtml(`${vehicle.year} ${vehicle.make} ${vehicle.model}`);
const chips = [vehicle.odometer, vehicle.transmission, vehicle.fuel]
.map(
(text, index) =>
`<span class="chip" id="chip${index}">${escapeHtml(text)}</span>`
)
.join('');

return {
asset: {
type: 'html5',
html: `<div class="card" id="card"><div class="title">${title}</div><div class="trim">${escapeHtml(vehicle.trim)}</div><div class="price">${escapeHtml(vehicle.price)}</div><div class="row">${chips}</div></div>`,
css: 'html,body{margin:0;padding:0;width:960px;height:420px;overflow:hidden;background:transparent;font-family:system-ui,sans-serif}.card{box-sizing:border-box;width:960px;height:420px;padding:40px 48px;background:rgba(10,15,30,0.86);border-radius:32px;color:#fff;opacity:0;transform:translateY(24px)}.title{font-size:60px;font-weight:800;letter-spacing:-1px;line-height:1.05}.trim{margin-top:10px;font-size:34px;color:#cbd5e1}.price{margin-top:22px;font-size:72px;font-weight:800;color:#facc15;line-height:1}.row{display:flex;gap:14px;margin-top:26px}.chip{font-size:28px;padding:10px 20px;border-radius:999px;background:rgba(255,255,255,0.14);color:#e2e8f0;white-space:nowrap;opacity:0;transform:translateY(12px)}',
js: "const tl=gsap.timeline();tl.to('#card',{opacity:1,y:0,duration:0.6,ease:'power3.out'},0).to(['#chip0','#chip1','#chip2'],{opacity:1,y:0,duration:0.6,ease:'power3.out',stagger:0.13},0.3);"
},
start: 0.5,
length: length - 0.5,
width: 960,
height: 420,
position: 'bottom',
offset: { y: 0.05 }
};
}

function dealerBadge(vehicle, length) {
return {
asset: {
type: 'html5',
html: `<div class="badge">${escapeHtml(vehicle.dealer)}</div>`,
css: 'html,body{margin:0;padding:0;width:960px;height:110px;overflow:hidden;background:transparent;font-family:system-ui,sans-serif}.badge{box-sizing:border-box;display:inline-block;height:110px;line-height:110px;padding:0 44px;border-radius:55px;background:rgba(10,15,30,0.7);color:#fff;font-size:42px;font-weight:700}'
},
start: 0,
length,
width: 960,
height: 110,
position: 'top',
offset: { y: -0.04 }
};
}

// One clip per photo on a single track. "auto" starts each clip when the
// previous one ends, so the video is as long as the vehicle has photos.
function photoClips(photos, secondsPerPhoto) {
return photos.map((src, index) => ({
asset: { type: 'image', src },
start: index === 0 ? 0 : 'auto',
length: secondsPerPhoto,
fit: 'crop',
effect: EFFECTS[index % EFFECTS.length],
transition: { in: 'fade' }
}));
}

export function buildEdit(vehicle) {
// A vehicle with one photo still gets a video long enough to read the card.
const secondsPerPhoto = Math.max(
SECONDS_PER_PHOTO,
MIN_LENGTH / vehicle.photos.length
);
const length = secondsPerPhoto * vehicle.photos.length;

return {
timeline: {
background: '#000000',
tracks: [
{ clips: [specCard(vehicle, length)] },
{ clips: [dealerBadge(vehicle, length)] },
{ clips: photoClips(vehicle.photos, secondsPerPhoto) },
{
clips: [
{
asset: {
type: 'audio',
src: MUSIC_URL,
volume: 0.3,
effect: 'fadeOut'
},
start: 0,
length: 'end'
}
]
}
]
},
output: { format: 'mp4', size: { width: 1080, height: 1920 } }
};
}
87 changes: 87 additions & 0 deletions examples/automotive-inventory-videos/render.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { readFile } from 'node:fs/promises';
import { fail, requireConfig, submitRender } from './api.mjs';
import { buildEdit } from './edit.mjs';
import { recordRender } from './renders.mjs';

const REQUIRED_FIELDS = [
'stockId',
'year',
'make',
'model',
'trim',
'price',
'odometer',
'transmission',
'fuel',
'dealer'
];

requireConfig();

let vehicles;

try {
vehicles = JSON.parse(
await readFile(new URL('./vehicles.json', import.meta.url), 'utf8')
);
} catch (error) {
fail(`Could not read vehicles.json: ${error.message}`);
}

if (!Array.isArray(vehicles) || vehicles.length === 0) {
fail('vehicles.json must contain an array with at least one vehicle.');
}

const problems = [];

vehicles.forEach((vehicle, index) => {
const label = vehicle.stockId ?? `vehicle ${index + 1}`;

for (const field of REQUIRED_FIELDS) {
if (vehicle[field] === undefined || vehicle[field] === '') {
problems.push(`${label}: ${field} is required.`);
}
}

if (!Array.isArray(vehicle.photos) || vehicle.photos.length === 0) {
problems.push(`${label}: photos must contain at least one HTTPS URL.`);
return;
}

for (const photo of vehicle.photos) {
if (!/^https:\/\//.test(photo)) {
problems.push(`${label}: photo ${photo} must be an HTTPS URL.`);
}
}
});

if (problems.length > 0) {
fail(problems.join('\n'));
}

let submitted = 0;

for (const vehicle of vehicles) {
try {
const edit = buildEdit(vehicle);
const renderId = await submitRender(edit, vehicle.stockId);

await recordRender({
renderId,
stockId: vehicle.stockId,
submittedAt: new Date().toISOString()
});

console.log(`${vehicle.stockId} → ${renderId}`);
submitted += 1;
} catch (error) {
if (error.fatal) {
fail(error.message);
}

console.error(error.message);
process.exitCode = 1;
}
}

console.log(`${submitted}/${vehicles.length} submitted`);
Loading
Loading