Appearance
Parsing highlight clips
How to read the per-selector rally clips returned by GET /video/{video_id}/results and group them into a usable structure.
Key format
Every rally clip is one entry in outputs, and so is its thumbnail:
| Key | What it is |
|---|---|
clip-<selector>-<n> | Portrait (autopan) clip video |
clip-<selector>-thumbnail-<n> | Portrait (autopan) clip thumbnail |
clip-<selector>-landscape-<n> | Landscape clip video |
clip-<selector>-thumbnail-landscape-<n> | Landscape clip thumbnail |
<selector>is how the rally was picked, e.g.longest-rallyorpose-based. It can contain dashes.<n>is the clip number, starting at 1.- A recording can have several selectors, each with its own numbering. Do not merge selectors into one list.
- Selectors, clip counts and thumbnails vary by recording, and new selectors may appear. Parse by pattern, never from a fixed list of keys.
- Clips are only returned when status is
OKorCOMPLETED.
Output
parseClips(outputs) returns clips grouped by selector, then by view, sorted by clip number:
javascript
{
"longest-rally": {
autopan: [
{ index: 1, videoUrl: "https://...", thumbnailUrl: "https://..." },
{ index: 2, videoUrl: "https://...", thumbnailUrl: "https://..." }
],
landscape: [
{ index: 1, videoUrl: "https://...", thumbnailUrl: "https://..." }
]
},
"pose-based": {
autopan: [{ index: 1, videoUrl: "https://...", thumbnailUrl: null }],
landscape: []
}
}thumbnailUrl or videoUrl is null when that half is missing, so check before rendering.
Full example
Works in Node 18+ and modern browsers (uses built-in fetch).
javascript
const BASE_URL = "https://api.clutchapp.io/v1";
const CLIP_KEY = /^clip-(.+?)(-thumbnail)?(-landscape)?-(\d+)$/;
async function login(email, password) {
const res = await fetch(`${BASE_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`Login failed: ${res.status}`);
const { data } = await res.json();
return data.token;
}
async function getResults(token, videoId) {
const res = await fetch(`${BASE_URL}/video/${videoId}/results`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 202) return null; // still processing, try again later
if (!res.ok) throw new Error(`Results failed: ${res.status}`);
const { data } = await res.json();
return data;
}
function parseClipKey(key) {
const match = CLIP_KEY.exec(key);
if (!match) return null;
const [, selector, thumbnail, landscape, index] = match;
return {
selector,
view: landscape ? "landscape" : "autopan",
kind: thumbnail ? "thumbnail" : "video",
index: Number(index),
};
}
function parseClips(outputs) {
const bySelector = {};
for (const { key, url } of outputs) {
const parsed = parseClipKey(key);
if (!parsed) continue;
const { selector, view, kind, index } = parsed;
const views = (bySelector[selector] ??= { autopan: {}, landscape: {} });
const clip = (views[view][index] ??= { index, videoUrl: null, thumbnailUrl: null });
if (kind === "video") clip.videoUrl = url;
else clip.thumbnailUrl = url;
}
const result = {};
for (const [selector, views] of Object.entries(bySelector)) {
result[selector] = {
autopan: Object.values(views.autopan).sort((a, b) => a.index - b.index),
landscape: Object.values(views.landscape).sort((a, b) => a.index - b.index),
};
}
return result;
}
// Every non-clip output (match.json, video, video_stream, ...) as { key: url }
function outputsByKey(outputs) {
return Object.fromEntries(
outputs.filter(({ key }) => !CLIP_KEY.test(key)).map(({ key, url }) => [key, url])
);
}
async function main() {
const token = await login("you@example.com", "your_password");
const results = await getResults(token, "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX");
if (!results) {
console.log("Video is still processing");
return;
}
const files = outputsByKey(results.outputs);
const clips = parseClips(results.outputs);
console.log("Match data:", files["match.json"]);
for (const [selector, views] of Object.entries(clips)) {
for (const clip of views.autopan) {
console.log(`${selector} #${clip.index}`, clip.videoUrl, clip.thumbnailUrl);
}
}
}
main().catch(console.error);Notes
- URLs are signed and valid for 12 hours. Call
/resultsagain rather than storing URLs long term. - Sort by
index, not by position inoutputs. The order ofoutputsis not guaranteed. - Change log entry: API Change Log