> ## Documentation Index
> Fetch the complete documentation index at: https://docs.9pic.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Video Clipping Search

> Generate short personalised video clips by uploading a selfie

## Overview

Video Clipping Search is the API surface for **9Pic Motion**, our selfie + BIB-driven video clipping pipeline. It finds the moments where a participant appears in the event's videos and returns short, downloadable MP4 clips of those moments.

The participant uploads a selfie (or reuses a [Face Search](/api-reference/face-search) `request_id`) and the API:

1. Matches the selfie against faces detected across the event's source videos.
2. Groups matched frames into continuous time segments.
3. Asks the clipping service to produce one clip per matched source video.
4. Returns publicly-resolvable clip URLs you can play, download, or hand to your UI.

The search is **asynchronous**. You start it once with `POST` and then `GET` the result by `request_id` until it reaches a terminal status. The same `request_id` is shared with [Face Search](/api-reference/face-search), so a single selfie upload can power both photo *and* video discovery — and once Face Search has run, you can start a clip with **just that `request_id` and no file upload**, because the matching frames were already computed.

<Note>
  Video Clipping Search must be enabled for the event. Both `video_search` (the event-level toggle) and `video_selfie_search` (the selfie-driven video toggle) returned by [Event Details](/api-reference/event-details) must be `true`.
</Note>

## Why Two Endpoints?

Producing a video clip is **substantially more expensive** than searching for photos: it has to scan every video frame index, run face matching, decide on the best time window per source video, and finally invoke the clipping service. We split this into:

* `POST /video-clipping/search-by-selfie` — accepts the selfie, persists a record, and immediately enqueues background processing. Returns a `request_id` you can poll.
* `GET /video-clipping/{request_id}` — reads the cached result and returns a structured status payload (with clip URLs once ready).

