Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down
2 changes: 2 additions & 0 deletions examples/first-render/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# https://dashboard.shotstack.io/register
SHOTSTACK_API_KEY=
1 change: 1 addition & 0 deletions examples/first-render/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
60 changes: 60 additions & 0 deletions examples/first-render/README.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions examples/first-render/edit.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
126 changes: 126 additions & 0 deletions examples/first-render/render.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
130 changes: 130 additions & 0 deletions examples/first-render/render.py
Original file line number Diff line number Diff line change
@@ -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
Loading