From d01f1c4e968d6155d9ba460a8eb94cc57c4ca783 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 6 Aug 2026 13:39:50 +1000 Subject: [PATCH] chore: add example standards, template and CI format checks --- .claude/settings.json | 16 ++++ .github/workflows/check.yml | 25 +++++++ README.md | 10 ++- STANDARDS.md | 81 ++++++++++++++++++++ examples/_template/.env.example | 1 + examples/_template/.gitignore | 2 + examples/_template/README.md | 44 +++++++++++ examples/instagram-ai-video/index.js | 94 ++++++++++++------------ examples/instagram-ai-video/package.json | 3 +- examples/rapidreels/package.json | 3 + examples/rapidreels/postcss.config.js | 4 +- examples/reelestate/package.json | 5 +- examples/reelestate/pages/_document.tsx | 10 ++- examples/reelestate/postcss.config.js | 4 +- ruff.toml | 5 ++ 15 files changed, 248 insertions(+), 59 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .github/workflows/check.yml create mode 100644 STANDARDS.md create mode 100644 examples/_template/.env.example create mode 100644 examples/_template/.gitignore create mode 100644 examples/_template/README.md create mode 100644 ruff.toml diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..cbafef9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "echo 'The examples in this repository obey STANDARDS.md in the root directory. Read that file before you add or change an example. It is written in Simplified Technical English (ASD-STE100), and new example documentation must use the same style. The CI pipeline checks the format with .prettierrc and ruff.toml. Run those checks before you commit.'", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..13a2bc0 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,25 @@ +name: check + +on: + pull_request: + push: + branches: [master] + +jobs: + format-and-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - run: npx prettier@3 --check . + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # Passes trivially until an example ships Python. + - run: pip install ruff==0.16.1 && ruff check . diff --git a/README.md b/README.md index 3b68b18..395e7da 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Example applications and companion code for Shotstack guides and tutorials. -Clone this repo, or head into the folder for the example you want. Each example has its own README with the API keys and setup steps it needs. +Clone this repository, or open the directory of the example you want. Each example has its own README. The README gives the API keys and the setup steps for that example. ## Examples @@ -10,13 +10,17 @@ Clone this repo, or head into the folder for the example you want. Each example - [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/). +## Contributing + +To make a new example, copy [`examples/_template`](examples/_template). Then read [STANDARDS.md](STANDARDS.md). It gives the rules for API keys, failures, README structure, and the checks to do before you make a pull request. + ## Editing with an AI agent -If you are working on these examples with Claude Code or another coding agent, install the Shotstack CLI and its skill first: +Install the Shotstack CLI and its skill before you use a coding agent, such as Claude Code, on these examples: ```bash npm install -g @shotstack/cli npx skills add shotstack/shotstack-cli ``` -The skill gives the agent the Edit JSON authoring conventions, which are easy to get wrong from instinct. `shotstack validate ` then lints a template offline, no API key and no render credits. See the [agent guide](https://shotstack.io/docs/guide/agents/cli/). +The skill gives the agent the rules to write Edit JSON. These rules are easy to get wrong. The `shotstack validate ` command then checks a template on your computer. It does not need an API key, and it does not use render credits. For more data, see the [agent guide](https://shotstack.io/docs/guide/agents/cli/). diff --git a/STANDARDS.md b/STANDARDS.md new file mode 100644 index 0000000..0727073 --- /dev/null +++ b/STANDARDS.md @@ -0,0 +1,81 @@ +# Standards for cookbook examples + +All the code in this repository is public. Developers copy it into their own projects. These rules make the +examples consistent and safe to copy. + +To make a new example, copy the `examples/_template/` directory. + +## Structure + +- Put each example in its own directory in `examples/`. +- Each example must run after a new clone of the repository. Do not add steps that are not in the README of + the example. +- The `name` in `package.json` must be the same as the directory name. +- If the example has a `package.json`, give the minimum Node version in `engines`. The minimum version for + the repository is in `.nvmrc`. +- If the example has no `package.json`, no tool can check the minimum version. Do not put flags in the README + that need a specific version of Node. Use environment variables. + +## API keys + +- Read the API key from an environment variable. +- Do not put a key in the code. Do not print a key. Do not commit a key. +- Add a `.env.example` file. Put example values in it. +- For a Next.js example, name the file `.env.local.example`. Next.js reads `.env.local`. +- Add the real environment file to `.gitignore`. + +## Format and lint + +- The `.prettierrc` file in the root directory controls the format of the JavaScript, TypeScript, JSON, CSS + and Markdown files. +- The `ruff.toml` file controls the format of the Python files. +- If the example has a `package.json`, add a `format` script. +- The CI pipeline does `prettier --check` and `ruff check` for each pull request. Do these commands on your + computer first. + +## Failures + +An example can fail for these usual reasons: + +- The user did not set the API key. +- The API rejected the key. +- The network failed. + +For each of these failures, the example must print one line that tells the user what to do. Then the example +must stop with an exit code that is not zero. Do not let the example print a stack trace. + +Most users get the first failure, because they forgot to set the API key. Give the most attention to that +failure. + +## README structure + +Use these sections, in this sequence, in each README: + +1. What the example does, and a link to the related guide if there is one +2. Requirements +3. Setup +4. How to run the example +5. What happens when you run it + +Write as few statements of fact as possible. Each version number, flag and command in a README is a statement +that must stay correct. + +Write the documentation in Simplified Technical English (ASD-STE100). Many readers of this repository do not +have English as their first language. Obey these rules: + +- Write short sentences. Use a maximum of 20 words in an instruction, and 25 words in a description. +- Write one instruction in each sentence. +- Use the active voice. Write "Set the API key", not "The API key must be set". +- Use the same word for the same thing each time. +- Do not use idioms, metaphors or humour. + +## Before you make a pull request + +You must read the code and run it before you make a pull request. Then do these checks: + +1. Clone the repository again. Run the example with no API key. Read the message. +2. Run the example with an incorrect API key. Read the message. +3. Do each command in your README, in the correct sequence. +4. Run the formatter and the linter. +5. If the example is in two languages, compare the two files. If they are different, one of them is + incorrect. diff --git a/examples/_template/.env.example b/examples/_template/.env.example new file mode 100644 index 0000000..aec4f5c --- /dev/null +++ b/examples/_template/.env.example @@ -0,0 +1 @@ +SHOTSTACK_API_KEY=your_api_key diff --git a/examples/_template/.gitignore b/examples/_template/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/examples/_template/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/examples/_template/README.md b/examples/_template/README.md new file mode 100644 index 0000000..e44941e --- /dev/null +++ b/examples/_template/README.md @@ -0,0 +1,44 @@ +# Example name + +What the example does. Write one or two sentences. Tell the user what they get at the end. + +Related guide: [Guide title](https://shotstack.io/learn/...). Remove this line if there is no guide. + +## Requirements + +- A [Shotstack account](https://dashboard.shotstack.io/register) and an API key +- Each other tool that the example needs, and its minimum version + +## Setup + +```bash +git clone https://github.com/shotstack/shotstack-cookbook.git +cd shotstack-cookbook/examples/ +``` + +```bash +export SHOTSTACK_API_KEY="your_api_key" +``` + +## Run + +```bash + +``` + +## What happens + +What the example prints. How long it takes. What to do with the result. + +--- + +Remove this line and all the text below it. + +Keep the five sections above, in this sequence. + +The `STANDARDS.md` file in the root directory gives the rules for all the examples. It tells you how to use +API keys, how to format the code, and what the example must do when it fails. It also lists the checks to do +before you make a pull request. + +Add a `package.json` file only if the example has dependencies. If you add one, set `engines` and a `format` +script. diff --git a/examples/instagram-ai-video/index.js b/examples/instagram-ai-video/index.js index bd6782d..3c4282e 100644 --- a/examples/instagram-ai-video/index.js +++ b/examples/instagram-ai-video/index.js @@ -20,17 +20,17 @@ const openai = () => const ingestHeaders = () => ({ 'x-api-key': process.env.SHOTSTACK_API_KEY }); const editHeaders = () => ({ 'x-api-key': process.env.SHOTSTACK_API_KEY, - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sleep = ms => new Promise(r => setTimeout(r, ms)); function requireEnv(keys) { - const missing = keys.filter((key) => !process.env[key]); + const missing = keys.filter(key => !process.env[key]); if (missing.length) { throw new Error( `Missing environment variables: ${missing.join(', ')}\n` + - 'Copy .env.example to .env and fill in your keys.', + 'Copy .env.example to .env and fill in your keys.' ); } } @@ -45,14 +45,14 @@ async function generateContent(topic) { Schema: { "script": string, "hook": string, "caption": string } - script: a 15-20 second voiceover narration (~40 words), punchy and direct - hook: the on-screen title, under 60 characters -- caption: under 75 characters with 2-3 relevant hashtags`, +- caption: under 75 characters with 2-3 relevant hashtags` }, { role: 'user', - content: `Write a script, hook and caption for an Instagram Reel about: ${topic}`, - }, + content: `Write a script, hook and caption for an Instagram Reel about: ${topic}` + } ], - response_format: { type: 'json_object' }, + response_format: { type: 'json_object' } }); return JSON.parse(response.choices[0].message.content); @@ -66,14 +66,14 @@ async function generateVoiceover(script) { method: 'POST', headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY, - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }, body: JSON.stringify({ text: script, model_id: 'eleven_flash_v2_5', - voice_settings: { stability: 0.5, similarity_boost: 0.75 }, - }), - }, + voice_settings: { stability: 0.5, similarity_boost: 0.75 } + }) + } ); if (!response.ok) throw new Error(`ElevenLabs error: ${response.status}`); @@ -86,7 +86,7 @@ async function generateBackground(topic) { prompt: `Cinematic vertical background image for an Instagram Reel about: ${topic}. Bold colors, visually striking, no text, no people. Designed for 9:16 portrait format.`, size: '1024x1536', - quality: 'medium', + quality: 'medium' }); return Buffer.from(result.data[0].b64_json, 'base64'); @@ -96,7 +96,7 @@ async function uploadToShotstack(buffer, contentType) { const uploadRes = await fetch(`${INGEST_BASE}/upload`, { method: 'POST', headers: { ...ingestHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({}) }); if (!uploadRes.ok) throw new Error(`Ingest upload init failed: ${uploadRes.status}`); @@ -106,13 +106,13 @@ async function uploadToShotstack(buffer, contentType) { const putRes = await fetch(signedUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, - body: buffer, + body: buffer }); if (!putRes.ok) throw new Error(`Signed URL upload failed: ${putRes.status}`); for (let i = 0; i < 20; i++) { const statusRes = await fetch(`${INGEST_BASE}/sources/${sourceId}`, { - headers: ingestHeaders(), + headers: ingestHeaders() }); const { data: src } = await statusRes.json(); if (src.attributes.status === 'ready') @@ -140,7 +140,7 @@ async function renderReel(backgroundUrl, voiceoverUrl, hookText, duration) { family: FONT_FAMILY, size: 54, weight: '700', - color: '#ffffff', + color: '#ffffff' }, stroke: { width: 2, color: '#000000' }, shadow: { @@ -148,19 +148,19 @@ async function renderReel(backgroundUrl, voiceoverUrl, hookText, duration) { offsetY: 4, blur: 12, color: '#000000', - opacity: 0.6, + opacity: 0.6 }, align: { horizontal: 'center', vertical: 'middle' }, - animation: { preset: 'ascend', duration: 0.6, direction: 'up' }, + animation: { preset: 'ascend', duration: 0.6, direction: 'up' } }, start: 0, length: duration, width: 940, height: 400, position: 'bottom', - offset: { y: 0.15 }, // positive y moves up - }, - ], + offset: { y: 0.15 } // positive y moves up + } + ] }, { clips: [ @@ -169,33 +169,33 @@ async function renderReel(backgroundUrl, voiceoverUrl, hookText, duration) { start: 0, length: duration, fit: 'crop', - effect: 'zoomIn', - }, - ], + effect: 'zoomIn' + } + ] }, { clips: [ { asset: { type: 'audio', src: voiceoverUrl, effect: 'fadeOut' }, start: 0, - length: 'auto', - }, - ], - }, - ], + length: 'auto' + } + ] + } + ] }, output: { format: 'mp4', size: { width: 1080, height: 1920 }, fps: 30, - quality: 'medium', - }, + quality: 'medium' + } }; const renderRes = await fetch(`${EDIT_BASE}/render`, { method: 'POST', headers: editHeaders(), - body: JSON.stringify(edit), + body: JSON.stringify(edit) }); if (!renderRes.ok) throw new Error(`Shotstack render submit failed: ${renderRes.status}`); @@ -204,7 +204,7 @@ async function renderReel(backgroundUrl, voiceoverUrl, hookText, duration) { for (let i = 0; i < 30; i++) { await sleep(5000); const statusRes = await fetch(`${EDIT_BASE}/render/${response.id}`, { - headers: editHeaders(), + headers: editHeaders() }); const { response: render } = await statusRes.json(); if (render.status === 'done') return render.url; @@ -225,8 +225,8 @@ async function postToInstagram(videoUrl, caption) { video_url: videoUrl, caption, share_to_feed: 'true', - access_token: accessToken, - }), + access_token: accessToken + }) }); if (!containerRes.ok) throw new Error(`Container creation failed: ${containerRes.status}`); @@ -234,7 +234,7 @@ async function postToInstagram(videoUrl, caption) { for (let i = 0; i < 5; i++) { const statusRes = await fetch( - `${IG_BASE}/${containerId}?fields=status_code&access_token=${accessToken}`, + `${IG_BASE}/${containerId}?fields=status_code&access_token=${accessToken}` ); if (!statusRes.ok) throw new Error(`Container status check failed: ${statusRes.status}`); @@ -244,7 +244,7 @@ async function postToInstagram(videoUrl, caption) { throw new Error(`Instagram container ${status_code.toLowerCase()}`); if (i === 4) throw new Error( - 'Instagram container was not ready to publish after 5 minutes', + 'Instagram container was not ready to publish after 5 minutes' ); await sleep(60_000); } @@ -253,8 +253,8 @@ async function postToInstagram(videoUrl, caption) { method: 'POST', body: new URLSearchParams({ creation_id: containerId, - access_token: accessToken, - }), + access_token: accessToken + }) }); if (!publishRes.ok) throw new Error(`Publish failed: ${publishRes.status}`); const { id: mediaId } = await publishRes.json(); @@ -266,7 +266,7 @@ export async function createAndPostReel(topic, { publish = false } = {}) { 'OPENAI_API_KEY', 'ELEVENLABS_API_KEY', 'ELEVENLABS_VOICE_ID', - 'SHOTSTACK_API_KEY', + 'SHOTSTACK_API_KEY' ]); if (publish) requireEnv(['IG_USER_ID', 'IG_ACCESS_TOKEN']); @@ -277,12 +277,12 @@ export async function createAndPostReel(topic, { publish = false } = {}) { const [voiceoverBuffer, backgroundBuffer] = await Promise.all([ generateVoiceover(script), - generateBackground(topic), + generateBackground(topic) ]); const [voiceover, background] = await Promise.all([ uploadToShotstack(voiceoverBuffer, 'audio/mpeg'), - uploadToShotstack(backgroundBuffer, 'image/png'), + uploadToShotstack(backgroundBuffer, 'image/png') ]); console.log('✓ Assets uploaded to Shotstack Ingest'); @@ -294,7 +294,7 @@ export async function createAndPostReel(topic, { publish = false } = {}) { background.url, voiceover.url, hook, - duration, + duration ); console.log('✓ Reel rendered:', videoUrl); @@ -312,12 +312,12 @@ export async function createAndPostReel(topic, { publish = false } = {}) { async function main() { const args = process.argv.slice(2); const publish = args.includes('--publish'); - const topic = args.find((arg) => !arg.startsWith('--')); + const topic = args.find(arg => !arg.startsWith('--')); if (!topic) { console.error( 'Usage: node --env-file=.env index.js "" [--publish]\n\n' + - 'Renders a Reel and prints the video URL. Add --publish to post it to Instagram.', + 'Renders a Reel and prints the video URL. Add --publish to post it to Instagram.' ); process.exit(1); } @@ -326,7 +326,7 @@ async function main() { } if (import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { + main().catch(error => { console.error(error.message); process.exit(1); }); diff --git a/examples/instagram-ai-video/package.json b/examples/instagram-ai-video/package.json index 779b562..8ff9d54 100644 --- a/examples/instagram-ai-video/package.json +++ b/examples/instagram-ai-video/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "index.js", "scripts": { - "start": "node --env-file=.env index.js" + "start": "node --env-file=.env index.js", + "format": "npx prettier@3 --write ." }, "keywords": [ "shotstack", diff --git a/examples/rapidreels/package.json b/examples/rapidreels/package.json index 373c96d..0daa820 100644 --- a/examples/rapidreels/package.json +++ b/examples/rapidreels/package.json @@ -2,6 +2,9 @@ "name": "rapidreels", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20" + }, "scripts": { "dev": "next dev", "build": "next build", diff --git a/examples/rapidreels/postcss.config.js b/examples/rapidreels/postcss.config.js index 12a703d..5cbc2c7 100644 --- a/examples/rapidreels/postcss.config.js +++ b/examples/rapidreels/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: { tailwindcss: {}, - autoprefixer: {}, - }, + autoprefixer: {} + } }; diff --git a/examples/reelestate/package.json b/examples/reelestate/package.json index 4cfe961..d56ba4e 100644 --- a/examples/reelestate/package.json +++ b/examples/reelestate/package.json @@ -1,7 +1,10 @@ { - "name": "rapidreels", + "name": "reelestate", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20" + }, "scripts": { "dev": "next dev", "build": "next build", diff --git a/examples/reelestate/pages/_document.tsx b/examples/reelestate/pages/_document.tsx index 95ad015..6e9d9a0 100644 --- a/examples/reelestate/pages/_document.tsx +++ b/examples/reelestate/pages/_document.tsx @@ -1,15 +1,19 @@ -import { Html, Head, Main, NextScript } from 'next/document' +import { Html, Head, Main, NextScript } from 'next/document'; export default function Document() { return ( -