Skip to content

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:

KeyWhat 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-rally or pose-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 OK or COMPLETED.

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 /results again rather than storing URLs long term.
  • Sort by index, not by position in outputs. The order of outputs is not guaranteed.
  • Change log entry: API Change Log

Clutch Partner API · v1