diff --git a/README.md b/README.md
index 9150720..90d2f19 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ Clone this repository, or open the directory of the example you want. Each examp
## Examples
- [agency-client-social-videos](examples/agency-client-social-videos) renders one short vertical video per agency client from that client's own photos and video clips, ending on a card in the client's brand color. The Edit JSON is built in code, one clip per media item.
+- [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/).
- [elevenlabs-voiceover-video](examples/elevenlabs-voiceover-video) narrates a video in an ElevenLabs voice, cloned from your own sample if you want, uploads the audio through the Ingest API and renders a captioned video.
- [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/).
diff --git a/examples/automotive-inventory-videos/.env.example b/examples/automotive-inventory-videos/.env.example
new file mode 100644
index 0000000..77bd08f
--- /dev/null
+++ b/examples/automotive-inventory-videos/.env.example
@@ -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=
diff --git a/examples/automotive-inventory-videos/.gitignore b/examples/automotive-inventory-videos/.gitignore
new file mode 100644
index 0000000..09c29c2
--- /dev/null
+++ b/examples/automotive-inventory-videos/.gitignore
@@ -0,0 +1,2 @@
+.env
+renders.jsonl
diff --git a/examples/automotive-inventory-videos/README.md b/examples/automotive-inventory-videos/README.md
new file mode 100644
index 0000000..e4062a7
--- /dev/null
+++ b/examples/automotive-inventory-videos/README.md
@@ -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 dissolves into the next, 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. The sample has one vehicle. Add a record to `vehicles.json` for each vehicle you want to render.
+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`.
diff --git a/examples/automotive-inventory-videos/api.mjs b/examples/automotive-inventory-videos/api.mjs
new file mode 100644
index 0000000..45a44bb
--- /dev/null
+++ b/examples/automotive-inventory-videos/api.mjs
@@ -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);
+}
diff --git a/examples/automotive-inventory-videos/edit.mjs b/examples/automotive-inventory-videos/edit.mjs
new file mode 100644
index 0000000..59e0798
--- /dev/null
+++ b/examples/automotive-inventory-videos/edit.mjs
@@ -0,0 +1,131 @@
+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 =>
+ ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ })[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) =>
+ `${escapeHtml(text)}`
+ )
+ .join('');
+
+ return {
+ asset: {
+ type: 'html5',
+ html: `
${title}
${escapeHtml(vehicle.trim)}
${escapeHtml(vehicle.price)}
${chips}
`,
+ 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: `${escapeHtml(vehicle.dealer)}
`,
+ 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, alternating between two tracks so each photo
+// dissolves into the next instead of dipping to black. An odd photo sits on
+// the upper track: it fades in over the photo below, then fades out to reveal
+// the next photo, which already plays underneath. OVERLAP matches the length
+// of the fade transition.
+const OVERLAP = 1;
+
+function photoClips(photos, secondsPerPhoto) {
+ const upper = [];
+ const lower = [];
+
+ photos.forEach((src, index) => {
+ const isLast = index === photos.length - 1;
+ const clip = {
+ asset: { type: 'image', src },
+ start: index * secondsPerPhoto,
+ length: secondsPerPhoto + (isLast ? 0 : OVERLAP),
+ fit: 'crop',
+ effect: EFFECTS[index % EFFECTS.length]
+ };
+
+ if (index % 2 === 1) {
+ clip.transition = isLast ? { in: 'fade' } : { in: 'fade', out: 'fade' };
+ upper.push(clip);
+ } else {
+ if (index === 0) clip.transition = { in: 'fade' };
+ lower.push(clip);
+ }
+ });
+
+ return { upper, lower };
+}
+
+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;
+ const photos = photoClips(vehicle.photos, secondsPerPhoto);
+
+ return {
+ timeline: {
+ background: '#000000',
+ tracks: [
+ { clips: [specCard(vehicle, length)] },
+ { clips: [dealerBadge(vehicle, length)] },
+ { clips: photos.upper },
+ { clips: photos.lower },
+ {
+ clips: [
+ {
+ asset: {
+ type: 'audio',
+ src: MUSIC_URL,
+ volume: 0.3,
+ effect: 'fadeOut'
+ },
+ start: 0,
+ length: 'end'
+ }
+ ]
+ }
+ ]
+ },
+ output: { format: 'mp4', size: { width: 1080, height: 1920 } }
+ };
+}
diff --git a/examples/automotive-inventory-videos/render.mjs b/examples/automotive-inventory-videos/render.mjs
new file mode 100644
index 0000000..fe513d5
--- /dev/null
+++ b/examples/automotive-inventory-videos/render.mjs
@@ -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`);
diff --git a/examples/automotive-inventory-videos/renders.mjs b/examples/automotive-inventory-videos/renders.mjs
new file mode 100644
index 0000000..6debacb
--- /dev/null
+++ b/examples/automotive-inventory-videos/renders.mjs
@@ -0,0 +1,40 @@
+import { appendFile, readFile } from 'node:fs/promises';
+
+const FILE = new URL('./renders.jsonl', import.meta.url);
+
+// One JSON object per line, appended. The API has no endpoint that lists
+// renders, so this file is the only record of which render belongs to
+// which record.
+export async function recordRender(row) {
+ try {
+ await appendFile(FILE, JSON.stringify(row) + '\n');
+ } catch (error) {
+ throw new Error(`Could not write renders.jsonl: ${error.message}`);
+ }
+}
+
+export async function readRenders() {
+ let text;
+
+ try {
+ text = await readFile(FILE, 'utf8');
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return [];
+ }
+ throw new Error(`Could not read renders.jsonl: ${error.message}`);
+ }
+
+ return text
+ .split('\n')
+ .filter(line => line.trim())
+ .map((line, index) => {
+ try {
+ return JSON.parse(line);
+ } catch {
+ throw new Error(
+ `renders.jsonl line ${index + 1} is not valid JSON. Fix or delete the file and submit again.`
+ );
+ }
+ });
+}
diff --git a/examples/automotive-inventory-videos/status.mjs b/examples/automotive-inventory-videos/status.mjs
new file mode 100644
index 0000000..30f6aee
--- /dev/null
+++ b/examples/automotive-inventory-videos/status.mjs
@@ -0,0 +1,43 @@
+import { fail, getRender, requireConfig } from './api.mjs';
+import { readRenders } from './renders.mjs';
+
+requireConfig();
+
+let rows;
+
+try {
+ rows = await readRenders();
+} catch (error) {
+ fail(error.message);
+}
+
+if (rows.length === 0) {
+ console.log('No renders recorded yet. Run node render.mjs first.');
+ process.exit(0);
+}
+
+for (const row of rows) {
+ let render;
+
+ try {
+ render = await getRender(row.renderId, row.stockId);
+ } catch (error) {
+ if (error.fatal) {
+ fail(error.message);
+ }
+
+ console.error(error.message);
+ process.exitCode = 1;
+ continue;
+ }
+
+ console.log(`${row.stockId} → ${render.status}`);
+
+ if (render.status === 'done') {
+ console.log(` ${render.url}`);
+ }
+
+ if (render.status === 'failed') {
+ console.log(` error: ${render.error}`);
+ }
+}
diff --git a/examples/automotive-inventory-videos/vehicles.json b/examples/automotive-inventory-videos/vehicles.json
new file mode 100644
index 0000000..8961410
--- /dev/null
+++ b/examples/automotive-inventory-videos/vehicles.json
@@ -0,0 +1,23 @@
+[
+ {
+ "stockId": "STK-10482",
+ "year": 2024,
+ "make": "Mercedes-Benz",
+ "model": "A 200",
+ "trim": "Progressive hatch, Jupiter Red",
+ "price": "$47,990",
+ "odometer": "12,450 km",
+ "transmission": "Automatic",
+ "fuel": "Petrol",
+ "dealer": "Northside Motors",
+ "photos": [
+ "https://templates.shotstack.io/car-sale-slideshow-video/27efb285-a3b2-47e6-8440-574a6b660183/pexels-photo-9513395.jpg",
+ "https://templates.shotstack.io/car-sale-slideshow-video/e574b011-2c53-47df-acfe-e0759840a106/source.jpg",
+ "https://templates.shotstack.io/car-sale-slideshow-video/ec960f5b-85be-453e-a951-e1469da2dd6a/source.jpg",
+ "https://templates.shotstack.io/car-sale-slideshow-video/96fd90d8-c88b-482c-a539-e375314c564b/source.jpg",
+ "https://templates.shotstack.io/car-sale-slideshow-video/6726f69d-af0f-4701-b5f9-98594f9ccfad/source.jpg",
+ "https://templates.shotstack.io/car-sale-slideshow-video/a25bb50a-8813-4514-9913-3e323cd7cf8c/source.jpg",
+ "https://templates.shotstack.io/car-sale-slideshow-video/cda2590c-1033-430a-bd0f-ca28506f99c8/source.jpg"
+ ]
+ }
+]