diff --git a/README.md b/README.md index 395e7da..132f872 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 +- [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..5e8bfd2 --- /dev/null +++ b/examples/first-render/.env.example @@ -0,0 +1,2 @@ +# https://dashboard.shotstack.io/register +SHOTSTACK_API_KEY= diff --git a/examples/first-render/.gitignore b/examples/first-render/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/examples/first-render/.gitignore @@ -0,0 +1 @@ +.env diff --git a/examples/first-render/README.md b/examples/first-render/README.md new file mode 100644 index 0000000..2f25b1c --- /dev/null +++ b/examples/first-render/README.md @@ -0,0 +1,60 @@ +# 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.8 or later with + [requests](https://pypi.org/project/requests/) 2 or later + +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 +``` + +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 + +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..ad6e83d --- /dev/null +++ b/examples/first-render/render.mjs @@ -0,0 +1,126 @@ +import { readFile } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; + +// The sandbox environment. The API names it "stage". +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 = {}) { + let response; + + try { + response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + signal: AbortSignal.timeout(30_000), + headers: { + Accept: 'application/json', + 'x-api-key': apiKey, + ...options.headers + } + }); + } catch (error) { + throw new Error( + 'Could not reach the Shotstack API. ' + + 'Check your network connection and try again.', + { cause: error } + ); + } + + const responseText = await response.text(); + let body = null; + + try { + body = JSON.parse(responseText); + } catch { + // Handled below: an error response falls back to the raw body text, and + // a success response that is not JSON gets its own message. + } + + if (!response.ok) { + const detail = + body?.errors?.[0]?.detail || (body ? JSON.stringify(body) : responseText); + throw new Error(`Shotstack returned ${response.status}: ${detail}`); + } + + if (!body || typeof body !== 'object') { + throw new Error( + `Shotstack returned a non-JSON response with status ${response.status}.` + ); + } + + 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') { + if (!render.url) { + throw new Error('The render finished without an output URL.'); + } + 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 ${MAX_WAIT_MS / 60_000} 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..e7bc0f7 --- /dev/null +++ b/examples/first-render/render.py @@ -0,0 +1,130 @@ +import json +import os +import sys +import time +from pathlib import Path + +import requests + +# The sandbox environment. The API names it "stage". +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): + try: + response = requests.request( + method, + f"{API_BASE_URL}{path}", + headers={ + "Accept": "application/json", + "x-api-key": api_key, + }, + timeout=30, + **kwargs, + ) + except requests.RequestException as error: + raise RuntimeError( + "Could not reach the Shotstack API. " + "Check your network connection and try again." + ) from error + + try: + body = response.json() + except ValueError: + body = None + + if not response.ok: + try: + detail = body["errors"][0]["detail"] + except (TypeError, KeyError, IndexError): + if body is None: + detail = response.text + else: + detail = json.dumps(body, separators=(",", ":")) + raise RuntimeError(f"Shotstack returned {response.status_code}: {detail}") + + if not isinstance(body, dict): + raise RuntimeError( + "Shotstack returned a non-JSON response " + f"with status {response.status_code}." + ) + + return body + + +def submit_render(edit, api_key): + result = shotstack_request("POST", "/render", api_key, json=edit) + render_id = (result.get("response") or {}).get("id") + + if not render_id: + raise RuntimeError( + "The response did not contain a render ID: " + f"{json.dumps(result, separators=(',', ':'))}" + ) + + 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") or {} + status = render.get("status") + + if not status: + raise RuntimeError( + "Unexpected status response: " + f"{json.dumps(result, separators=(',', ':'))}" + ) + + print(f"Render status: {status}") + + if status == "done": + if not render.get("url"): + raise RuntimeError("The render finished without an output URL.") + 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 " + f"within {MAX_WAIT_SECONDS // 60} 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 KeyboardInterrupt: + # KeyboardInterrupt is not an Exception, and this script sleeps in the + # poll loop, so it is easy to hit. Exit without a traceback. + raise SystemExit(1) from None + except Exception as error: + # Catch Exception rather than a list of classes. One line, no traceback. + print(error, file=sys.stderr) + raise SystemExit(1) from error