This lets your client stay responsive: trigger the search once, then poll for results without re-uploading the selfie. The `request_id` follows the same idempotency model as [Face Search](/api-reference/face-search) — see [Conventions](/api-reference/conventions#request-id-idempotency).

<Tip>
  Reuse a `request_id` returned by [Face Search](/api-reference/face-search) when you start a video clipping search. The same selfie record is updated, so the user gets photo *and* video results from a single upload.
</Tip>

## Endpoints

```
POST /api/v1/ext/{org_id}/event/{event_id}/video-clipping/search-by-selfie
GET  /api/v1/ext/{org_id}/event/{event_id}/video-clipping/{request_id}
```

## POST /video-clipping/search-by-selfie — Start a Search

Accepts **either** a selfie image **or** a Face Search `request_id`, persists the request, and enqueues background clipping. Returns immediately with a `request_id` for polling.

### Path Parameters

| Parameter  | Type   | Required | Description                 |
| ---------- | ------ | -------- | --------------------------- |
| `org_id`   | number | Yes      | Your organisation ID.       |
| `event_id` | number | Yes      | The event to search within. |

### Multipart Form Fields

| Field        | Type   | Required | Description                                                                                                                                                                                                                                                           |
| ------------ | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `file`       | file   | No       | Selfie image (JPEG/PNG). The clearest single-face image gives best results. **Optional** — omit it and pass a `request_id` from a completed Face Search to reuse the segments already computed for that selfie.                                                       |
| `request_id` | string | No       | UUID of the selfie record. Pass a Face Search `request_id` to reuse the same record. **Required when no `file` is uploaded** — sending it alone reuses the precomputed segments and clips immediately. If omitted (with a `file`), 9Pic generates one and returns it. |

<Note>
  You must provide a `file`, a `request_id`, or both. A `request_id` with no `file` only works after a [Face Search](/api-reference/face-search) has run for that `request_id` (so the matching frames exist); otherwise the call returns `400`.
</Note>

### Example Request

<CodeGroup>
  ```bash cURL theme={null}
  curl -i \
    -H "X-API-Key: <your_9pic_api_key>" \
    -F "file=@selfie.jpg" \
    "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/search-by-selfie"
  ```

  ```python Python theme={null}
  import requests

  with open("selfie.jpg", "rb") as f:
      response = requests.post(
          "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/search-by-selfie",
          headers={"X-API-Key": "<your_9pic_api_key>"},
          files={"file": ("selfie.jpg", f, "image/jpeg")},
      )

  data = response.json()
  print(data["data"]["request_id"], data["data"]["status"])
  ```

  ```javascript JavaScript theme={null}
  const formData = new FormData();
  formData.append("file", selfieFile);

  const response = await fetch(
    "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/search-by-selfie",
    {
      method: "POST",
      headers: { "X-API-Key": "<your_9pic_api_key>" },
      body: formData,
    }
  );

  const result = await response.json();
  console.log(result.data.request_id, result.data.status);
  ```
</CodeGroup>

To reuse an existing `request_id` (for example one returned by Face Search) while still uploading a selfie, add `-F "request_id=<uuid>"` (cURL), `data={"request_id": "<uuid>"}` (Python), or `formData.append("request_id", "<uuid>")` (JavaScript).

To clip **without re-uploading** — the recommended flow right after a Face Search — send only the `request_id` and omit the `file`. 9Pic reuses the matching frames it already computed and starts clipping immediately:

<CodeGroup>
  ```bash cURL theme={null}
  curl -i \
    -H "X-API-Key: <your_9pic_api_key>" \
    -F "request_id=ddc661a7-8861-4793-9437-af42a82d12f8" \
    "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/search-by-selfie"
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/search-by-selfie",
      headers={"X-API-Key": "<your_9pic_api_key>"},
      data={"request_id": "ddc661a7-8861-4793-9437-af42a82d12f8"},
  )
  print(response.json()["data"]["status"])
  ```

  ```javascript JavaScript theme={null}
  const formData = new FormData();
  formData.append("request_id", "ddc661a7-8861-4793-9437-af42a82d12f8");

  const response = await fetch(
    "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/search-by-selfie",
    {
      method: "POST",
      headers: { "X-API-Key": "<your_9pic_api_key>" },
      body: formData,
    }
  );
  console.log((await response.json()).data.status);
  ```
</CodeGroup>

### Example Response

```json theme={null}
{
  "responseType": "success",
  "message": "Video clipping search started. Poll GET /api/v1/ext/{org_id}/event/{event_id}/video-clipping/{request_id} for results.",
  "data": {
    "status": "processing",
    "request_id": "ddc661a7-8861-4793-9437-af42a82d12f8"
  }
}
```

On a successful POST the status is usually `processing` — use the `request_id` with the GET endpoint to wait for the terminal status. If the selfie matched no video frames at all, the POST can instead return a terminal `no_matches` straight away (no polling needed). Other terminal outcomes such as `no_segments` are determined later during clipping and surface only via the GET endpoint. Treat any `processing` response as "keep polling".

## GET /video-clipping/{request_id} — Poll for Results

Returns the current status of the search and any generated clips. Safe to call repeatedly until the status is terminal.

### Path Parameters

| Parameter    | Type   | Required | Description                                                               |
| ------------ | ------ | -------- | ------------------------------------------------------------------------- |
| `org_id`     | number | Yes      | Your organisation ID.                                                     |
| `event_id`   | number | Yes      | The event the search belongs to.                                          |
| `request_id` | string | Yes      | UUID returned by a previous `POST /video-clipping/search-by-selfie` call. |

### Status Lifecycle

| Status        | Terminal? | Meaning                                                                                                                                                |
| ------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `processing`  | No        | The job is still running. Continue polling (recommended interval: every 3–5 seconds, with backoff).                                                    |
| `completed`   | Yes       | One or more clips were generated. `videos[]` is populated.                                                                                             |
| `no_matches`  | Yes       | The selfie did not match any frames in the event videos. No clips generated.                                                                           |
| `no_segments` | Yes       | The selfie matched frames, but the matches were too sparse or scattered to form a usable clip window.                                                  |
| `clip_failed` | Yes       | The selfie matched and a window was selected, but the clipping service failed to produce one or more clips. `clip_errors[]` carries per-source detail. |
| `failed`      | Yes       | The job failed before any clips could be generated (for example: enqueue failed, internal error). `error` carries a human-readable message.            |

<Tip>
  Use exponential backoff (e.g. 3s, 5s, 10s, 15s, then every 15s) and stop polling once the status is anything other than `processing`. End-to-end processing typically completes within tens of seconds, but heavy events can take longer.
</Tip>

### Example Request

<CodeGroup>
  ```bash cURL theme={null}
  curl -i \
    -H "X-API-Key: <your_9pic_api_key>" \
    "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/ddc661a7-8861-4793-9437-af42a82d12f8"
  ```

  ```python Python theme={null}
  import time
  import requests

  URL = "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/ddc661a7-8861-4793-9437-af42a82d12f8"
  HEADERS = {"X-API-Key": "<your_9pic_api_key>"}

  for delay in (3, 5, 10, 15):
      response = requests.get(URL, headers=HEADERS)
      payload = response.json()["data"]
      if payload["status"] != "processing":
          print(payload["status"], payload.get("videos", []))
          break
      time.sleep(delay)
  else:
      while payload["status"] == "processing":
          time.sleep(15)
          payload = requests.get(URL, headers=HEADERS).json()["data"]
      print(payload["status"], payload.get("videos", []))
  ```

  ```javascript JavaScript theme={null}
  const url =
    "https://api.9pic.ai/api/v1/ext/903/event/456/video-clipping/ddc661a7-8861-4793-9437-af42a82d12f8";
  const headers = { "X-API-Key": "<your_9pic_api_key>" };

  const delays = [3000, 5000, 10000, 15000];
  let payload;

  for (const delay of delays) {
    payload = (await (await fetch(url, { headers })).json()).data;
    if (payload.status !== "processing") break;
    await new Promise((resolve) => setTimeout(resolve, delay));
  }

  while (payload.status === "processing") {
    await new Promise((resolve) => setTimeout(resolve, 15000));
    payload = (await (await fetch(url, { headers })).json()).data;
  }

  console.log(payload.status, payload.videos);
  ```
</CodeGroup>

### Example Response

<Tabs>
  <Tab title="completed">
    ```json theme={null}
    {
      "responseType": "success",
      "message": "Video clipping completed",
      "data": {
        "request_id": "ddc661a7-8861-4793-9437-af42a82d12f8",
        "status": "completed",
        "videos": [
          {
            "uuid": "5e9a7cc1-0bcb-4a4d-b6e3-6c0b25c57a08",
            "video_url": "https://photos.9pic.ai/vids/456/motion/selfie/ddc661a7-8861-4793-9437-af42a82d12f8/5e9a7cc1-0bcb-4a4d-b6e3-6c0b25c57a08.mp4",
            "source_video_key": "12",
            "start_time": "00:00:42",
            "end_time": "00:00:52"
          }
        ],
        "error": null,
        "clip_errors": []
      }
    }
    ```
  </Tab>

  <Tab title="processing">
    ```json theme={null}
    {
      "responseType": "success",
      "message": "Video clip search is still processing. Poll again shortly.",
      "data": {
        "request_id": "ddc661a7-8861-4793-9437-af42a82d12f8",
        "status": "processing",
        "videos": [],
        "error": null,
        "clip_errors": []
      }
    }
    ```
  </Tab>

  <Tab title="no_matches">
    ```json theme={null}
    {
      "responseType": "success",
      "message": "No frames in the event videos matched the uploaded selfie.",
      "data": {
        "request_id": "ddc661a7-8861-4793-9437-af42a82d12f8",
        "status": "no_matches",
        "videos": [],
        "error": null,
        "clip_errors": []
      }
    }
    ```
  </Tab>

  <Tab title="no_segments">
    ```json theme={null}
    {
      "responseType": "success",
      "message": "Matches were found but no continuous video segment could be extracted.",
      "data": {
        "request_id": "ddc661a7-8861-4793-9437-af42a82d12f8",
        "status": "no_segments",
        "videos": [],
        "error": null,
        "clip_errors": [
          {
            "video_key": "12",
            "error": "no usable segment",
            "status_code": null
          }
        ]
      }
    }
    ```
  </Tab>

  <Tab title="clip_failed">
    ```json theme={null}
    {
      "responseType": "success",
      "message": "Selfie matched but the video clipping service could not produce one or more clips.",
      "data": {
        "request_id": "ddc661a7-8861-4793-9437-af42a82d12f8",
        "status": "clip_failed",
        "videos": [],
        "error": null,
        "clip_errors": [
          {
            "video_key": "34",
            "error": "Clipping service returned 500",
            "status_code": 500
          }
        ]
      }
    }
    ```
  </Tab>

  <Tab title="failed">
    ```json theme={null}
    {
      "responseType": "success",
      "message": "Video clip search failed before any clips could be generated.",
      "data": {
        "request_id": "ddc661a7-8861-4793-9437-af42a82d12f8",
        "status": "failed",
        "videos": [],
        "error": "Failed to start processing",
        "clip_errors": []
      }
    }
    ```
  </Tab>
</Tabs>

## Recommended Integration Sequence — "POST once, GET many"

<Steps>
  <Step title="Confirm video clipping is enabled">
    Call [Event Details](/api-reference/event-details) and check that both `video_search` and `video_selfie_search` are `true`. If either is `false`, do not show the video clipping CTA.
  </Step>

  <Step title="POST once">
    Upload the selfie to `POST /video-clipping/search-by-selfie`. Persist the `request_id` from the response (URL state, local storage, or your DB).
    If you already have a `request_id` from [Face Search](/api-reference/face-search), send only that `request_id` (no `file`) to clip from the segments Face Search already computed.
  </Step>

  <Step title="Poll the GET endpoint">
    Call `GET /video-clipping/{request_id}` until `status` is anything other than `processing`. Use backoff: 3s, 5s, 10s, 15s, then every 15s.
  </Step>

  <Step title="Render or fall back">
    On `completed`, render the clips from `videos[].video_url`.
    On `no_matches` / `no_segments`, show a friendly empty state (the selfie did not produce a clip).
    On `clip_failed` / `failed`, surface a retry CTA — a fresh `POST` (with a new selfie) is the most reliable next step.
  </Step>
</Steps>

<Warning>
  A new `POST` (with a fresh `request_id` or no `request_id`) re-runs the full pipeline and is treated as a new billable search. Avoid issuing fresh `POST`s on every refresh — `GET` polling is cheap, the `POST` is not.
</Warning>

## Response Models

| Model                                                                                                        | Description                                          |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| <a href="/api-reference/models/search-responses#videoclippingstartresponse">VideoClippingStartResponse</a>   | Returned by `POST /video-clipping/search-by-selfie`. |
| <a href="/api-reference/models/search-responses#videoclippingresultresponse">VideoClippingResultResponse</a> | Returned by `GET /video-clipping/{request_id}`.      |
| <a href="/api-reference/models/search-responses#videoclipitem">VideoClipItem</a>                             | Each entry inside `videos[]`.                        |
| <a href="/api-reference/models/search-responses#videocliperroritem">VideoClipErrorItem</a>                   | Each entry inside `clip_errors[]`.                   |

## Error Responses

| Status | Meaning                                                                                                                                                                                                                                               |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `request_id` is not a valid UUID; the uploaded selfie file is empty; neither a `file` nor a `request_id` was provided; or a `request_id` was sent with no file but has no precomputed segments (run [Face Search](/api-reference/face-search) first). |
| `401`  | API key is missing.                                                                                                                                                                                                                                   |
| `403`  | API key is invalid, inactive, token/event ownership mismatch, or video clipping (event-level or selfie-driven) is disabled for this event.                                                                                                            |
| `404`  | Event configuration not found, video search configuration not found, or `request_id` is unknown (on GET, or on the file-less reuse POST).                                                                                                             |
| `429`  | Rate limit exceeded. Back off and retry with the same `request_id` to hit the cache.                                                                                                                                                                  |
| `500`  | Internal failure that prevented the request from being recorded, or the background pipeline could not be enqueued. The selfie record is marked `failed`; a fresh `POST` is the safest retry.                                                          |

See [Errors](/api-reference/errors) for canonical descriptions and retry guidance.
