diff --git a/README.md b/README.md index 2485268..8c83043 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Clone this repository, or open the directory of the example you want. Each examp - [in-app-video-creation](examples/in-app-video-creation) lets a user create the same promo video three ways: an embedded Studio SDK editor, a quick form, and a one-click headless render, all through one render proxy that keeps the API key server-side. Companion code for [Add video creation to your app without building an editor](https://shotstack.io/learn/add-video-creation-to-your-app/). - [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/). +- [news-article-videos](examples/news-article-videos) renders one vertical video per article from one fixed template: a headline and two images filled in with merge fields. The finished video URL is written back onto the article, so a rerun only renders new stories. - [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/news-article-videos/.env.example b/examples/news-article-videos/.env.example new file mode 100644 index 0000000..1c818b9 --- /dev/null +++ b/examples/news-article-videos/.env.example @@ -0,0 +1,10 @@ +# Your Shotstack sandbox API key: https://dashboard.shotstack.io/register +SHOTSTACK_API_KEY= + +# Optional. stage (sandbox, default) or v1 (production). Use the key from the same environment. +# Both keys are in the dashboard under API Keys: https://dashboard.shotstack.io/ +SHOTSTACK_ENV= + +# Optional. An HTTPS URL on your server. Shotstack posts to it when each render finishes. +# https://shotstack.io/docs/guide/architecting-an-application/webhooks/ +CALLBACK_URL= diff --git a/examples/news-article-videos/.gitignore b/examples/news-article-videos/.gitignore new file mode 100644 index 0000000..09c29c2 --- /dev/null +++ b/examples/news-article-videos/.gitignore @@ -0,0 +1,2 @@ +.env +renders.jsonl diff --git a/examples/news-article-videos/README.md b/examples/news-article-videos/README.md new file mode 100644 index 0000000..b0154a5 --- /dev/null +++ b/examples/news-article-videos/README.md @@ -0,0 +1,77 @@ +# News article videos + +Render one vertical video for each article a newsroom publishes. Each article record has a +headline and two images. One template holds the design. The script fills the template with merge +fields and submits one render per article. When a render finishes, the script writes the video URL +back onto the article, so a rerun only renders new stories. You get one 24 second 1080 x 1920 MP4 +per article. + +## Requirements + +- A [Shotstack account](https://dashboard.shotstack.io/register) and your **sandbox** API key +- Node.js 20 or later + +Sandbox renders are watermarked. Your account needs at least one credit to use the sandbox. + +## Setup + +```bash +git clone https://github.com/shotstack/shotstack-cookbook.git +cd shotstack-cookbook/examples/news-article-videos +``` + +Copy the environment file. Add your sandbox key to `.env`. + +```bash +cp .env.example .env +``` + +Load the file into your shell. Do this in each new terminal: + +```bash +set -a +source .env +set +a +``` + +## Run + +Submit one render per article: + +```bash +node render.mjs +``` + +Then check them: + +```bash +node status.mjs +``` + +Run `status.mjs` again until each render shows `done`. + +## What happens + +`render.mjs` reads `edit.json` once and `articles.json` once. It checks that each article has a +slug, a headline and two HTTPS image URLs. It reports every problem it finds, then stops. It skips +articles that already have a `videoUrl`. For each remaining article it adds a `merge` array to the +template and submits one render. The first image shows for 12 seconds with a slow zoom. The second +image fades in for the next 12 seconds. The headline rises in over a dark panel at the bottom. A +music track plays under the whole video. The script appends one line per render to +`renders.jsonl` and ends with the count of submitted renders. + +`status.mjs` reads `renders.jsonl` and checks each render once. A `done` render prints its video +URL. The script then writes each finished URL into `articles.json` as `videoUrl`. In production, +read the articles from your CMS and write the URL back through its API. A sandbox URL expires +after 24 hours. + +To receive a webhook instead of polling, set `CALLBACK_URL` in `.env` to an HTTPS URL on your +server. Shotstack posts to it when each render finishes. + +To render your own stories, replace the records in `articles.json`. Image URLs must be public +HTTPS URLs. To change the design, edit `edit.json` and keep the `{{PLACEHOLDER}}` tokens. + +`renders.jsonl` only appends. To start a new batch, delete the file and remove the `videoUrl` +fields from `articles.json`. + +To render in production, set `SHOTSTACK_ENV=v1` and put your production key in `.env`. diff --git a/examples/news-article-videos/api.mjs b/examples/news-article-videos/api.mjs new file mode 100644 index 0000000..45a44bb --- /dev/null +++ b/examples/news-article-videos/api.mjs @@ -0,0 +1,88 @@ +const API_KEY = process.env.SHOTSTACK_API_KEY; +const ENV = process.env.SHOTSTACK_ENV || 'stage'; +const API = `https://api.shotstack.io/edit/${ENV}`; +const TIMEOUT_MS = 30_000; + +export function fail(message) { + console.error(message); + process.exit(1); +} + +export function requireConfig() { + if (!API_KEY) { + fail('Set SHOTSTACK_API_KEY before you run this script.'); + } + + if (!['stage', 'v1'].includes(ENV)) { + fail('SHOTSTACK_ENV must be stage or v1.'); + } +} + +// A rejected key or a dead network fails every record the same way. The +// caller stops the batch at the first one instead of printing a line per record. +function stopBatch(message) { + return Object.assign(new Error(message), { fatal: true }); +} + +async function apiError(response) { + const text = await response.text(); + + try { + const body = JSON.parse(text); + return ( + body.errors?.[0]?.detail ?? body.response?.error ?? body.message ?? text + ); + } catch { + return text; + } +} + +async function request(path, options, label) { + let response; + + try { + response = await fetch(`${API}${path}`, { + ...options, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-api-key': API_KEY + }, + signal: AbortSignal.timeout(TIMEOUT_MS) + }); + } catch { + throw stopBatch( + `${label}: the network request failed. Check your connection and run again.` + ); + } + + if (response.status === 401 || response.status === 403) { + throw stopBatch( + `${label}: the API rejected the key (${response.status}). Check SHOTSTACK_API_KEY and SHOTSTACK_ENV.` + ); + } + + if (!response.ok) { + throw new Error(`${label}: ${response.status} ${await apiError(response)}`); + } + + return (await response.json()).response; +} + +export async function submitRender(edit, label) { + const { id } = await request( + '/render', + { method: 'POST', body: JSON.stringify(edit) }, + label + ); + + if (!id) { + throw new Error(`${label}: the render response did not contain an id.`); + } + + return id; +} + +export function getRender(renderId, label) { + return request(`/render/${renderId}`, { method: 'GET' }, label); +} diff --git a/examples/news-article-videos/articles.json b/examples/news-article-videos/articles.json new file mode 100644 index 0000000..784969f --- /dev/null +++ b/examples/news-article-videos/articles.json @@ -0,0 +1,29 @@ +[ + { + "slug": "harbour-beaches-closed", + "headline": "Big swell closes harbour beaches for the weekend", + "images": [ + "https://shotstack-assets.s3.amazonaws.com/images/wave-barrel.jpg", + "https://shotstack-assets.s3.amazonaws.com/images/slideshow2.jpeg" + ], + "videoUrl": "https://shotstack-api-stage-output.s3-ap-southeast-2.amazonaws.com/m0018rioc2/8e0a34d4-cb39-434f-b723-d44a50f7365a.mp4" + }, + { + "slug": "mountain-road-reopens", + "headline": "Mountain road reopens six weeks after the landslide", + "images": [ + "https://shotstack-assets.s3.amazonaws.com/images/slideshow7.jpeg", + "https://shotstack-assets.s3.amazonaws.com/images/slideshow4.jpeg" + ], + "videoUrl": "https://shotstack-api-stage-output.s3-ap-southeast-2.amazonaws.com/m0018rioc2/7bc220e2-db31-43f8-b403-d4e5995a9cf5.mp4" + }, + { + "slug": "rates-fall-again", + "headline": "Home loan rates fall for the third month in a row", + "images": [ + "https://shotstack-assets.s3.amazonaws.com/images/financial-background.jpg", + "https://shotstack-assets.s3.amazonaws.com/images/business-man.jpg" + ], + "videoUrl": "https://shotstack-api-stage-output.s3-ap-southeast-2.amazonaws.com/m0018rioc2/0f30a9ca-22b0-48dd-b928-a443ee70cdec.mp4" + } +] diff --git a/examples/news-article-videos/edit.json b/examples/news-article-videos/edit.json new file mode 100644 index 0000000..1ea93a3 --- /dev/null +++ b/examples/news-article-videos/edit.json @@ -0,0 +1,81 @@ +{ + "timeline": { + "background": "#000000", + "fonts": [ + { + "src": "https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm45xW5rygbi49c.ttf" + } + ], + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "{{HEADLINE}}", + "font": { + "family": "JTUSjIg1_i6t8kCHKm45xW5rygbi49c", + "size": 72, + "weight": "800", + "color": "#ffffff" + }, + "style": { "lineHeight": 1.1 }, + "background": { + "color": "#0b0f1a", + "opacity": 0.82, + "borderRadius": 24 + }, + "padding": { "top": 36, "right": 44, "bottom": 44, "left": 44 }, + "align": { "horizontal": "left", "vertical": "middle" }, + "animation": { + "preset": "ascend", + "duration": 0.6, + "direction": "up" + } + }, + "start": 0.5, + "length": 23.5, + "width": 960, + "height": 420, + "position": "bottom", + "offset": { "y": 0.08 } + } + ] + }, + { + "clips": [ + { + "asset": { "type": "image", "src": "{{IMAGE_1}}" }, + "start": 0, + "length": 12, + "fit": "crop", + "effect": "zoomInSlow" + }, + { + "asset": { "type": "image", "src": "{{IMAGE_2}}" }, + "start": 12, + "length": 12, + "fit": "crop", + "effect": "slideUpSlow", + "transition": { "in": "fade" } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "audio", + "src": "https://shotstack-assets.s3.amazonaws.com/music/unminus/berlin.mp3", + "volume": 0.3, + "effect": "fadeOut" + }, + "start": 0, + "length": "end" + } + ] + } + ] + }, + "output": { "format": "mp4", "size": { "width": 1080, "height": 1920 } } +} diff --git a/examples/news-article-videos/render.mjs b/examples/news-article-videos/render.mjs new file mode 100644 index 0000000..df6c4e6 --- /dev/null +++ b/examples/news-article-videos/render.mjs @@ -0,0 +1,109 @@ +import { readFile } from 'node:fs/promises'; +import { fail, requireConfig, submitRender } from './api.mjs'; +import { recordRender } from './renders.mjs'; + +const REQUIRED_FIELDS = ['slug', 'headline']; + +requireConfig(); + +const callback = process.env.CALLBACK_URL; + +if (callback && !/^https:\/\//.test(callback)) { + fail('CALLBACK_URL must be an HTTPS URL.'); +} + +async function readJson(name) { + try { + return JSON.parse(await readFile(new URL(name, import.meta.url), 'utf8')); + } catch (error) { + fail(`Could not read ${name}: ${error.message}`); + } +} + +const template = await readJson('./edit.json'); +const articles = await readJson('./articles.json'); + +if (!Array.isArray(articles) || articles.length === 0) { + fail('articles.json must contain an array with at least one article.'); +} + +const problems = []; + +articles.forEach((article, index) => { + const label = article.slug ?? `article ${index + 1}`; + + for (const field of REQUIRED_FIELDS) { + if (!article[field]) { + problems.push(`${label}: ${field} is required.`); + } + } + + if ( + !Array.isArray(article.images) || + article.images.length !== 2 || + !article.images.every(url => /^https:\/\//.test(url)) + ) { + problems.push(`${label}: images must list two HTTPS URLs.`); + } +}); + +if (problems.length > 0) { + fail(problems.join('\n')); +} + +// An article that already has a video URL was rendered on an earlier run. +// status.mjs writes that URL back, so a rerun only renders new stories. +const pending = articles.filter(article => !article.videoUrl); + +if (pending.length === 0) { + console.log('Every article already has a video URL. Nothing to render.'); + process.exit(0); +} + +// The template is fixed. Only the merge values change per article, so the +// same edit.json renders every story the newsroom publishes. Every +// placeholder in the template is a string, so every replace value is a string. +function editFor(article) { + const edit = { + ...template, + merge: [ + { find: 'HEADLINE', replace: String(article.headline) }, + { find: 'IMAGE_1', replace: String(article.images[0]) }, + { find: 'IMAGE_2', replace: String(article.images[1]) } + ] + }; + + if (callback) { + edit.callback = callback; + } + + return edit; +} + +let submitted = 0; + +for (const article of pending) { + try { + const renderId = await submitRender(editFor(article), article.slug); + + await recordRender({ + renderId, + slug: article.slug, + submittedAt: new Date().toISOString() + }); + + console.log(`${article.slug} → ${renderId}`); + submitted += 1; + } catch (error) { + if (error.fatal) { + fail(error.message); + } + + console.error(error.message); + process.exitCode = 1; + } +} + +console.log( + `${submitted}/${pending.length} submitted, ${articles.length - pending.length} already had a video URL` +); diff --git a/examples/news-article-videos/renders.mjs b/examples/news-article-videos/renders.mjs new file mode 100644 index 0000000..6debacb --- /dev/null +++ b/examples/news-article-videos/renders.mjs @@ -0,0 +1,40 @@ +import { appendFile, readFile } from 'node:fs/promises'; + +const FILE = new URL('./renders.jsonl', import.meta.url); + +// One JSON object per line, appended. The API has no endpoint that lists +// renders, so this file is the only record of which render belongs to +// which record. +export async function recordRender(row) { + try { + await appendFile(FILE, JSON.stringify(row) + '\n'); + } catch (error) { + throw new Error(`Could not write renders.jsonl: ${error.message}`); + } +} + +export async function readRenders() { + let text; + + try { + text = await readFile(FILE, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + throw new Error(`Could not read renders.jsonl: ${error.message}`); + } + + return text + .split('\n') + .filter(line => line.trim()) + .map((line, index) => { + try { + return JSON.parse(line); + } catch { + throw new Error( + `renders.jsonl line ${index + 1} is not valid JSON. Fix or delete the file and submit again.` + ); + } + }); +} diff --git a/examples/news-article-videos/status.mjs b/examples/news-article-videos/status.mjs new file mode 100644 index 0000000..64bf891 --- /dev/null +++ b/examples/news-article-videos/status.mjs @@ -0,0 +1,77 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { fail, getRender, requireConfig } from './api.mjs'; +import { readRenders } from './renders.mjs'; + +requireConfig(); + +let rows; + +try { + rows = await readRenders(); +} catch (error) { + fail(error.message); +} + +if (rows.length === 0) { + console.log('No renders recorded yet. Run node render.mjs first.'); + process.exit(0); +} + +const finished = new Map(); + +for (const row of rows) { + let render; + + try { + render = await getRender(row.renderId, row.slug); + } catch (error) { + if (error.fatal) { + fail(error.message); + } + + console.error(error.message); + process.exitCode = 1; + continue; + } + + console.log(`${row.slug} → ${render.status}`); + + if (render.status === 'done') { + console.log(` ${render.url}`); + finished.set(row.slug, render.url); + } + + if (render.status === 'failed') { + console.log(` error: ${render.error}`); + } +} + +// Close the loop: put each finished video URL back on its article, the way a +// newsroom would write it back to the CMS. render.mjs skips these next time. +const ARTICLES = new URL('./articles.json', import.meta.url); +let articles; + +try { + articles = JSON.parse(await readFile(ARTICLES, 'utf8')); +} catch (error) { + fail(`Could not read articles.json: ${error.message}`); +} + +let written = 0; + +for (const article of articles) { + if (finished.has(article.slug) && !article.videoUrl) { + article.videoUrl = finished.get(article.slug); + written += 1; + } +} + +if (written > 0) { + try { + await writeFile(ARTICLES, `${JSON.stringify(articles, null, 2)}\n`); + } catch (error) { + fail(`Could not write articles.json: ${error.message}`); + } + + console.log(`Wrote ${written} video URL(s) into articles.json`); +}