diff --git a/README.md b/README.md index ebd77b7..218d824 100644 --- a/README.md +++ b/README.md @@ -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/). diff --git a/examples/multi-client-video-automation/.env.example b/examples/multi-client-video-automation/.env.example new file mode 100644 index 0000000..4291ff8 --- /dev/null +++ b/examples/multi-client-video-automation/.env.example @@ -0,0 +1,10 @@ +# https://dashboard.shotstack.io/register +SHOTSTACK_API_KEY= + +# 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= diff --git a/examples/multi-client-video-automation/.gitignore b/examples/multi-client-video-automation/.gitignore new file mode 100644 index 0000000..09c29c2 --- /dev/null +++ b/examples/multi-client-video-automation/.gitignore @@ -0,0 +1,2 @@ +.env +renders.jsonl diff --git a/examples/multi-client-video-automation/README.md b/examples/multi-client-video-automation/README.md new file mode 100644 index 0000000..6b269a2 --- /dev/null +++ b/examples/multi-client-video-automation/README.md @@ -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. diff --git a/examples/multi-client-video-automation/clients.mjs b/examples/multi-client-video-automation/clients.mjs new file mode 100644 index 0000000..27e8b2f --- /dev/null +++ b/examples/multi-client-video-automation/clients.mjs @@ -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: + '' + }, + + '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: + '' + }, + + '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: + '' + } +}; + +/** + * 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) } + ]; +} diff --git a/examples/multi-client-video-automation/db.mjs b/examples/multi-client-video-automation/db.mjs new file mode 100644 index 0000000..7134444 --- /dev/null +++ b/examples/multi-client-video-automation/db.mjs @@ -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.` + ); + } + }); + } + } +}; diff --git a/examples/multi-client-video-automation/render.mjs b/examples/multi-client-video-automation/render.mjs new file mode 100644 index 0000000..4a08271 --- /dev/null +++ b/examples/multi-client-video-automation/render.mjs @@ -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; diff --git a/examples/multi-client-video-automation/status.mjs b/examples/multi-client-video-automation/status.mjs new file mode 100644 index 0000000..b37f317 --- /dev/null +++ b/examples/multi-client-video-automation/status.mjs @@ -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}`); +} diff --git a/examples/multi-client-video-automation/template.json b/examples/multi-client-video-automation/template.json new file mode 100644 index 0000000..f006d0f --- /dev/null +++ b/examples/multi-client-video-automation/template.json @@ -0,0 +1,76 @@ +{ + "name": "Client promo v1", + "template": { + "timeline": { + "background": "#000000", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "{{ HEADLINE }}", + "font": { + "family": "{{ FONT }}", + "size": 48, + "weight": 700, + "color": "#ffffff" + }, + "stroke": { "width": 3, "color": "#000000" }, + "align": { "horizontal": "center", "vertical": "middle" }, + "animation": { "preset": "fadeIn", "duration": 1 } + }, + "start": 0.5, + "length": 4.5, + "width": 1000, + "height": 260 + } + ] + }, + { + "clips": [ + { + "asset": { "type": "svg", "src": "{{ BRAND_MARK }}" }, + "start": 0.5, + "length": 4.5, + "width": 120, + "height": 120, + "fit": "contain", + "position": "topLeft", + "offset": { "x": 0.06, "y": -0.06 } + } + ] + }, + { + "clips": [ + { + "asset": { "type": "video", "src": "{{ FOOTAGE }}" }, + "start": 0, + "length": 5, + "fit": "crop", + "effect": "zoomIn" + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "audio", + "src": "{{ MUSIC }}", + "volume": 0.35, + "effect": "fadeOut" + }, + "start": 0, + "length": "end" + } + ] + } + ] + }, + "output": { + "format": "mp4", + "size": { "width": "{{ WIDTH }}", "height": "{{ HEIGHT }}" } + } + } +}