From 508f50a1c201fa34e72e14bda8575990e38389c0 Mon Sep 17 00:00:00 2001 From: Sonia Srivastava Date: Thu, 24 Sep 2026 14:04:26 +0700 Subject: [PATCH 1/3] Add sports-highlight-videos example --- README.md | 1 + examples/sports-highlight-videos/.env.example | 6 ++ examples/sports-highlight-videos/.gitignore | 2 + examples/sports-highlight-videos/README.md | 76 ++++++++++++++++ examples/sports-highlight-videos/api.mjs | 88 +++++++++++++++++++ examples/sports-highlight-videos/edit.mjs | 42 +++++++++ examples/sports-highlight-videos/match.json | 15 ++++ examples/sports-highlight-videos/render.mjs | 56 ++++++++++++ examples/sports-highlight-videos/renders.mjs | 40 +++++++++ examples/sports-highlight-videos/status.mjs | 43 +++++++++ 10 files changed, 369 insertions(+) create mode 100644 examples/sports-highlight-videos/.env.example create mode 100644 examples/sports-highlight-videos/.gitignore create mode 100644 examples/sports-highlight-videos/README.md create mode 100644 examples/sports-highlight-videos/api.mjs create mode 100644 examples/sports-highlight-videos/edit.mjs create mode 100644 examples/sports-highlight-videos/match.json create mode 100644 examples/sports-highlight-videos/render.mjs create mode 100644 examples/sports-highlight-videos/renders.mjs create mode 100644 examples/sports-highlight-videos/status.mjs diff --git a/README.md b/README.md index 2485268..b819c24 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/sports-highlight-videos/.env.example b/examples/sports-highlight-videos/.env.example new file mode 100644 index 0000000..77bd08f --- /dev/null +++ b/examples/sports-highlight-videos/.env.example @@ -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= diff --git a/examples/sports-highlight-videos/.gitignore b/examples/sports-highlight-videos/.gitignore new file mode 100644 index 0000000..09c29c2 --- /dev/null +++ b/examples/sports-highlight-videos/.gitignore @@ -0,0 +1,2 @@ +.env +renders.jsonl diff --git a/examples/sports-highlight-videos/README.md b/examples/sports-highlight-videos/README.md new file mode 100644 index 0000000..4d0d9d6 --- /dev/null +++ b/examples/sports-highlight-videos/README.md @@ -0,0 +1,76 @@ +# 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 crops the landscape footage to fill a vertical frame and adds music 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 script crops each clip from the center of +the frame to fill 1080 x 1920. 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. 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`. diff --git a/examples/sports-highlight-videos/api.mjs b/examples/sports-highlight-videos/api.mjs new file mode 100644 index 0000000..45a44bb --- /dev/null +++ b/examples/sports-highlight-videos/api.mjs @@ -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); +} diff --git a/examples/sports-highlight-videos/edit.mjs b/examples/sports-highlight-videos/edit.mjs new file mode 100644 index 0000000..e4da455 --- /dev/null +++ b/examples/sports-highlight-videos/edit.mjs @@ -0,0 +1,42 @@ +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. fit "crop" fills the vertical frame +// from the center of the landscape footage. +function goalClips(match) { + return match.goals.map((goal, index) => ({ + asset: { type: 'video', src: match.source, trim: goal.at, volume: 0.6 }, + start: index === 0 ? 0 : 'auto', + length: CLIP_SECONDS, + fit: 'crop' + })); +} + +export function buildEdit(match) { + return { + timeline: { + background: '#000000', + tracks: [ + { clips: goalClips(match) }, + { + clips: [ + { + asset: { + type: 'audio', + src: MUSIC_URL, + volume: 0.35, + effect: 'fadeInFadeOut' + }, + start: 0, + length: 'end' + } + ] + } + ] + }, + output: { format: 'mp4', size: { width: 1080, height: 1920 } } + }; +} diff --git a/examples/sports-highlight-videos/match.json b/examples/sports-highlight-videos/match.json new file mode 100644 index 0000000..6645691 --- /dev/null +++ b/examples/sports-highlight-videos/match.json @@ -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 + } + ] +} diff --git a/examples/sports-highlight-videos/render.mjs b/examples/sports-highlight-videos/render.mjs new file mode 100644 index 0000000..f638fed --- /dev/null +++ b/examples/sports-highlight-videos/render.mjs @@ -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); +} diff --git a/examples/sports-highlight-videos/renders.mjs b/examples/sports-highlight-videos/renders.mjs new file mode 100644 index 0000000..6debacb --- /dev/null +++ b/examples/sports-highlight-videos/renders.mjs @@ -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.` + ); + } + }); +} diff --git a/examples/sports-highlight-videos/status.mjs b/examples/sports-highlight-videos/status.mjs new file mode 100644 index 0000000..db96b81 --- /dev/null +++ b/examples/sports-highlight-videos/status.mjs @@ -0,0 +1,43 @@ +import { fail, getRender, requireConfig } from './api.mjs'; +import { readRenders } from './renders.mjs'; + +requireConfig(); + +let rows; + +try { + rows = await readRenders(); +} catch (error) { + fail(error.message); +} + +if (rows.length === 0) { + console.log('No renders recorded yet. Run node render.mjs first.'); + process.exit(0); +} + +for (const row of rows) { + let render; + + try { + render = await getRender(row.renderId, row.matchId); + } catch (error) { + if (error.fatal) { + fail(error.message); + } + + console.error(error.message); + process.exitCode = 1; + continue; + } + + console.log(`${row.matchId} → ${render.status}`); + + if (render.status === 'done') { + console.log(` ${render.url}`); + } + + if (render.status === 'failed') { + console.log(` error: ${render.error}`); + } +} From 4c0c3f4751c0b88f4051067708dbb725186ca636 Mon Sep 17 00:00:00 2001 From: Sonia Srivastava Date: Fri, 25 Sep 2026 11:28:31 +0700 Subject: [PATCH 2/3] Tighten sports-highlight-videos to the evidence --- examples/sports-highlight-videos/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/sports-highlight-videos/README.md b/examples/sports-highlight-videos/README.md index 4d0d9d6..43e8bd4 100644 --- a/examples/sports-highlight-videos/README.md +++ b/examples/sports-highlight-videos/README.md @@ -66,9 +66,10 @@ URL. The sample source video is a 99 minute match of about 700 MB. Shotstack dow 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. 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 +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. From 25a32ceefad44c78c032ac9a71cf51b90872aa96 Mon Sep 17 00:00:00 2001 From: Sonia Srivastava Date: Mon, 28 Sep 2026 12:18:10 +0700 Subject: [PATCH 3/3] Polish sports-highlight-videos: one sample record --- examples/sports-highlight-videos/README.md | 8 ++++--- examples/sports-highlight-videos/edit.mjs | 25 ++++++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/examples/sports-highlight-videos/README.md b/examples/sports-highlight-videos/README.md index 43e8bd4..857026d 100644 --- a/examples/sports-highlight-videos/README.md +++ b/examples/sports-highlight-videos/README.md @@ -3,7 +3,8 @@ 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 crops the landscape footage to fill a vertical frame and adds music under the match sound. You +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/). @@ -57,8 +58,9 @@ Run `status.mjs` again until the render shows `done`. `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 script crops each clip from the center of -the frame to fill 1080 x 1920. The match sound plays at a lower volume, with a music track under it. +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 diff --git a/examples/sports-highlight-videos/edit.mjs b/examples/sports-highlight-videos/edit.mjs index e4da455..99bdb10 100644 --- a/examples/sports-highlight-videos/edit.mjs +++ b/examples/sports-highlight-videos/edit.mjs @@ -4,23 +4,36 @@ 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. fit "crop" fills the vertical frame -// from the center of the landscape footage. -function goalClips(match) { +// 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: 0.6 }, + asset: { + type: 'video', + src: match.source, + trim: goal.at, + volume: options.volume + }, start: index === 0 ? 0 : 'auto', length: CLIP_SECONDS, - fit: 'crop' + ...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) }, + { clips: goalClips(match, { volume: 0.6, clip: { fit: 'contain' } }) }, + { + clips: goalClips(match, { + volume: 0, + clip: { fit: 'crop', filter: 'blur', opacity: 0.6 } + }) + }, { clips: [ {