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..857026d --- /dev/null +++ b/examples/sports-highlight-videos/README.md @@ -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`. 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..99bdb10 --- /dev/null +++ b/examples/sports-highlight-videos/edit.mjs @@ -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 } } + }; +} 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}`); + } +}