diff --git a/README.md b/README.md index 3b68b18..0fe5ff3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Clone this repo, or head into the folder for the example you want. Each example ## Examples +- [first-render](examples/first-render) the very basics: submit an Edit, poll the render status, and print the output URL, in Node.js and Python. Start here if you are new to the API. Companion code for [Render your first video with the Shotstack API](https://shotstack.io/learn/render-your-first-video-shotstack-api/). - [instagram-ai-video](examples/instagram-ai-video) generates a script, voiceover and background image with AI, renders a 1080x1920 video, and publishes it as an Instagram Reel. Companion code for [How to automate Instagram posts with AI video](https://shotstack.io/learn/automate-instagram-posts-with-ai-video/). - [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/first-render/.env.example b/examples/first-render/.env.example new file mode 100644 index 0000000..2bf0cb3 --- /dev/null +++ b/examples/first-render/.env.example @@ -0,0 +1 @@ +SHOTSTACK_API_KEY=your_sandbox_api_key diff --git a/examples/first-render/.gitignore b/examples/first-render/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/examples/first-render/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/examples/first-render/README.md b/examples/first-render/README.md new file mode 100644 index 0000000..6609e71 --- /dev/null +++ b/examples/first-render/README.md @@ -0,0 +1,51 @@ +# First render + +The very basics of the Shotstack API, and the loop every other example in this cookbook builds on: +submit an Edit, poll the render status, and get the output URL. The same flow is implemented twice, +in Node.js and in Python. Start here if you have never rendered a video with Shotstack before. + +Companion code for [Render your first video with the Shotstack API](https://shotstack.io/learn/render-your-first-video-shotstack-api/). + +## Requirements + +- A [Shotstack account](https://dashboard.shotstack.io/register) and your **sandbox** API key + (dashboard menu under your account name, top right, under **API Keys**) +- Node.js 20 or later, or Python 3 with [requests](https://pypi.org/project/requests/) + +Sandbox renders are watermarked and don't consume credits, but 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/first-render +``` + +Set your sandbox key: + +```bash +export SHOTSTACK_API_KEY="your_sandbox_api_key" +``` + +## Run + +Node.js: + +```bash +node render.mjs +``` + +Python: + +```bash +python3 -m pip install requests +python3 render.py +``` + +## What happens + +Both scripts read `edit.json` (a five-second "Hello World" rich-text video), submit it to the +sandbox render endpoint, poll every five seconds until the render reaches `done` or `failed`, and +print the temporary output URL. A sandbox render finishes in under a minute. The URL expires after +24 hours; see the guide for retrieving the CDN-hosted copy through the Serve API. diff --git a/examples/first-render/edit.json b/examples/first-render/edit.json new file mode 100644 index 0000000..5a57652 --- /dev/null +++ b/examples/first-render/edit.json @@ -0,0 +1,31 @@ +{ + "timeline": { + "background": "#101827", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "Hello World", + "font": { + "size": 64, + "color": "#ffffff" + }, + "align": { + "horizontal": "center", + "vertical": "middle" + } + }, + "start": 0, + "length": 5 + } + ] + } + ] + }, + "output": { + "format": "mp4", + "resolution": "preview" + } +} diff --git a/examples/first-render/render.mjs b/examples/first-render/render.mjs new file mode 100644 index 0000000..178d232 --- /dev/null +++ b/examples/first-render/render.mjs @@ -0,0 +1,108 @@ +import { readFile } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; + +const API_BASE_URL = 'https://api.shotstack.io/edit/stage'; +const POLL_INTERVAL_MS = 5_000; +const MAX_WAIT_MS = 10 * 60 * 1_000; +const apiKey = process.env.SHOTSTACK_API_KEY; + +if (!apiKey) { + console.error('Set the SHOTSTACK_API_KEY environment variable first.'); + process.exit(1); +} + +async function shotstackRequest(path, options = {}) { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + signal: AbortSignal.timeout(30_000), + headers: { + Accept: 'application/json', + 'x-api-key': apiKey, + ...options.headers + } + }); + + const responseText = await response.text(); + let body = null; + + try { + body = JSON.parse(responseText); + } catch { + // The error below includes the raw body when Shotstack does not return JSON. + } + + if (!response.ok) { + const details = body ? JSON.stringify(body) : responseText; + throw new Error( + `Shotstack returned ${response.status} ${response.statusText}: ${details}` + ); + } + + if (!body) { + throw new Error('Shotstack returned an unexpected non-JSON response.'); + } + + return body; +} + +async function submitRender(edit) { + const result = await shotstackRequest('/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edit) + }); + + const renderId = result?.response?.id; + + if (!renderId) { + throw new Error( + `The response did not contain a render ID: ${JSON.stringify(result)}` + ); + } + + return renderId; +} + +async function waitForRender(renderId) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < MAX_WAIT_MS) { + const result = await shotstackRequest(`/render/${renderId}`); + const render = result?.response; + + if (!render?.status) { + throw new Error(`Unexpected status response: ${JSON.stringify(result)}`); + } + + console.log(`Render status: ${render.status}`); + + if (render.status === 'done') { + return render; + } + + if (render.status === 'failed') { + throw new Error( + render.error || 'The render failed without an error message.' + ); + } + + await delay(POLL_INTERVAL_MS); + } + + throw new Error(`Render ${renderId} did not finish within 10 minutes.`); +} + +try { + const edit = JSON.parse( + await readFile(new URL('./edit.json', import.meta.url), 'utf8') + ); + const renderId = await submitRender(edit); + + console.log(`Queued render: ${renderId}`); + + const render = await waitForRender(renderId); + console.log(`Temporary output URL: ${render.url}`); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/examples/first-render/render.py b/examples/first-render/render.py new file mode 100644 index 0000000..c7cd51d --- /dev/null +++ b/examples/first-render/render.py @@ -0,0 +1,108 @@ +import json +import os +import sys +import time +from pathlib import Path + +import requests + +API_BASE_URL = "https://api.shotstack.io/edit/stage" +POLL_INTERVAL_SECONDS = 5 +MAX_WAIT_SECONDS = 10 * 60 + + +def shotstack_request(method, path, api_key, **kwargs): + response = requests.request( + method, + f"{API_BASE_URL}{path}", + headers={ + "Accept": "application/json", + "x-api-key": api_key, + }, + timeout=30, + **kwargs, + ) + + try: + body = response.json() + except requests.exceptions.JSONDecodeError as error: + raise RuntimeError( + "Shotstack returned a non-JSON response " + f"with status {response.status_code}." + ) from error + + if not response.ok: + raise RuntimeError( + f"Shotstack returned {response.status_code}: {json.dumps(body)}" + ) + + return body + + +def submit_render(edit, api_key): + result = shotstack_request("POST", "/render", api_key, json=edit) + render_id = result.get("response", {}).get("id") + + if not render_id: + raise RuntimeError( + f"The response did not contain a render ID: {json.dumps(result)}" + ) + + return render_id + + +def wait_for_render(render_id, api_key): + started_at = time.monotonic() + + while time.monotonic() - started_at < MAX_WAIT_SECONDS: + result = shotstack_request("GET", f"/render/{render_id}", api_key) + render = result.get("response", {}) + status = render.get("status") + + if not status: + raise RuntimeError(f"Unexpected status response: {json.dumps(result)}") + + print(f"Render status: {status}") + + if status == "done": + return render + + if status == "failed": + raise RuntimeError( + render.get("error") or "The render failed without an error message." + ) + + time.sleep(POLL_INTERVAL_SECONDS) + + raise TimeoutError(f"Render {render_id} did not finish within 10 minutes.") + + +def main(): + api_key = os.environ.get("SHOTSTACK_API_KEY") + + if not api_key: + raise RuntimeError("Set the SHOTSTACK_API_KEY environment variable first.") + + edit_path = Path(__file__).with_name("edit.json") + edit = json.loads(edit_path.read_text(encoding="utf-8")) + render_id = submit_render(edit, api_key) + + print(f"Queued render: {render_id}") + + render = wait_for_render(render_id, api_key) + print(f"Temporary output URL: {render['url']}") + + +if __name__ == "__main__": + try: + main() + except ( + OSError, + KeyError, + ValueError, + RuntimeError, + TimeoutError, + requests.RequestException, + ) as error: + print(error, file=sys.stderr) + raise SystemExit(1) from error