From a9f3d5bafc7376e6b2ce9e9a813744b93b3a1a08 Mon Sep 17 00:00:00 2001 From: Musab Date: Thu, 6 Aug 2026 08:35:49 +0500 Subject: [PATCH 1/4] Add bulk-csv-videos example: one video per CSV row from a template, with optional AI data step --- README.md | 1 + examples/bulk-csv-videos/.env.example | 4 + examples/bulk-csv-videos/.gitignore | 5 + examples/bulk-csv-videos/README.md | 86 +++ examples/bulk-csv-videos/bulk-render.mjs | 493 ++++++++++++++++++ examples/bulk-csv-videos/bulk_render.py | 482 +++++++++++++++++ examples/bulk-csv-videos/generate-data-ai.mjs | 139 +++++ examples/bulk-csv-videos/generate-data.mjs | 89 ++++ examples/bulk-csv-videos/package-lock.json | 24 + examples/bulk-csv-videos/package.json | 13 + examples/bulk-csv-videos/template.json | 142 +++++ 11 files changed, 1478 insertions(+) create mode 100644 examples/bulk-csv-videos/.env.example create mode 100644 examples/bulk-csv-videos/.gitignore create mode 100644 examples/bulk-csv-videos/README.md create mode 100644 examples/bulk-csv-videos/bulk-render.mjs create mode 100644 examples/bulk-csv-videos/bulk_render.py create mode 100644 examples/bulk-csv-videos/generate-data-ai.mjs create mode 100644 examples/bulk-csv-videos/generate-data.mjs create mode 100644 examples/bulk-csv-videos/package-lock.json create mode 100644 examples/bulk-csv-videos/package.json create mode 100644 examples/bulk-csv-videos/template.json diff --git a/README.md b/README.md index 395e7da..ad1f35e 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 +- [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/). - [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/). - [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/bulk-csv-videos/.env.example b/examples/bulk-csv-videos/.env.example new file mode 100644 index 0000000..464cc81 --- /dev/null +++ b/examples/bulk-csv-videos/.env.example @@ -0,0 +1,4 @@ +SHOTSTACK_API_KEY=your_sandbox_api_key +SHOTSTACK_ENV=stage +SHOTSTACK_TEMPLATE_ID=your_stage_template_id +ANTHROPIC_API_KEY=your_anthropic_api_key diff --git a/examples/bulk-csv-videos/.gitignore b/examples/bulk-csv-videos/.gitignore new file mode 100644 index 0000000..212e272 --- /dev/null +++ b/examples/bulk-csv-videos/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +products.csv +products-ai.csv +batch-results*.json diff --git a/examples/bulk-csv-videos/README.md b/examples/bulk-csv-videos/README.md new file mode 100644 index 0000000..0c07a04 --- /dev/null +++ b/examples/bulk-csv-videos/README.md @@ -0,0 +1,86 @@ +# Bulk videos from a CSV + +Render one video per row of a CSV from a single reusable template: create the template with merge +fields, generate and validate a 100-row dataset, submit one template render per row at a safe pace, +and track every render in a resumable manifest. The submit loop is implemented twice, in Node.js and +in Python. An optional AI step has Claude write each row's headline and text-to-image prompt, which +the same pipeline validates like any other input. + +Companion code for [Generate videos in bulk with an API and an AI agent](https://shotstack.io/learn/bulk-create-videos-from-csv/). + +## Requirements + +- A [Shotstack account](https://dashboard.shotstack.io/register) with your **sandbox** API key + (dashboard menu under your account name, top right, under **API Keys**) +- Node.js 18 or later, or Python 3 for the submit loop +- Optional, for the AI step: an [Anthropic API key](https://platform.claude.com/) + +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/bulk-csv-videos +npm install +``` + +Copy `.env.example` to `.env` and fill in your keys, or export them: + +```bash +export SHOTSTACK_API_KEY="your_sandbox_api_key" +export SHOTSTACK_ENV="stage" +``` + +## Run + +1. 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 +``` + +2. Generate the 100-row dataset: + +```bash +node generate-data.mjs +``` + +3. Preflight three rows, then submit and poll (Node or Python): + +```bash +export SHOTSTACK_TEMPLATE_ID="your_stage_template_id" +SHOTSTACK_ROW_LIMIT=3 node bulk-render.mjs submit +node bulk-render.mjs status +``` + +```bash +SHOTSTACK_ROW_LIMIT=3 python3 bulk_render.py submit +python3 bulk_render.py status +``` + +Remove `SHOTSTACK_ROW_LIMIT` for the full batch. `node bulk-render.mjs summary` (or the Python +equivalent) reads the local manifest without calling any API. + +4. Optional AI step — Claude writes each row's headline and image prompt, the script validates them, +and the render loop runs unchanged on the new file: + +```bash +export ANTHROPIC_API_KEY="your_anthropic_api_key" +node generate-data-ai.mjs +CSV_PATH=products-ai.csv node bulk-render.mjs submit +``` + +## Notes + +- Templates belong to the environment they were created in. Re-create the template with your + production key and `SHOTSTACK_ENV=v1` before a production run; the ids will differ. +- The manifest (`batch-results.json`) makes reruns safe: rows with a render id are skipped, and + only confirmed failures are retried with `SHOTSTACK_RETRY_FAILED=true`. +- Do not run the Node and Python submitters against the same manifest at the same time. diff --git a/examples/bulk-csv-videos/bulk-render.mjs b/examples/bulk-csv-videos/bulk-render.mjs new file mode 100644 index 0000000..a054e54 --- /dev/null +++ b/examples/bulk-csv-videos/bulk-render.mjs @@ -0,0 +1,493 @@ +import { createHash } from 'node:crypto'; +import { readFile, rename, writeFile } from 'node:fs/promises'; +import { parse } from 'csv-parse/sync'; + +const command = process.argv[2] ?? 'submit'; +const validCommands = new Set(['submit', 'status', 'summary']); + +if (!validCommands.has(command)) { + console.error('Usage: node bulk-render.mjs [submit|status|summary]'); + process.exit(1); +} + +const API_KEY = process.env.SHOTSTACK_API_KEY; +const ENVIRONMENT = process.env.SHOTSTACK_ENV ?? 'stage'; +const TEMPLATE_ID = process.env.SHOTSTACK_TEMPLATE_ID; +const CSV_PATH = process.env.CSV_PATH ?? 'products.csv'; +const MANIFEST_PATH = process.env.MANIFEST_PATH ?? 'batch-results.json'; +const REQUEST_INTERVAL_MS = Number( + process.env.SHOTSTACK_REQUEST_INTERVAL_MS ?? '1000', +); +const ROW_LIMIT = Number(process.env.SHOTSTACK_ROW_LIMIT ?? '0'); +const RETRY_FAILED = process.env.SHOTSTACK_RETRY_FAILED === 'true'; +const MAX_RATE_LIMIT_RETRIES = 3; +const EDIT_BASE = ( + process.env.SHOTSTACK_EDIT_BASE ?? + `https://api.shotstack.io/edit/${ENVIRONMENT}` +).replace(/\/$/, ''); +const SERVE_BASE = ( + process.env.SHOTSTACK_SERVE_BASE ?? + `https://api.shotstack.io/serve/${ENVIRONMENT}` +).replace(/\/$/, ''); + +if (!['stage', 'v1'].includes(ENVIRONMENT)) { + throw new Error('SHOTSTACK_ENV must be stage or v1.'); +} + +if (!Number.isFinite(REQUEST_INTERVAL_MS) || REQUEST_INTERVAL_MS < 0) { + throw new Error('SHOTSTACK_REQUEST_INTERVAL_MS must be zero or greater.'); +} + +if (!Number.isInteger(ROW_LIMIT) || ROW_LIMIT < 0) { + throw new Error('SHOTSTACK_ROW_LIMIT must be a non-negative integer.'); +} + +if (command !== 'summary' && !API_KEY) { + throw new Error('Set SHOTSTACK_API_KEY before running this command.'); +} + +if (command === 'submit' && !TEMPLATE_ID) { + throw new Error('Set SHOTSTACK_TEMPLATE_ID before submitting renders.'); +} + +const sleep = (milliseconds) => + milliseconds > 0 + ? new Promise((resolve) => setTimeout(resolve, milliseconds)) + : Promise.resolve(); + +const hashRow = (row) => + createHash('sha256') + .update(JSON.stringify(row, Object.keys(row).sort())) + .digest('hex'); + +const responseMessage = (body) => + body?.response?.error ?? + body?.response?.message ?? + body?.message ?? + JSON.stringify(body); + +async function parseResponse(response) { + const text = await response.text(); + + if (!text) { + return {}; + } + + try { + return JSON.parse(text); + } catch { + return { message: text }; + } +} + +async function saveManifest(manifest) { + manifest.updatedAt = new Date().toISOString(); + const temporaryPath = `${MANIFEST_PATH}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`); + await rename(temporaryPath, MANIFEST_PATH); +} + +async function loadManifest({ create = false } = {}) { + try { + const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8')); + + if (manifest.environment !== ENVIRONMENT) { + throw new Error( + `${MANIFEST_PATH} belongs to ${manifest.environment}, not ${ENVIRONMENT}.`, + ); + } + + if ( + TEMPLATE_ID && + manifest.templateId && + manifest.templateId !== TEMPLATE_ID + ) { + throw new Error(`${MANIFEST_PATH} belongs to a different template.`); + } + + return manifest; + } catch (error) { + if (error.code !== 'ENOENT' || !create) { + throw error; + } + + return { + version: 1, + environment: ENVIRONMENT, + templateId: TEMPLATE_ID, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + rows: [], + }; + } +} + +async function loadRows() { + const rows = parse(await readFile(CSV_PATH, 'utf8'), { + bom: true, + columns: true, + skip_empty_lines: true, + trim: true, + }); + + const requiredColumns = [ + 'row_id', + 'product_name', + 'headline', + 'price', + 'image_url', + 'brand_color', + ]; + const seenIds = new Set(); + const errors = []; + + rows.forEach((row, index) => { + const line = index + 2; + + for (const column of requiredColumns) { + if (!row[column]) { + errors.push(`Line ${line}: ${column} is required.`); + } + } + + if (!/^[A-Za-z0-9_-]+$/.test(row.row_id ?? '')) { + errors.push( + `Line ${line}: row_id may contain only letters, numbers, _ and -.`, + ); + } + + if (seenIds.has(row.row_id)) { + errors.push(`Line ${line}: duplicate row_id ${row.row_id}.`); + } + seenIds.add(row.row_id); + + if ((row.product_name ?? '').length > 40) { + errors.push(`Line ${line}: product_name must be 40 characters or fewer.`); + } + + if ((row.headline ?? '').length > 55) { + errors.push(`Line ${line}: headline must be 55 characters or fewer.`); + } + + if ((row.price ?? '').length > 20) { + errors.push(`Line ${line}: price must be 20 characters or fewer.`); + } + + try { + const imageUrl = new URL(row.image_url); + if (imageUrl.protocol !== 'https:') { + throw new Error('not HTTPS'); + } + } catch { + errors.push(`Line ${line}: image_url must be a valid HTTPS URL.`); + } + + if (!/^#[0-9A-Fa-f]{6}$/.test(row.brand_color ?? '')) { + errors.push(`Line ${line}: brand_color must be a six-digit hex color.`); + } + }); + + if (errors.length > 0) { + throw new Error(`CSV validation failed:\n${errors.join('\n')}`); + } + + return ROW_LIMIT > 0 ? rows.slice(0, ROW_LIMIT) : rows; +} + +function mergeFields(row) { + return [ + { find: 'PRODUCT_NAME', replace: row.product_name }, + { find: 'HEADLINE', replace: row.headline }, + { find: 'PRICE', replace: row.price }, + { find: 'IMAGE_URL', replace: row.image_url }, + { find: 'BRAND_COLOR', replace: row.brand_color }, + ]; +} + +function retryDelay(response, retryNumber) { + const retryAfter = response.headers.get('retry-after'); + + if (retryAfter && /^\d+$/.test(retryAfter)) { + return Number(retryAfter) * 1000; + } + + if (retryAfter) { + const dateDelay = Date.parse(retryAfter) - Date.now(); + if (Number.isFinite(dateDelay) && dateDelay > 0) { + return dateDelay; + } + } + + return 60_000 * 2 ** retryNumber; +} + +async function submitTemplate(row) { + const payload = { + id: TEMPLATE_ID, + merge: mergeFields(row), + }; + + for (let retry = 0; retry <= MAX_RATE_LIMIT_RETRIES; retry += 1) { + let response; + + try { + response = await fetch(`${EDIT_BASE}/templates/render`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-api-key': API_KEY, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(30_000), + }); + } catch (error) { + return { + kind: 'unknown', + error: `No definitive API response: ${error.message}`, + }; + } + + const body = await parseResponse(response); + + if (response.status === 429) { + if (retry === MAX_RATE_LIMIT_RETRIES) { + return { + kind: 'rejected', + statusCode: 429, + error: responseMessage(body), + }; + } + + const delay = retryDelay(response, retry); + console.warn(`Rate limited. Waiting ${Math.ceil(delay / 1000)} seconds.`); + await sleep(delay); + continue; + } + + if (response.status === 201 && body?.response?.id) { + return { kind: 'accepted', renderId: body.response.id }; + } + + if (response.status >= 400 && response.status < 500) { + return { + kind: 'rejected', + statusCode: response.status, + error: responseMessage(body), + }; + } + + return { + kind: 'unknown', + statusCode: response.status, + error: `Unexpected response: ${responseMessage(body)}`, + }; + } +} + +async function submitRows() { + const rows = await loadRows(); + const manifest = await loadManifest({ create: true }); + + console.log(`Validated ${rows.length} rows from ${CSV_PATH}.`); + + for (const [index, row] of rows.entries()) { + let entry = manifest.rows.find((item) => item.rowId === row.row_id); + + if (entry?.status === 'submitting') { + entry.status = 'unknown'; + entry.error = + 'The previous process stopped during submission. Check the dashboard before retrying.'; + await saveManifest(manifest); + } + + if (entry?.status === 'unknown') { + console.warn(`[${row.row_id}] skipped: previous outcome is unknown.`); + continue; + } + + const canRetry = + RETRY_FAILED && + (entry?.status === 'failed' || entry?.status === 'submission_failed'); + const currentHash = hashRow(row); + + if (entry?.inputHash && entry.inputHash !== currentHash && !canRetry) { + throw new Error( + `Row ${row.row_id} changed after its first submission. ` + + 'Use a new row_id, or retry it only after confirming the previous request failed.', + ); + } + + if (entry?.renderId && !canRetry) { + console.log(`[${row.row_id}] skipped: already has a render ID.`); + continue; + } + + if (entry?.status === 'submission_failed' && !canRetry) { + console.log(`[${row.row_id}] skipped: set SHOTSTACK_RETRY_FAILED=true.`); + continue; + } + + if (!entry) { + entry = { rowId: row.row_id, attempts: 0 }; + manifest.rows.push(entry); + } + + if (canRetry) { + if (entry.renderId) { + entry.previousRenderIds = [ + ...(entry.previousRenderIds ?? []), + entry.renderId, + ]; + } + delete entry.renderId; + delete entry.temporaryUrl; + delete entry.hostedUrl; + delete entry.hostingStatus; + delete entry.statusUpdatedAt; + delete entry.completedAt; + delete entry.submittedAt; + delete entry.statusCode; + } + + entry.inputHash = currentHash; + entry.status = 'submitting'; + entry.error = null; + entry.attempts += 1; + entry.lastAttemptAt = new Date().toISOString(); + await saveManifest(manifest); + + const result = await submitTemplate(row); + + if (result.kind === 'accepted') { + entry.renderId = result.renderId; + entry.status = 'queued'; + entry.submittedAt = new Date().toISOString(); + console.log( + `[${index + 1}/${rows.length}] ${row.row_id} -> ${result.renderId}`, + ); + } else if (result.kind === 'rejected') { + entry.status = 'submission_failed'; + entry.statusCode = result.statusCode; + entry.error = result.error; + console.error(`[${row.row_id}] rejected: ${result.error}`); + } else { + entry.status = 'unknown'; + entry.statusCode = result.statusCode; + entry.error = result.error; + console.error(`[${row.row_id}] unknown outcome: ${result.error}`); + } + + await saveManifest(manifest); + await sleep(REQUEST_INTERVAL_MS); + } + + printSummary(manifest); +} + +async function getJson(url) { + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'x-api-key': API_KEY, + }, + signal: AbortSignal.timeout(30_000), + }); + const body = await parseResponse(response); + return { response, body }; +} + +async function updateStatuses() { + const manifest = await loadManifest(); + + for (const entry of manifest.rows) { + if ( + !entry.renderId || + entry.status === 'failed' || + (entry.status === 'done' && entry.hostedUrl) + ) { + continue; + } + + if (entry.status !== 'done') { + try { + const { response, body } = await getJson( + `${EDIT_BASE}/render/${entry.renderId}?data=false`, + ); + + if (response.ok && body?.response?.status) { + entry.status = body.response.status; + entry.error = body.response.error || null; + entry.temporaryUrl = body.response.url || null; + entry.statusUpdatedAt = body.response.updated || null; + + if (entry.status === 'done' || entry.status === 'failed') { + entry.completedAt = body.response.updated || null; + } + } else { + console.warn( + `[${entry.rowId}] status lookup failed: ${response.status} ${responseMessage(body)}`, + ); + } + } catch (error) { + console.warn(`[${entry.rowId}] status lookup failed: ${error.message}`); + } + } + + if (entry.status === 'done' && !entry.hostedUrl) { + try { + const { response, body } = await getJson( + `${SERVE_BASE}/assets/render/${entry.renderId}`, + ); + + if (response.ok && Array.isArray(body.data)) { + const video = body.data.find( + (asset) => + asset?.attributes?.status === 'ready' && + asset?.attributes?.filename?.endsWith('.mp4'), + ); + const firstAsset = body.data[0]?.attributes; + + entry.hostingStatus = video + ? 'ready' + : (firstAsset?.status ?? 'pending'); + entry.hostedUrl = video?.attributes?.url ?? null; + } else { + entry.hostingStatus = 'pending'; + } + } catch { + entry.hostingStatus = 'pending'; + } + } + + await saveManifest(manifest); + await sleep(REQUEST_INTERVAL_MS); + } + + printSummary(manifest); +} + +function printSummary(manifest) { + const counts = {}; + + for (const entry of manifest.rows) { + counts[entry.status] = (counts[entry.status] ?? 0) + 1; + } + + console.table( + Object.entries(counts) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([status, count]) => ({ status, count })), + ); + console.log( + `Hosted videos ready: ${manifest.rows.filter((row) => row.hostedUrl).length}/${manifest.rows.length}`, + ); +} + +if (command === 'submit') { + await submitRows(); +} else if (command === 'status') { + await updateStatuses(); +} else { + printSummary(await loadManifest()); +} diff --git a/examples/bulk-csv-videos/bulk_render.py b/examples/bulk-csv-videos/bulk_render.py new file mode 100644 index 0000000..cb953d3 --- /dev/null +++ b/examples/bulk-csv-videos/bulk_render.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 + +import csv +import hashlib +import json +import os +import re +import sys +import time +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + + +COMMAND = sys.argv[1] if len(sys.argv) > 1 else "submit" +VALID_COMMANDS = {"submit", "status", "summary"} + +if COMMAND not in VALID_COMMANDS: + raise SystemExit("Usage: python3 bulk_render.py [submit|status|summary]") + +API_KEY = os.environ.get("SHOTSTACK_API_KEY") +ENVIRONMENT = os.environ.get("SHOTSTACK_ENV", "stage") +TEMPLATE_ID = os.environ.get("SHOTSTACK_TEMPLATE_ID") +CSV_PATH = Path(os.environ.get("CSV_PATH", "products.csv")) +MANIFEST_PATH = Path(os.environ.get("MANIFEST_PATH", "batch-results.json")) +REQUEST_INTERVAL_MS = int(os.environ.get("SHOTSTACK_REQUEST_INTERVAL_MS", "1000")) +ROW_LIMIT = int(os.environ.get("SHOTSTACK_ROW_LIMIT", "0")) +RETRY_FAILED = os.environ.get("SHOTSTACK_RETRY_FAILED") == "true" +MAX_RATE_LIMIT_RETRIES = 3 +EDIT_BASE = os.environ.get( + "SHOTSTACK_EDIT_BASE", f"https://api.shotstack.io/edit/{ENVIRONMENT}" +).rstrip("/") +SERVE_BASE = os.environ.get( + "SHOTSTACK_SERVE_BASE", f"https://api.shotstack.io/serve/{ENVIRONMENT}" +).rstrip("/") + +if ENVIRONMENT not in {"stage", "v1"}: + raise SystemExit("SHOTSTACK_ENV must be stage or v1.") + +if REQUEST_INTERVAL_MS < 0: + raise SystemExit("SHOTSTACK_REQUEST_INTERVAL_MS must be zero or greater.") + +if ROW_LIMIT < 0: + raise SystemExit("SHOTSTACK_ROW_LIMIT must be a non-negative integer.") + +if COMMAND != "summary" and not API_KEY: + raise SystemExit("Set SHOTSTACK_API_KEY before running this command.") + +if COMMAND == "submit" and not TEMPLATE_ID: + raise SystemExit("Set SHOTSTACK_TEMPLATE_ID before submitting renders.") + + +def sleep_between_requests(): + if REQUEST_INTERVAL_MS > 0: + time.sleep(REQUEST_INTERVAL_MS / 1000) + + +def row_hash(row): + payload = json.dumps( + row, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def response_message(body): + if not isinstance(body, dict): + return str(body) + response = body.get("response") + if isinstance(response, dict): + return response.get("error") or response.get("message") or str(response) + return body.get("message") or str(body) + + +def save_manifest(manifest): + manifest["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + temporary_path = MANIFEST_PATH.with_name(f"{MANIFEST_PATH.name}.tmp") + temporary_path.write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + os.replace(temporary_path, MANIFEST_PATH) + + +def load_manifest(create=False): + if MANIFEST_PATH.exists(): + manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + if manifest.get("environment") != ENVIRONMENT: + raise RuntimeError( + f"{MANIFEST_PATH} belongs to {manifest.get('environment')}, " + f"not {ENVIRONMENT}." + ) + + if ( + TEMPLATE_ID + and manifest.get("templateId") + and manifest["templateId"] != TEMPLATE_ID + ): + raise RuntimeError(f"{MANIFEST_PATH} belongs to a different template.") + + return manifest + + if not create: + raise FileNotFoundError(MANIFEST_PATH) + + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return { + "version": 1, + "environment": ENVIRONMENT, + "templateId": TEMPLATE_ID, + "createdAt": now, + "updatedAt": now, + "rows": [], + } + + +def load_rows(): + with CSV_PATH.open(newline="", encoding="utf-8-sig") as csv_file: + rows = list(csv.DictReader(csv_file)) + + required_columns = [ + "row_id", + "product_name", + "headline", + "price", + "image_url", + "brand_color", + ] + seen_ids = set() + errors = [] + + for index, row in enumerate(rows, start=2): + for column in required_columns: + if not row.get(column): + errors.append(f"Line {index}: {column} is required.") + + row_id = row.get("row_id", "") + if not re.fullmatch(r"[A-Za-z0-9_-]+", row_id): + errors.append( + f"Line {index}: row_id may contain only letters, numbers, _ and -." + ) + + if row_id in seen_ids: + errors.append(f"Line {index}: duplicate row_id {row_id}.") + seen_ids.add(row_id) + + if len(row.get("product_name", "")) > 40: + errors.append( + f"Line {index}: product_name must be 40 characters or fewer." + ) + + if len(row.get("headline", "")) > 55: + errors.append(f"Line {index}: headline must be 55 characters or fewer.") + + if len(row.get("price", "")) > 20: + errors.append(f"Line {index}: price must be 20 characters or fewer.") + + image_url = urlparse(row.get("image_url", "")) + if image_url.scheme != "https" or not image_url.netloc: + errors.append(f"Line {index}: image_url must be a valid HTTPS URL.") + + if not re.fullmatch(r"#[0-9A-Fa-f]{6}", row.get("brand_color", "")): + errors.append( + f"Line {index}: brand_color must be a six-digit hex color." + ) + + if errors: + raise ValueError("CSV validation failed:\n" + "\n".join(errors)) + + return rows[:ROW_LIMIT] if ROW_LIMIT > 0 else rows + + +def merge_fields(row): + return [ + {"find": "PRODUCT_NAME", "replace": row["product_name"]}, + {"find": "HEADLINE", "replace": row["headline"]}, + {"find": "PRICE", "replace": row["price"]}, + {"find": "IMAGE_URL", "replace": row["image_url"]}, + {"find": "BRAND_COLOR", "replace": row["brand_color"]}, + ] + + +def request_json(method, url, payload=None): + data = json.dumps(payload).encode() if payload is not None else None + headers = { + "Accept": "application/json", + "x-api-key": API_KEY, + } + if data is not None: + headers["Content-Type"] = "application/json" + + request = Request(url, data=data, headers=headers, method=method) + + try: + with urlopen(request, timeout=30) as response: + text = response.read().decode() + body = json.loads(text) if text else {} + return response.status, body, response.headers + except HTTPError as error: + text = error.read().decode() + try: + body = json.loads(text) if text else {} + except json.JSONDecodeError: + body = {"message": text} + return error.code, body, error.headers + + +def retry_delay(headers, retry_number): + retry_after = headers.get("Retry-After") if headers else None + if retry_after and retry_after.isdigit(): + return int(retry_after) + return 60 * (2**retry_number) + + +def submit_template(row): + payload = {"id": TEMPLATE_ID, "merge": merge_fields(row)} + + for retry in range(MAX_RATE_LIMIT_RETRIES + 1): + try: + status_code, body, headers = request_json( + "POST", f"{EDIT_BASE}/templates/render", payload + ) + except (URLError, TimeoutError, OSError) as error: + return { + "kind": "unknown", + "error": f"No definitive API response: {error}", + } + + if status_code == 429: + if retry == MAX_RATE_LIMIT_RETRIES: + return { + "kind": "rejected", + "statusCode": 429, + "error": response_message(body), + } + delay = retry_delay(headers, retry) + print(f"Rate limited. Waiting {delay} seconds.", file=sys.stderr) + time.sleep(delay) + continue + + render_id = ( + body.get("response", {}).get("id") if isinstance(body, dict) else None + ) + if status_code == 201 and render_id: + return {"kind": "accepted", "renderId": render_id} + + if 400 <= status_code < 500: + return { + "kind": "rejected", + "statusCode": status_code, + "error": response_message(body), + } + + return { + "kind": "unknown", + "statusCode": status_code, + "error": f"Unexpected response: {response_message(body)}", + } + + raise RuntimeError("Unreachable") + + +def submit_rows(): + rows = load_rows() + manifest = load_manifest(create=True) + print(f"Validated {len(rows)} rows from {CSV_PATH}.") + + for index, row in enumerate(rows, start=1): + entry = next( + (item for item in manifest["rows"] if item["rowId"] == row["row_id"]), + None, + ) + + if entry and entry.get("status") == "submitting": + entry["status"] = "unknown" + entry["error"] = ( + "The previous process stopped during submission. " + "Check the dashboard before retrying." + ) + save_manifest(manifest) + + if entry and entry.get("status") == "unknown": + print( + f"[{row['row_id']}] skipped: previous outcome is unknown.", + file=sys.stderr, + ) + continue + + can_retry = RETRY_FAILED and entry and entry.get("status") in { + "failed", + "submission_failed", + } + current_hash = row_hash(row) + + if ( + entry + and entry.get("inputHash") + and entry["inputHash"] != current_hash + and not can_retry + ): + raise RuntimeError( + f"Row {row['row_id']} changed after its first submission. " + "Use a new row_id, or retry it only after confirming the " + "previous request failed." + ) + + if entry and entry.get("renderId") and not can_retry: + print(f"[{row['row_id']}] skipped: already has a render ID.") + continue + + if ( + entry + and entry.get("status") == "submission_failed" + and not can_retry + ): + print( + f"[{row['row_id']}] skipped: set SHOTSTACK_RETRY_FAILED=true." + ) + continue + + if entry is None: + entry = {"rowId": row["row_id"], "attempts": 0} + manifest["rows"].append(entry) + + if can_retry: + if entry.get("renderId"): + entry["previousRenderIds"] = [ + *entry.get("previousRenderIds", []), + entry["renderId"], + ] + entry.pop("renderId", None) + entry.pop("temporaryUrl", None) + entry.pop("hostedUrl", None) + entry.pop("hostingStatus", None) + entry.pop("statusUpdatedAt", None) + entry.pop("completedAt", None) + entry.pop("submittedAt", None) + entry.pop("statusCode", None) + + entry["inputHash"] = current_hash + entry["status"] = "submitting" + entry["error"] = None + entry["attempts"] = entry.get("attempts", 0) + 1 + entry["lastAttemptAt"] = time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime() + ) + save_manifest(manifest) + + result = submit_template(row) + + if result["kind"] == "accepted": + entry["renderId"] = result["renderId"] + entry["status"] = "queued" + entry["submittedAt"] = time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime() + ) + print( + f"[{index}/{len(rows)}] {row['row_id']} -> {result['renderId']}" + ) + elif result["kind"] == "rejected": + entry["status"] = "submission_failed" + entry["statusCode"] = result.get("statusCode") + entry["error"] = result["error"] + print( + f"[{row['row_id']}] rejected: {result['error']}", + file=sys.stderr, + ) + else: + entry["status"] = "unknown" + entry["statusCode"] = result.get("statusCode") + entry["error"] = result["error"] + print( + f"[{row['row_id']}] unknown outcome: {result['error']}", + file=sys.stderr, + ) + + save_manifest(manifest) + sleep_between_requests() + + print_summary(manifest) + + +def update_statuses(): + manifest = load_manifest() + + for entry in manifest["rows"]: + if ( + not entry.get("renderId") + or entry.get("status") == "failed" + or (entry.get("status") == "done" and entry.get("hostedUrl")) + ): + continue + + if entry.get("status") != "done": + try: + status_code, body, _ = request_json( + "GET", + f"{EDIT_BASE}/render/{entry['renderId']}?data=false", + ) + response = body.get("response", {}) if isinstance(body, dict) else {} + + if 200 <= status_code < 300 and response.get("status"): + entry["status"] = response["status"] + entry["error"] = response.get("error") or None + entry["temporaryUrl"] = response.get("url") + entry["statusUpdatedAt"] = response.get("updated") + + if entry["status"] in {"done", "failed"}: + entry["completedAt"] = response.get("updated") + else: + print( + f"[{entry['rowId']}] status lookup failed: " + f"{status_code} {response_message(body)}", + file=sys.stderr, + ) + except (URLError, TimeoutError, OSError) as error: + print( + f"[{entry['rowId']}] status lookup failed: {error}", + file=sys.stderr, + ) + + if entry.get("status") == "done" and not entry.get("hostedUrl"): + try: + status_code, body, _ = request_json( + "GET", + f"{SERVE_BASE}/assets/render/{entry['renderId']}", + ) + assets = body.get("data", []) if isinstance(body, dict) else [] + + if 200 <= status_code < 300 and isinstance(assets, list): + video = next( + ( + asset.get("attributes", {}) + for asset in assets + if asset.get("attributes", {}).get("status") == "ready" + and asset.get("attributes", {}) + .get("filename", "") + .endswith(".mp4") + ), + None, + ) + first_asset = ( + assets[0].get("attributes", {}) if assets else {} + ) + entry["hostingStatus"] = ( + "ready" if video else first_asset.get("status", "pending") + ) + entry["hostedUrl"] = video.get("url") if video else None + else: + entry["hostingStatus"] = "pending" + except (URLError, TimeoutError, OSError): + entry["hostingStatus"] = "pending" + + save_manifest(manifest) + sleep_between_requests() + + print_summary(manifest) + + +def print_summary(manifest): + counts = {} + for entry in manifest["rows"]: + status = entry.get("status", "unknown") + counts[status] = counts.get(status, 0) + 1 + + print("status\tcount") + for status in sorted(counts): + print(f"{status}\t{counts[status]}") + + hosted = sum(bool(row.get("hostedUrl")) for row in manifest["rows"]) + print(f"Hosted videos ready: {hosted}/{len(manifest['rows'])}") + + +if COMMAND == "submit": + submit_rows() +elif COMMAND == "status": + update_statuses() +else: + print_summary(load_manifest()) diff --git a/examples/bulk-csv-videos/generate-data-ai.mjs b/examples/bulk-csv-videos/generate-data-ai.mjs new file mode 100644 index 0000000..24c9244 --- /dev/null +++ b/examples/bulk-csv-videos/generate-data-ai.mjs @@ -0,0 +1,139 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { parse } from 'csv-parse/sync'; + +const API_KEY = process.env.ANTHROPIC_API_KEY; +const MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-opus-5'; +const CSV_IN = process.env.CSV_PATH ?? 'products.csv'; +const CSV_OUT = process.env.CSV_AI_PATH ?? 'products-ai.csv'; +const MAX_HEADLINE_LENGTH = 55; +const MAX_PROMPT_LENGTH = 300; + +if (!API_KEY) { + throw new Error('Set ANTHROPIC_API_KEY before running this script.'); +} + +const rows = parse(await readFile(CSV_IN, 'utf8'), { + bom: true, + columns: true, + skip_empty_lines: true, + trim: true, +}); + +const schema = { + type: 'object', + properties: { + headlines: { + type: 'array', + items: { + type: 'object', + properties: { + row_id: { type: 'string' }, + headline: { + type: 'string', + description: `Marketing headline, ${MAX_HEADLINE_LENGTH} characters or fewer`, + }, + image_prompt: { + type: 'string', + description: `Text-to-image prompt for a product background, ${MAX_PROMPT_LENGTH} characters or fewer`, + }, + }, + required: ['row_id', 'headline', 'image_prompt'], + additionalProperties: false, + }, + }, + }, + required: ['headlines'], + additionalProperties: false, +}; + +const products = rows.map(({ row_id, product_name, price }) => ({ + row_id, + product_name, + price, +})); + +const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': API_KEY, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: 16000, + output_config: { + effort: 'low', + format: { type: 'json_schema', schema }, + }, + messages: [ + { + role: 'user', + content: [ + 'For each product below, write one short marketing headline and one text-to-image prompt.', + `Every headline must be ${MAX_HEADLINE_LENGTH} characters or fewer, plain text, no quotes or emoji.`, + `Every image prompt must be ${MAX_PROMPT_LENGTH} characters or fewer and describe a clean product background photo.`, + 'Return exactly one entry per row_id.', + '', + JSON.stringify(products), + ].join('\n'), + }, + ], + }), + signal: AbortSignal.timeout(120_000), +}); + +if (!response.ok) { + throw new Error(`Anthropic API error ${response.status}: ${await response.text()}`); +} + +const body = await response.json(); + +if (body.stop_reason === 'refusal') { + throw new Error('The model declined the request; keep the original headlines.'); +} +if (body.stop_reason === 'max_tokens') { + throw new Error('The response was truncated. Raise max_tokens or send fewer rows.'); +} + +const text = body.content.find((block) => block.type === 'text')?.text ?? '{}'; +const generated = new Map( + (JSON.parse(text).headlines ?? []).map((item) => [item.row_id, item]), +); + +// Validate the model's output with the same rules as any other input. +let headlines = 0; +let prompts = 0; +for (const row of rows) { + const item = generated.get(row.row_id); + const headline = item?.headline?.trim(); + const imagePrompt = item?.image_prompt?.trim(); + + if (headline && headline.length <= MAX_HEADLINE_LENGTH) { + row.headline = headline; + headlines += 1; + } + + if (imagePrompt && imagePrompt.length <= MAX_PROMPT_LENGTH) { + row.image_prompt = imagePrompt; + prompts += 1; + } else { + // Deterministic fallback so the text-to-image asset always has a prompt. + row.image_prompt = `Studio product photo of ${row.product_name} on a plain background`; + } +} + +const columns = ['row_id', 'product_name', 'headline', 'price', 'image_url', 'brand_color', 'image_prompt']; +const csvEscape = (value) => { + const textValue = String(value); + return /[",\n]/.test(textValue) ? `"${textValue.replaceAll('"', '""')}"` : textValue; +}; +const csv = [ + columns.join(','), + ...rows.map((row) => columns.map((column) => csvEscape(row[column])).join(',')), +].join('\n'); + +await writeFile(CSV_OUT, `${csv}\n`, 'utf8'); +console.log( + `Wrote ${CSV_OUT}: ${headlines}/${rows.length} headlines and ${prompts}/${rows.length} image prompts AI-generated.`, +); diff --git a/examples/bulk-csv-videos/generate-data.mjs b/examples/bulk-csv-videos/generate-data.mjs new file mode 100644 index 0000000..2c220f8 --- /dev/null +++ b/examples/bulk-csv-videos/generate-data.mjs @@ -0,0 +1,89 @@ +import { writeFile } from 'node:fs/promises'; + +const imageUrls = [ + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow1.jpeg', + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow2.jpeg', + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow3.jpeg', + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow4.jpeg', + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow5.jpeg', + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow6.jpeg', + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow7.jpeg', +]; + +const headlines = [ + 'New this week', + 'Made for everyday use', + 'A customer favorite', + 'Limited release', + 'Built to last', +]; + +const brandColors = ['#0f766e', '#1d4ed8', '#7c3aed', '#be123c', '#b45309']; + +const preflightRows = [ + { + row_id: 'product-001', + product_name: 'Limited Edition Travel Backpack - XL Pro', + headline: 'Longest approved headline checks wrapping before launch', + price: 'From $199', + image_url: imageUrls[0], + brand_color: brandColors[0], + }, + { + row_id: 'product-002', + product_name: 'Mug', + headline: 'New', + price: '$9.00', + image_url: imageUrls[1], + brand_color: brandColors[1], + }, + { + row_id: 'product-003', + product_name: 'Café "Voyager", Édition', + headline: 'Built for Nairobi, Montréal, and everywhere between', + price: 'From $49', + image_url: imageUrls[2], + brand_color: brandColors[2], + }, +]; + +const csvEscape = (value) => { + const text = String(value); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +}; + +const rows = Array.from({ length: 100 }, (_, index) => { + const number = index + 1; + + if (index < preflightRows.length) { + return preflightRows[index]; + } + + return { + row_id: `product-${String(number).padStart(3, '0')}`, + product_name: `Demo Product ${String(number).padStart(3, '0')}`, + headline: headlines[index % headlines.length], + price: `$${29 + ((number * 7) % 170)}.00`, + image_url: imageUrls[index % imageUrls.length], + brand_color: brandColors[index % brandColors.length], + }; +}); + +const columns = [ + 'row_id', + 'product_name', + 'headline', + 'price', + 'image_url', + 'brand_color', +]; + +const csv = [ + columns.join(','), + ...rows.map((row) => + columns.map((column) => csvEscape(row[column])).join(','), + ), +].join('\n'); + +await writeFile('products.csv', `${csv}\n`, 'utf8'); +console.log(`Created products.csv with ${rows.length} rows.`); diff --git a/examples/bulk-csv-videos/package-lock.json b/examples/bulk-csv-videos/package-lock.json new file mode 100644 index 0000000..6e172a9 --- /dev/null +++ b/examples/bulk-csv-videos/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "bulk-csv-videos", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bulk-csv-videos", + "version": "1.0.0", + "dependencies": { + "csv-parse": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csv-parse": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz", + "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==", + "license": "MIT" + } + } +} diff --git a/examples/bulk-csv-videos/package.json b/examples/bulk-csv-videos/package.json new file mode 100644 index 0000000..2bec299 --- /dev/null +++ b/examples/bulk-csv-videos/package.json @@ -0,0 +1,13 @@ +{ + "name": "bulk-csv-videos", + "version": "1.0.0", + "private": true, + "description": "Render one video per CSV row from a single Shotstack template, with an optional AI data step", + "type": "module", + "engines": { + "node": ">=18" + }, + "dependencies": { + "csv-parse": "^7.0.2" + } +} diff --git a/examples/bulk-csv-videos/template.json b/examples/bulk-csv-videos/template.json new file mode 100644 index 0000000..f70ddac --- /dev/null +++ b/examples/bulk-csv-videos/template.json @@ -0,0 +1,142 @@ +{ + "name": "Bulk catalog promo - 3 seconds", + "template": { + "timeline": { + "background": "{{BRAND_COLOR}}", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "{{PRICE}}", + "font": { + "family": "Montserrat", + "size": 52, + "weight": 800, + "color": "#111827" + }, + "background": { + "color": "#ffffff", + "opacity": 1, + "borderRadius": 24 + }, + "padding": 20, + "align": { + "horizontal": "center", + "vertical": "middle" + } + }, + "start": 0, + "length": 3, + "width": 320, + "height": 120, + "position": "bottom", + "offset": { + "y": 0.05 + }, + "transition": { + "in": "fade", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "{{HEADLINE}}", + "font": { + "family": "Montserrat", + "size": 38, + "weight": 600, + "color": "#ffffff" + }, + "align": { + "horizontal": "center", + "vertical": "middle" + } + }, + "start": 0, + "length": 3, + "width": 620, + "height": 120, + "position": "bottom", + "offset": { + "y": 0.2 + }, + "transition": { + "in": "fade", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "{{PRODUCT_NAME}}", + "font": { + "family": "Montserrat", + "size": 58, + "weight": 800, + "color": "#ffffff" + }, + "align": { + "horizontal": "center", + "vertical": "middle" + } + }, + "start": 0, + "length": 3, + "width": 620, + "height": 180, + "position": "top", + "offset": { + "y": -0.08 + }, + "transition": { + "in": "fade", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "image", + "src": "{{IMAGE_URL}}" + }, + "start": 0, + "length": 3, + "width": 560, + "height": 560, + "fit": "crop", + "position": "center", + "offset": { + "y": 0.04 + }, + "effect": "zoomInSlow", + "transition": { + "in": "fade", + "out": "fade" + } + } + ] + } + ] + }, + "output": { + "format": "mp4", + "resolution": "hd", + "aspectRatio": "9:16", + "fps": 25 + } + } +} From c833746aa2c31e86bc4179b6cd4b5d34b6731672 Mon Sep 17 00:00:00 2001 From: Musab Date: Thu, 6 Aug 2026 08:54:10 +0500 Subject: [PATCH 2/4] Remove simultaneous out-fades from the bulk template so videos end on content --- examples/bulk-csv-videos/template.json | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/examples/bulk-csv-videos/template.json b/examples/bulk-csv-videos/template.json index f70ddac..da06ac1 100644 --- a/examples/bulk-csv-videos/template.json +++ b/examples/bulk-csv-videos/template.json @@ -36,8 +36,7 @@ "y": 0.05 }, "transition": { - "in": "fade", - "out": "fade" + "in": "fade" } } ] @@ -68,8 +67,7 @@ "y": 0.2 }, "transition": { - "in": "fade", - "out": "fade" + "in": "fade" } } ] @@ -100,8 +98,7 @@ "y": -0.08 }, "transition": { - "in": "fade", - "out": "fade" + "in": "fade" } } ] @@ -124,8 +121,7 @@ }, "effect": "zoomInSlow", "transition": { - "in": "fade", - "out": "fade" + "in": "fade" } } ] From b149f014d913f8469050d6e0cdb72d80167a73b2 Mon Sep 17 00:00:00 2001 From: Musab Date: Thu, 6 Aug 2026 09:47:33 +0500 Subject: [PATCH 3/4] Point companion links at the article's final URL --- README.md | 2 +- examples/bulk-csv-videos/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad1f35e..8c474bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Clone this repository, or open the directory of the example you want. Each examp ## Examples -- [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/). +- [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/). - [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/). - [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/bulk-csv-videos/README.md b/examples/bulk-csv-videos/README.md index 0c07a04..a628a68 100644 --- a/examples/bulk-csv-videos/README.md +++ b/examples/bulk-csv-videos/README.md @@ -6,7 +6,7 @@ and track every render in a resumable manifest. The submit loop is implemented t in Python. An optional AI step has Claude write each row's headline and text-to-image prompt, which the same pipeline validates like any other input. -Companion code for [Generate videos in bulk with an API and an AI agent](https://shotstack.io/learn/bulk-create-videos-from-csv/). +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/). ## Requirements From 548d404df6b0671e68e72af1a830b4e4a849ef72 Mon Sep 17 00:00:00 2001 From: Musab Date: Fri, 7 Aug 2026 05:57:56 +0500 Subject: [PATCH 4/4] Apply cookbook standards: one-line failures, IMAGE_PROMPT merge field, formatting --- examples/bulk-csv-videos/.gitignore | 3 +- examples/bulk-csv-videos/README.md | 22 ++- .../__pycache__/bulk_render.cpython-313.pyc | Bin 0 -> 23210 bytes examples/bulk-csv-videos/bulk-render.mjs | 141 ++++++++++++------ examples/bulk-csv-videos/bulk_render.py | 90 +++++++++-- examples/bulk-csv-videos/generate-data-ai.mjs | 132 ++++++++++------ examples/bulk-csv-videos/generate-data.mjs | 22 ++- examples/bulk-csv-videos/package-lock.json | 2 +- examples/bulk-csv-videos/package.json | 5 +- 9 files changed, 289 insertions(+), 128 deletions(-) create mode 100644 examples/bulk-csv-videos/__pycache__/bulk_render.cpython-313.pyc diff --git a/examples/bulk-csv-videos/.gitignore b/examples/bulk-csv-videos/.gitignore index 212e272..bbecc9f 100644 --- a/examples/bulk-csv-videos/.gitignore +++ b/examples/bulk-csv-videos/.gitignore @@ -1,5 +1,4 @@ node_modules/ .env -products.csv -products-ai.csv +products*.csv batch-results*.json diff --git a/examples/bulk-csv-videos/README.md b/examples/bulk-csv-videos/README.md index a628a68..ab39989 100644 --- a/examples/bulk-csv-videos/README.md +++ b/examples/bulk-csv-videos/README.md @@ -12,7 +12,7 @@ Companion code for [Generate videos in bulk with an API and an AI agent](https:/ - A [Shotstack account](https://dashboard.shotstack.io/register) with your **sandbox** API key (dashboard menu under your account name, top right, under **API Keys**) -- Node.js 18 or later, or Python 3 for the submit loop +- Node.js 20 or later, or Python 3 for the submit loop - Optional, for the AI step: an [Anthropic API key](https://platform.claude.com/) Sandbox renders are watermarked, and your account needs at least one credit to use the environment. @@ -25,7 +25,7 @@ cd shotstack-cookbook/examples/bulk-csv-videos npm install ``` -Copy `.env.example` to `.env` and fill in your keys, or export them: +Set your sandbox key and environment: ```bash export SHOTSTACK_API_KEY="your_sandbox_api_key" @@ -69,18 +69,32 @@ Remove `SHOTSTACK_ROW_LIMIT` for the full batch. `node bulk-render.mjs summary` equivalent) reads the local manifest without calling any API. 4. Optional AI step — Claude writes each row's headline and image prompt, the script validates them, -and the render loop runs unchanged on the new file: + and the render loop runs unchanged on the new file. Use a separate manifest, because the AI-written + rows differ from rows already tracked in `batch-results.json`: ```bash export ANTHROPIC_API_KEY="your_anthropic_api_key" node generate-data-ai.mjs -CSV_PATH=products-ai.csv node bulk-render.mjs submit +CSV_PATH=products-ai.csv MANIFEST_PATH=batch-results-ai.json node bulk-render.mjs submit ``` +If your template has a `text-to-image` asset with an `{{IMAGE_PROMPT}}` placeholder, the AI-written +prompt drives it. Templates without the placeholder ignore the extra field. + +## What happens + +`submit` validates the CSV, queues one template render per row, and records every render in the +manifest. `status` polls each render until it is `done` or `failed` and retrieves the hosted video +URL for finished renders. Each command ends with a status table and the count of hosted videos. The +first three rows finish in under a minute; the full batch takes several minutes at the default +one-request-per-second pace. + ## Notes - Templates belong to the environment they were created in. Re-create the template with your production key and `SHOTSTACK_ENV=v1` before a production run; the ids will differ. - The manifest (`batch-results.json`) makes reruns safe: rows with a render id are skipped, and only confirmed failures are retried with `SHOTSTACK_RETRY_FAILED=true`. +- A row marked `unknown` means the process stopped before the API confirmed the request. Check the + render dashboard first. To retry the row, delete its entry from the manifest and run `submit` again. - Do not run the Node and Python submitters against the same manifest at the same time. diff --git a/examples/bulk-csv-videos/__pycache__/bulk_render.cpython-313.pyc b/examples/bulk-csv-videos/__pycache__/bulk_render.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05c08646db017ad1f492db0555e2408e6495880f GIT binary patch literal 23210 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 literal 0 HcmV?d00001 diff --git a/examples/bulk-csv-videos/bulk-render.mjs b/examples/bulk-csv-videos/bulk-render.mjs index a054e54..bf96ae0 100644 --- a/examples/bulk-csv-videos/bulk-render.mjs +++ b/examples/bulk-csv-videos/bulk-render.mjs @@ -16,7 +16,7 @@ const TEMPLATE_ID = process.env.SHOTSTACK_TEMPLATE_ID; const CSV_PATH = process.env.CSV_PATH ?? 'products.csv'; const MANIFEST_PATH = process.env.MANIFEST_PATH ?? 'batch-results.json'; const REQUEST_INTERVAL_MS = Number( - process.env.SHOTSTACK_REQUEST_INTERVAL_MS ?? '1000', + process.env.SHOTSTACK_REQUEST_INTERVAL_MS ?? '1000' ); const ROW_LIMIT = Number(process.env.SHOTSTACK_ROW_LIMIT ?? '0'); const RETRY_FAILED = process.env.SHOTSTACK_RETRY_FAILED === 'true'; @@ -30,37 +30,42 @@ const SERVE_BASE = ( `https://api.shotstack.io/serve/${ENVIRONMENT}` ).replace(/\/$/, ''); +const fail = message => { + console.error(message); + process.exit(1); +}; + if (!['stage', 'v1'].includes(ENVIRONMENT)) { - throw new Error('SHOTSTACK_ENV must be stage or v1.'); + fail('SHOTSTACK_ENV must be stage or v1.'); } if (!Number.isFinite(REQUEST_INTERVAL_MS) || REQUEST_INTERVAL_MS < 0) { - throw new Error('SHOTSTACK_REQUEST_INTERVAL_MS must be zero or greater.'); + fail('SHOTSTACK_REQUEST_INTERVAL_MS must be zero or greater.'); } if (!Number.isInteger(ROW_LIMIT) || ROW_LIMIT < 0) { - throw new Error('SHOTSTACK_ROW_LIMIT must be a non-negative integer.'); + fail('SHOTSTACK_ROW_LIMIT must be a non-negative integer.'); } if (command !== 'summary' && !API_KEY) { - throw new Error('Set SHOTSTACK_API_KEY before running this command.'); + fail('Set SHOTSTACK_API_KEY before running this command.'); } if (command === 'submit' && !TEMPLATE_ID) { - throw new Error('Set SHOTSTACK_TEMPLATE_ID before submitting renders.'); + fail('Set SHOTSTACK_TEMPLATE_ID before submitting renders.'); } -const sleep = (milliseconds) => +const sleep = milliseconds => milliseconds > 0 - ? new Promise((resolve) => setTimeout(resolve, milliseconds)) + ? new Promise(resolve => setTimeout(resolve, milliseconds)) : Promise.resolve(); -const hashRow = (row) => +const hashRow = row => createHash('sha256') .update(JSON.stringify(row, Object.keys(row).sort())) .digest('hex'); -const responseMessage = (body) => +const responseMessage = body => body?.response?.error ?? body?.response?.message ?? body?.message ?? @@ -93,7 +98,7 @@ async function loadManifest({ create = false } = {}) { if (manifest.environment !== ENVIRONMENT) { throw new Error( - `${MANIFEST_PATH} belongs to ${manifest.environment}, not ${ENVIRONMENT}.`, + `${MANIFEST_PATH} belongs to ${manifest.environment}, not ${ENVIRONMENT}.` ); } @@ -117,17 +122,30 @@ async function loadManifest({ create = false } = {}) { templateId: TEMPLATE_ID, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - rows: [], + rows: [] }; } } async function loadRows() { - const rows = parse(await readFile(CSV_PATH, 'utf8'), { + let csvText; + + try { + csvText = await readFile(CSV_PATH, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error( + `Cannot find ${CSV_PATH}. Run node generate-data.mjs first, or set CSV_PATH.` + ); + } + throw error; + } + + const rows = parse(csvText, { bom: true, columns: true, skip_empty_lines: true, - trim: true, + trim: true }); const requiredColumns = [ @@ -136,7 +154,7 @@ async function loadRows() { 'headline', 'price', 'image_url', - 'brand_color', + 'brand_color' ]; const seenIds = new Set(); const errors = []; @@ -152,7 +170,7 @@ async function loadRows() { if (!/^[A-Za-z0-9_-]+$/.test(row.row_id ?? '')) { errors.push( - `Line ${line}: row_id may contain only letters, numbers, _ and -.`, + `Line ${line}: row_id may contain only letters, numbers, _ and -.` ); } @@ -195,13 +213,22 @@ async function loadRows() { } function mergeFields(row) { - return [ + const fields = [ { find: 'PRODUCT_NAME', replace: row.product_name }, { find: 'HEADLINE', replace: row.headline }, { find: 'PRICE', replace: row.price }, { find: 'IMAGE_URL', replace: row.image_url }, - { find: 'BRAND_COLOR', replace: row.brand_color }, + { find: 'BRAND_COLOR', replace: row.brand_color } ]; + + // The AI data step adds an image_prompt column for templates that use a + // text-to-image asset with an {{IMAGE_PROMPT}} placeholder. Templates + // without the placeholder ignore the extra merge field. + if (row.image_prompt) { + fields.push({ find: 'IMAGE_PROMPT', replace: row.image_prompt }); + } + + return fields; } function retryDelay(response, retryNumber) { @@ -224,7 +251,7 @@ function retryDelay(response, retryNumber) { async function submitTemplate(row) { const payload = { id: TEMPLATE_ID, - merge: mergeFields(row), + merge: mergeFields(row) }; for (let retry = 0; retry <= MAX_RATE_LIMIT_RETRIES; retry += 1) { @@ -236,15 +263,15 @@ async function submitTemplate(row) { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-api-key': API_KEY, + 'x-api-key': API_KEY }, body: JSON.stringify(payload), - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(30_000) }); } catch (error) { return { kind: 'unknown', - error: `No definitive API response: ${error.message}`, + error: `No definitive API response: ${error.message}` }; } @@ -255,7 +282,7 @@ async function submitTemplate(row) { return { kind: 'rejected', statusCode: 429, - error: responseMessage(body), + error: responseMessage(body) }; } @@ -273,14 +300,14 @@ async function submitTemplate(row) { return { kind: 'rejected', statusCode: response.status, - error: responseMessage(body), + error: responseMessage(body) }; } return { kind: 'unknown', statusCode: response.status, - error: `Unexpected response: ${responseMessage(body)}`, + error: `Unexpected response: ${responseMessage(body)}` }; } } @@ -288,11 +315,12 @@ async function submitTemplate(row) { async function submitRows() { const rows = await loadRows(); const manifest = await loadManifest({ create: true }); + let failures = 0; console.log(`Validated ${rows.length} rows from ${CSV_PATH}.`); for (const [index, row] of rows.entries()) { - let entry = manifest.rows.find((item) => item.rowId === row.row_id); + let entry = manifest.rows.find(item => item.rowId === row.row_id); if (entry?.status === 'submitting') { entry.status = 'unknown'; @@ -314,7 +342,7 @@ async function submitRows() { if (entry?.inputHash && entry.inputHash !== currentHash && !canRetry) { throw new Error( `Row ${row.row_id} changed after its first submission. ` + - 'Use a new row_id, or retry it only after confirming the previous request failed.', + 'Use a new row_id, or retry it only after confirming the previous request failed.' ); } @@ -337,7 +365,7 @@ async function submitRows() { if (entry.renderId) { entry.previousRenderIds = [ ...(entry.previousRenderIds ?? []), - entry.renderId, + entry.renderId ]; } delete entry.renderId; @@ -364,34 +392,48 @@ async function submitRows() { entry.status = 'queued'; entry.submittedAt = new Date().toISOString(); console.log( - `[${index + 1}/${rows.length}] ${row.row_id} -> ${result.renderId}`, + `[${index + 1}/${rows.length}] ${row.row_id} -> ${result.renderId}` ); } else if (result.kind === 'rejected') { entry.status = 'submission_failed'; entry.statusCode = result.statusCode; entry.error = result.error; + failures += 1; console.error(`[${row.row_id}] rejected: ${result.error}`); } else { entry.status = 'unknown'; entry.statusCode = result.statusCode; entry.error = result.error; + failures += 1; console.error(`[${row.row_id}] unknown outcome: ${result.error}`); } await saveManifest(manifest); + + if (result.kind === 'rejected' && [401, 403].includes(result.statusCode)) { + console.error( + 'The API key was rejected. Check SHOTSTACK_API_KEY and SHOTSTACK_ENV, then run submit again.' + ); + break; + } + await sleep(REQUEST_INTERVAL_MS); } printSummary(manifest); + + if (failures > 0) { + process.exitCode = 1; + } } async function getJson(url) { const response = await fetch(url, { headers: { Accept: 'application/json', - 'x-api-key': API_KEY, + 'x-api-key': API_KEY }, - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(30_000) }); const body = await parseResponse(response); return { response, body }; @@ -412,7 +454,7 @@ async function updateStatuses() { if (entry.status !== 'done') { try { const { response, body } = await getJson( - `${EDIT_BASE}/render/${entry.renderId}?data=false`, + `${EDIT_BASE}/render/${entry.renderId}?data=false` ); if (response.ok && body?.response?.status) { @@ -426,7 +468,7 @@ async function updateStatuses() { } } else { console.warn( - `[${entry.rowId}] status lookup failed: ${response.status} ${responseMessage(body)}`, + `[${entry.rowId}] status lookup failed: ${response.status} ${responseMessage(body)}` ); } } catch (error) { @@ -437,14 +479,14 @@ async function updateStatuses() { if (entry.status === 'done' && !entry.hostedUrl) { try { const { response, body } = await getJson( - `${SERVE_BASE}/assets/render/${entry.renderId}`, + `${SERVE_BASE}/assets/render/${entry.renderId}` ); if (response.ok && Array.isArray(body.data)) { const video = body.data.find( - (asset) => + asset => asset?.attributes?.status === 'ready' && - asset?.attributes?.filename?.endsWith('.mp4'), + asset?.attributes?.filename?.endsWith('.mp4') ); const firstAsset = body.data[0]?.attributes; @@ -477,17 +519,28 @@ function printSummary(manifest) { console.table( Object.entries(counts) .sort(([left], [right]) => left.localeCompare(right)) - .map(([status, count]) => ({ status, count })), + .map(([status, count]) => ({ status, count })) ); console.log( - `Hosted videos ready: ${manifest.rows.filter((row) => row.hostedUrl).length}/${manifest.rows.length}`, + `Hosted videos ready: ${manifest.rows.filter(row => row.hostedUrl).length}/${manifest.rows.length}` ); } -if (command === 'submit') { - await submitRows(); -} else if (command === 'status') { - await updateStatuses(); -} else { - printSummary(await loadManifest()); +try { + if (command === 'submit') { + await submitRows(); + } else if (command === 'status') { + await updateStatuses(); + } else { + printSummary(await loadManifest()); + } +} catch (error) { + if (error.code === 'ENOENT') { + console.error( + `Cannot find ${MANIFEST_PATH}. Run node bulk-render.mjs submit first.` + ); + } else { + console.error(error instanceof Error ? error.message : error); + } + process.exitCode = 1; } diff --git a/examples/bulk-csv-videos/bulk_render.py b/examples/bulk-csv-videos/bulk_render.py index cb953d3..fd25094 100644 --- a/examples/bulk-csv-videos/bulk_render.py +++ b/examples/bulk-csv-videos/bulk_render.py @@ -7,12 +7,13 @@ import re import sys import time +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen - COMMAND = sys.argv[1] if len(sys.argv) > 1 else "submit" VALID_COMMANDS = {"submit", "status", "summary"} @@ -118,6 +119,12 @@ def load_manifest(create=False): def load_rows(): + if not CSV_PATH.exists(): + raise FileNotFoundError( + f"Cannot find {CSV_PATH}. Run node generate-data.mjs first, " + "or set CSV_PATH." + ) + with CSV_PATH.open(newline="", encoding="utf-8-sig") as csv_file: rows = list(csv.DictReader(csv_file)) @@ -174,7 +181,7 @@ def load_rows(): def merge_fields(row): - return [ + fields = [ {"find": "PRODUCT_NAME", "replace": row["product_name"]}, {"find": "HEADLINE", "replace": row["headline"]}, {"find": "PRICE", "replace": row["price"]}, @@ -182,6 +189,23 @@ def merge_fields(row): {"find": "BRAND_COLOR", "replace": row["brand_color"]}, ] + # The AI data step adds an image_prompt column for templates that use a + # text-to-image asset with an {{IMAGE_PROMPT}} placeholder. Templates + # without the placeholder ignore the extra merge field. + if row.get("image_prompt"): + fields.append({"find": "IMAGE_PROMPT", "replace": row["image_prompt"]}) + + return fields + + +def parse_json_body(text): + if not text: + return {} + try: + return json.loads(text) + except json.JSONDecodeError: + return {"message": text} + def request_json(method, url, payload=None): data = json.dumps(payload).encode() if payload is not None else None @@ -196,22 +220,29 @@ def request_json(method, url, payload=None): try: with urlopen(request, timeout=30) as response: - text = response.read().decode() - body = json.loads(text) if text else {} + body = parse_json_body(response.read().decode()) return response.status, body, response.headers except HTTPError as error: - text = error.read().decode() - try: - body = json.loads(text) if text else {} - except json.JSONDecodeError: - body = {"message": text} + body = parse_json_body(error.read().decode()) return error.code, body, error.headers def retry_delay(headers, retry_number): retry_after = headers.get("Retry-After") if headers else None + if retry_after and retry_after.isdigit(): return int(retry_after) + + if retry_after: + try: + date_delay = ( + parsedate_to_datetime(retry_after) - datetime.now(timezone.utc) + ).total_seconds() + if date_delay > 0: + return date_delay + except (TypeError, ValueError): + pass + return 60 * (2**retry_number) @@ -266,6 +297,7 @@ def submit_template(row): def submit_rows(): rows = load_rows() manifest = load_manifest(create=True) + failures = 0 print(f"Validated {len(rows)} rows from {CSV_PATH}.") for index, row in enumerate(rows, start=1): @@ -364,6 +396,7 @@ def submit_rows(): entry["status"] = "submission_failed" entry["statusCode"] = result.get("statusCode") entry["error"] = result["error"] + failures += 1 print( f"[{row['row_id']}] rejected: {result['error']}", file=sys.stderr, @@ -372,16 +405,29 @@ def submit_rows(): entry["status"] = "unknown" entry["statusCode"] = result.get("statusCode") entry["error"] = result["error"] + failures += 1 print( f"[{row['row_id']}] unknown outcome: {result['error']}", file=sys.stderr, ) save_manifest(manifest) + + if result["kind"] == "rejected" and result.get("statusCode") in {401, 403}: + print( + "The API key was rejected. Check SHOTSTACK_API_KEY and " + "SHOTSTACK_ENV, then run submit again.", + file=sys.stderr, + ) + break + sleep_between_requests() print_summary(manifest) + if failures > 0: + raise SystemExit(1) + def update_statuses(): manifest = load_manifest() @@ -474,9 +520,23 @@ def print_summary(manifest): print(f"Hosted videos ready: {hosted}/{len(manifest['rows'])}") -if COMMAND == "submit": - submit_rows() -elif COMMAND == "status": - update_statuses() -else: - print_summary(load_manifest()) +try: + if COMMAND == "submit": + submit_rows() + elif COMMAND == "status": + update_statuses() + else: + print_summary(load_manifest()) +except FileNotFoundError as error: + if str(error) == str(MANIFEST_PATH): + print( + f"Cannot find {MANIFEST_PATH}. Run python3 bulk_render.py submit " + "first.", + file=sys.stderr, + ) + else: + print(error, file=sys.stderr) + raise SystemExit(1) from error +except (OSError, ValueError, RuntimeError) as error: + print(error, file=sys.stderr) + raise SystemExit(1) from error diff --git a/examples/bulk-csv-videos/generate-data-ai.mjs b/examples/bulk-csv-videos/generate-data-ai.mjs index 24c9244..c514ea9 100644 --- a/examples/bulk-csv-videos/generate-data-ai.mjs +++ b/examples/bulk-csv-videos/generate-data-ai.mjs @@ -8,15 +8,33 @@ const CSV_OUT = process.env.CSV_AI_PATH ?? 'products-ai.csv'; const MAX_HEADLINE_LENGTH = 55; const MAX_PROMPT_LENGTH = 300; +const fail = message => { + console.error(message); + process.exit(1); +}; + if (!API_KEY) { - throw new Error('Set ANTHROPIC_API_KEY before running this script.'); + fail('Set ANTHROPIC_API_KEY before running this script.'); } -const rows = parse(await readFile(CSV_IN, 'utf8'), { +let csvText; + +try { + csvText = await readFile(CSV_IN, 'utf8'); +} catch (error) { + if (error.code === 'ENOENT') { + fail( + `Cannot find ${CSV_IN}. Run node generate-data.mjs first, or set CSV_PATH.` + ); + } + throw error; +} + +const rows = parse(csvText, { bom: true, columns: true, skip_empty_lines: true, - trim: true, + trim: true }); const schema = { @@ -30,75 +48,81 @@ const schema = { row_id: { type: 'string' }, headline: { type: 'string', - description: `Marketing headline, ${MAX_HEADLINE_LENGTH} characters or fewer`, + description: `Marketing headline, ${MAX_HEADLINE_LENGTH} characters or fewer` }, image_prompt: { type: 'string', - description: `Text-to-image prompt for a product background, ${MAX_PROMPT_LENGTH} characters or fewer`, - }, + description: `Text-to-image prompt for a product background, ${MAX_PROMPT_LENGTH} characters or fewer` + } }, required: ['row_id', 'headline', 'image_prompt'], - additionalProperties: false, - }, - }, + additionalProperties: false + } + } }, required: ['headlines'], - additionalProperties: false, + additionalProperties: false }; const products = rows.map(({ row_id, product_name, price }) => ({ row_id, product_name, - price, + price })); -const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-api-key': API_KEY, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: MODEL, - max_tokens: 16000, - output_config: { - effort: 'low', - format: { type: 'json_schema', schema }, +let response; + +try { + response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': API_KEY, + 'anthropic-version': '2023-06-01' }, - messages: [ - { - role: 'user', - content: [ - 'For each product below, write one short marketing headline and one text-to-image prompt.', - `Every headline must be ${MAX_HEADLINE_LENGTH} characters or fewer, plain text, no quotes or emoji.`, - `Every image prompt must be ${MAX_PROMPT_LENGTH} characters or fewer and describe a clean product background photo.`, - 'Return exactly one entry per row_id.', - '', - JSON.stringify(products), - ].join('\n'), + body: JSON.stringify({ + model: MODEL, + max_tokens: 16000, + output_config: { + effort: 'low', + format: { type: 'json_schema', schema } }, - ], - }), - signal: AbortSignal.timeout(120_000), -}); + messages: [ + { + role: 'user', + content: [ + 'For each product below, write one short marketing headline and one text-to-image prompt.', + `Every headline must be ${MAX_HEADLINE_LENGTH} characters or fewer, plain text, no quotes or emoji.`, + `Every image prompt must be ${MAX_PROMPT_LENGTH} characters or fewer and describe a clean product background photo.`, + 'Return exactly one entry per row_id.', + '', + JSON.stringify(products) + ].join('\n') + } + ] + }), + signal: AbortSignal.timeout(120_000) + }); +} catch (error) { + fail(`Could not reach the Anthropic API: ${error.message}`); +} if (!response.ok) { - throw new Error(`Anthropic API error ${response.status}: ${await response.text()}`); + fail(`Anthropic API error ${response.status}: ${await response.text()}`); } const body = await response.json(); if (body.stop_reason === 'refusal') { - throw new Error('The model declined the request; keep the original headlines.'); + fail('The model declined the request; keep the original headlines.'); } if (body.stop_reason === 'max_tokens') { - throw new Error('The response was truncated. Raise max_tokens or send fewer rows.'); + fail('The response was truncated. Raise max_tokens or send fewer rows.'); } -const text = body.content.find((block) => block.type === 'text')?.text ?? '{}'; +const text = body.content.find(block => block.type === 'text')?.text ?? '{}'; const generated = new Map( - (JSON.parse(text).headlines ?? []).map((item) => [item.row_id, item]), + (JSON.parse(text).headlines ?? []).map(item => [item.row_id, item]) ); // Validate the model's output with the same rules as any other input. @@ -123,17 +147,27 @@ for (const row of rows) { } } -const columns = ['row_id', 'product_name', 'headline', 'price', 'image_url', 'brand_color', 'image_prompt']; -const csvEscape = (value) => { +const columns = [ + 'row_id', + 'product_name', + 'headline', + 'price', + 'image_url', + 'brand_color', + 'image_prompt' +]; +const csvEscape = value => { const textValue = String(value); - return /[",\n]/.test(textValue) ? `"${textValue.replaceAll('"', '""')}"` : textValue; + return /[",\n]/.test(textValue) + ? `"${textValue.replaceAll('"', '""')}"` + : textValue; }; const csv = [ columns.join(','), - ...rows.map((row) => columns.map((column) => csvEscape(row[column])).join(',')), + ...rows.map(row => columns.map(column => csvEscape(row[column])).join(',')) ].join('\n'); await writeFile(CSV_OUT, `${csv}\n`, 'utf8'); console.log( - `Wrote ${CSV_OUT}: ${headlines}/${rows.length} headlines and ${prompts}/${rows.length} image prompts AI-generated.`, + `Wrote ${CSV_OUT}: ${headlines}/${rows.length} headlines and ${prompts}/${rows.length} image prompts AI-generated.` ); diff --git a/examples/bulk-csv-videos/generate-data.mjs b/examples/bulk-csv-videos/generate-data.mjs index 2c220f8..4ced0e0 100644 --- a/examples/bulk-csv-videos/generate-data.mjs +++ b/examples/bulk-csv-videos/generate-data.mjs @@ -7,7 +7,7 @@ const imageUrls = [ 'https://shotstack-assets.s3.amazonaws.com/images/slideshow4.jpeg', 'https://shotstack-assets.s3.amazonaws.com/images/slideshow5.jpeg', 'https://shotstack-assets.s3.amazonaws.com/images/slideshow6.jpeg', - 'https://shotstack-assets.s3.amazonaws.com/images/slideshow7.jpeg', + 'https://shotstack-assets.s3.amazonaws.com/images/slideshow7.jpeg' ]; const headlines = [ @@ -15,7 +15,7 @@ const headlines = [ 'Made for everyday use', 'A customer favorite', 'Limited release', - 'Built to last', + 'Built to last' ]; const brandColors = ['#0f766e', '#1d4ed8', '#7c3aed', '#be123c', '#b45309']; @@ -27,7 +27,7 @@ const preflightRows = [ headline: 'Longest approved headline checks wrapping before launch', price: 'From $199', image_url: imageUrls[0], - brand_color: brandColors[0], + brand_color: brandColors[0] }, { row_id: 'product-002', @@ -35,7 +35,7 @@ const preflightRows = [ headline: 'New', price: '$9.00', image_url: imageUrls[1], - brand_color: brandColors[1], + brand_color: brandColors[1] }, { row_id: 'product-003', @@ -43,11 +43,11 @@ const preflightRows = [ headline: 'Built for Nairobi, Montréal, and everywhere between', price: 'From $49', image_url: imageUrls[2], - brand_color: brandColors[2], - }, + brand_color: brandColors[2] + } ]; -const csvEscape = (value) => { +const csvEscape = value => { const text = String(value); return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; }; @@ -65,7 +65,7 @@ const rows = Array.from({ length: 100 }, (_, index) => { headline: headlines[index % headlines.length], price: `$${29 + ((number * 7) % 170)}.00`, image_url: imageUrls[index % imageUrls.length], - brand_color: brandColors[index % brandColors.length], + brand_color: brandColors[index % brandColors.length] }; }); @@ -75,14 +75,12 @@ const columns = [ 'headline', 'price', 'image_url', - 'brand_color', + 'brand_color' ]; const csv = [ columns.join(','), - ...rows.map((row) => - columns.map((column) => csvEscape(row[column])).join(','), - ), + ...rows.map(row => columns.map(column => csvEscape(row[column])).join(',')) ].join('\n'); await writeFile('products.csv', `${csv}\n`, 'utf8'); diff --git a/examples/bulk-csv-videos/package-lock.json b/examples/bulk-csv-videos/package-lock.json index 6e172a9..bf651e8 100644 --- a/examples/bulk-csv-videos/package-lock.json +++ b/examples/bulk-csv-videos/package-lock.json @@ -11,7 +11,7 @@ "csv-parse": "^7.0.2" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/csv-parse": { diff --git a/examples/bulk-csv-videos/package.json b/examples/bulk-csv-videos/package.json index 2bec299..8a64548 100644 --- a/examples/bulk-csv-videos/package.json +++ b/examples/bulk-csv-videos/package.json @@ -4,8 +4,11 @@ "private": true, "description": "Render one video per CSV row from a single Shotstack template, with an optional AI data step", "type": "module", + "scripts": { + "format": "npx prettier@3 --write ." + }, "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "csv-parse": "^7.0.2"