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 @@ -13,6 +13,7 @@ Clone this repository, or open the directory of the example you want. Each examp
- [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/).
- [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/).
- [sports-highlight-videos](examples/sports-highlight-videos) trims a 10 second clip at each goal of a full match recording and joins them in one vertical render, cropped from the landscape footage. Extends [Automate sports video highlights using an API](https://shotstack.io/learn/automated-sports-highlight-video-api/).

## Contributing

Expand Down
6 changes: 6 additions & 0 deletions examples/sports-highlight-videos/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 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=
2 changes: 2 additions & 0 deletions examples/sports-highlight-videos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
renders.jsonl
79 changes: 79 additions & 0 deletions examples/sports-highlight-videos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Sports highlight videos

Turn a full match recording and a list of goals into one vertical highlight video. The match
record has a source video URL and one entry per goal, with the second in the recording where its
highlight starts. The script trims a 10 second clip at each goal and joins the clips in one render.
It shows the full landscape frame in the middle of a vertical video, over a
blurred copy of the same clip. Music plays under the match sound. You
get one 1080 x 1920 MP4 per match.

Related guide: [Automate sports video highlights using an API](https://shotstack.io/learn/automated-sports-highlight-video-api/).

## 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/sports-highlight-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 the render:

```bash
node render.mjs
```

Then check it:

```bash
node status.mjs
```

Run `status.mjs` again until the render shows `done`.

## What happens

`render.mjs` reads `match.json` and checks that it has a match id, an HTTPS source URL and at least
one goal with a time in seconds. It reports every problem it finds, then stops. It builds one Edit
JSON with one clip per goal, 10 seconds each, trimmed from the same source video with the `trim`
property. Each clip starts when the previous one ends. The full frame sits in the middle of the 1080 x 1920 video. A
blurred, darker copy of the same clip fills the space above and below, so the footage is not
cropped or enlarged. The match sound plays at a lower volume, with a music track under it.
The script appends one line to `renders.jsonl`.

`status.mjs` reads `renders.jsonl` and checks the render once. A `done` render prints its video
URL. The sample source video is a 99 minute match of about 700 MB. Shotstack downloads the full
file before it renders, so this render takes longer than a render of a short clip.

The sample match is the recording that the related guide uses. Each goal starts about 3 seconds
before the build-up, so the clip shows the attack, the goal and the celebration. Shotstack does not find the moments to cut. The times come from your own data, such as event
tags from your scoring system or the output of a detection model. To render your own match,
replace the source URL with a public HTTPS URL. Then list the goals. For each goal, set `at` to the
second in the recording where its highlight starts. To change the clip length, edit
`edit.mjs`.

`renders.jsonl` only appends. Delete the file to start a new batch.

To render in production, set `SHOTSTACK_ENV=v1` and put your production key in `.env`.
88 changes: 88 additions & 0 deletions examples/sports-highlight-videos/api.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
55 changes: 55 additions & 0 deletions examples/sports-highlight-videos/edit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const MUSIC_URL =
'https://shotstack-assets.s3.amazonaws.com/music/unminus/lit.mp3';
const CLIP_SECONDS = 10;

// Each goal becomes one trimmed clip of the full match recording. "auto"
// starts each clip when the previous one ends, so the goals join in one
// render with no intermediate files.
function goalClips(match, options) {
return match.goals.map((goal, index) => ({
asset: {
type: 'video',
src: match.source,
trim: goal.at,
volume: options.volume
},
start: index === 0 ? 0 : 'auto',
length: CLIP_SECONDS,
...options.clip
}));
}

// The landscape footage keeps its full frame in the middle of the vertical
// video. A blurred copy of the same clip fills the space above and below, so
// the footage is not cropped or enlarged.
export function buildEdit(match) {
return {
timeline: {
background: '#000000',
tracks: [
{ clips: goalClips(match, { volume: 0.6, clip: { fit: 'contain' } }) },
{
clips: goalClips(match, {
volume: 0,
clip: { fit: 'crop', filter: 'blur', opacity: 0.6 }
})
},
{
clips: [
{
asset: {
type: 'audio',
src: MUSIC_URL,
volume: 0.35,
effect: 'fadeInFadeOut'
},
start: 0,
length: 'end'
}
]
}
]
},
output: { format: 'mp4', size: { width: 1080, height: 1920 } }
};
}
15 changes: 15 additions & 0 deletions examples/sports-highlight-videos/match.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"matchId": "ottoville-v-ottawa-hills",
"source": "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/footage/soccer-game.mp4",
"goals": [
{
"at": 3468
},
{
"at": 4278
},
{
"at": 4598
}
]
}
56 changes: 56 additions & 0 deletions examples/sports-highlight-videos/render.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { readFile } from 'node:fs/promises';
import { fail, requireConfig, submitRender } from './api.mjs';
import { buildEdit } from './edit.mjs';
import { recordRender } from './renders.mjs';

requireConfig();

let match;

try {
match = JSON.parse(
await readFile(new URL('./match.json', import.meta.url), 'utf8')
);
} catch (error) {
fail(`Could not read match.json: ${error.message}`);
}

const problems = [];

for (const field of ['matchId', 'source']) {
if (!match[field]) {
problems.push(`match.json: ${field} is required.`);
}
}

if (match.source && !/^https:\/\//.test(match.source)) {
problems.push('match.json: source must be an HTTPS URL.');
}

if (!Array.isArray(match.goals) || match.goals.length === 0) {
problems.push('match.json: goals must contain at least one goal.');
} else {
match.goals.forEach((goal, index) => {
if (!Number.isFinite(goal.at) || goal.at < 0) {
problems.push(`goal ${index + 1}: at must be a number of seconds.`);
}
});
}

if (problems.length > 0) {
fail(problems.join('\n'));
}

try {
const renderId = await submitRender(buildEdit(match), match.matchId);

await recordRender({
renderId,
matchId: match.matchId,
submittedAt: new Date().toISOString()
});

console.log(`${match.matchId} → ${renderId}`);
} catch (error) {
fail(error.message);
}
40 changes: 40 additions & 0 deletions examples/sports-highlight-videos/renders.mjs
Original file line number Diff line number Diff line change
@@ -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.`
);
}
});
}
Loading
Loading