From 333c0d67132f0ebd2161604fd9851e4b613c4dde Mon Sep 17 00:00:00 2001 From: Sonia Srivastava Date: Thu, 24 Sep 2026 14:04:24 +0700 Subject: [PATCH] Add automotive-inventory-videos example --- README.md | 1 + .../automotive-inventory-videos/.env.example | 6 + .../automotive-inventory-videos/.gitignore | 2 + .../automotive-inventory-videos/README.md | 72 ++++++++++++ examples/automotive-inventory-videos/api.mjs | 88 ++++++++++++++ examples/automotive-inventory-videos/edit.mjs | 109 ++++++++++++++++++ .../automotive-inventory-videos/render.mjs | 87 ++++++++++++++ .../automotive-inventory-videos/renders.mjs | 40 +++++++ .../automotive-inventory-videos/status.mjs | 43 +++++++ .../automotive-inventory-videos/vehicles.json | 56 +++++++++ 10 files changed, 504 insertions(+) create mode 100644 examples/automotive-inventory-videos/.env.example create mode 100644 examples/automotive-inventory-videos/.gitignore create mode 100644 examples/automotive-inventory-videos/README.md create mode 100644 examples/automotive-inventory-videos/api.mjs create mode 100644 examples/automotive-inventory-videos/edit.mjs create mode 100644 examples/automotive-inventory-videos/render.mjs create mode 100644 examples/automotive-inventory-videos/renders.mjs create mode 100644 examples/automotive-inventory-videos/status.mjs create mode 100644 examples/automotive-inventory-videos/vehicles.json diff --git a/README.md b/README.md index 2485268..766920d 100644 --- a/README.md +++ b/README.md @@ -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/). 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..4996f02 --- /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 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`. 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..87389af --- /dev/null +++ b/examples/automotive-inventory-videos/edit.mjs @@ -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 => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[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 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 } } + }; +} 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..a114aad --- /dev/null +++ b/examples/automotive-inventory-videos/vehicles.json @@ -0,0 +1,56 @@ +[ + { + "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" + ] + }, + { + "stockId": "STK-10517", + "year": 2023, + "make": "Mercedes-Benz", + "model": "A 250", + "trim": "AMG Line hatch, Jupiter Red", + "price": "$52,500", + "odometer": "28,900 km", + "transmission": "Automatic", + "fuel": "Petrol", + "dealer": "Northside Motors", + "photos": [ + "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/a25bb50a-8813-4514-9913-3e323cd7cf8c/source.jpg", + "https://templates.shotstack.io/car-sale-slideshow-video/6726f69d-af0f-4701-b5f9-98594f9ccfad/source.jpg" + ] + }, + { + "stockId": "STK-10533", + "year": 2022, + "make": "Tesla", + "model": "Model 3", + "trim": "Standard Range Plus, Pearl White", + "price": "$58,900", + "odometer": "41,200 km", + "transmission": "Automatic", + "fuel": "Electric", + "dealer": "Northside Motors", + "photos": [ + "https://templates.shotstack.io/electric-car-for-sale/a7bc8cd1-1cf6-4020-841f-5b47b0d0f3b8/source.jpg" + ] + } +]