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..5e8bfd2 --- /dev/null +++ b/examples/multi-client-video-automation/.env.example @@ -0,0 +1,2 @@ +# https://dashboard.shotstack.io/register +SHOTSTACK_API_KEY= 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..4bd445d --- /dev/null +++ b/examples/multi-client-video-automation/README.md @@ -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. diff --git a/examples/multi-client-video-automation/clients.mjs b/examples/multi-client-video-automation/clients.mjs new file mode 100644 index 0000000..bb27f42 --- /dev/null +++ b/examples/multi-client-video-automation/clients.mjs @@ -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: + '' + }, + + '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: + '' + }, + + '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: + '' + } +}; + +/** + * 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 } + ]; +} diff --git a/examples/multi-client-video-automation/db.mjs b/examples/multi-client-video-automation/db.mjs new file mode 100644 index 0000000..7a6d820 --- /dev/null +++ b/examples/multi-client-video-automation/db.mjs @@ -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 []; + } + } + } +}; diff --git a/examples/multi-client-video-automation/render.mjs b/examples/multi-client-video-automation/render.mjs new file mode 100644 index 0000000..34fc73b --- /dev/null +++ b/examples/multi-client-video-automation/render.mjs @@ -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; diff --git a/examples/multi-client-video-automation/status.mjs b/examples/multi-client-video-automation/status.mjs new file mode 100644 index 0000000..383e6f5 --- /dev/null +++ b/examples/multi-client-video-automation/status.mjs @@ -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}`); +} 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 }}" } + } + } +}