{
  "openapi": "3.1.0",
  "info": {
    "title": "ClipOnIt API",
    "version": "1.0.0",
    "summary": "Turn long videos into short, subtitled, reframed clips.",
    "description": "ClipOnIt imports a long video (file upload or public URL), transcribes it, detects the most viral moments as clips, then lets you edit each clip (layout/reframing keyframes, cuts, subtitles, B-roll, music, emojis) and render it as a vertical (9:16) or horizontal (16:9) MP4.\n\n**Typical agent flow**: `importVideoUrl` or `uploadVideo` → poll `getVideo` until `status == \"ready\"` → pick a clip (highest `score`) → optionally `autoReframeClip`, `detectSilences` + `updateClipEdit`, `describeClip` → `exportClip` → poll `getExport` until `status == \"done\"` → download `url`.\n\n**Units**: all times are seconds (float). `start_s`/`end_s` of a clip, `cuts`, B-roll and emoji ranges are **absolute seconds in the source video**; keyframe `t` is **relative to the clip start**. Zones and emoji positions are **fractions 0..1** of the source/output frame.\n\n**Auth**: `Authorization: Bearer ck_…` (API key, Starter plan and above, created with `createApiKey`) or the `session` cookie / session token returned by `login`.\n\n**Errors**: every error is `{\"error\": \"message\"}`. `401` missing/invalid credentials, `402` plan quota reached (monthly videos, daily searches on free), `403` feature not in plan (API/MCP, translation, B-roll/music, Premiere XML, demo export on free), `404` resource not yours, `400` validation.",
    "contact": {
      "name": "ClipOnIt",
      "url": "https://cliponit.app"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://cliponit.app"
    }
  },
  "externalDocs": {
    "description": "Agent guide (llms-api.txt)",
    "url": "https://cliponit.app/llms-api.txt"
  },
  "servers": [
    {
      "url": "https://cliponit.app",
      "description": "Production"
    },
    {
      "url": "http://localhost:3000",
      "description": "Local dev (cargo run)"
    }
  ],
  "security": [
    {
      "ApiKey": []
    },
    {
      "SessionCookie": []
    },
    {
      "OAuth2": [
        "clips"
      ]
    }
  ],
  "tags": [
    {
      "name": "auth",
      "description": "Register, login, current user, password reset, Google OAuth."
    },
    {
      "name": "account",
      "description": "Onboarding, profile, API key, custom logo, feedback."
    },
    {
      "name": "videos",
      "description": "Import videos (file or URL), processing status, detected clips."
    },
    {
      "name": "clips",
      "description": "Edit state per orientation (layout, keyframes, cuts, subtitles, B-roll, music, emojis), transcript, exports."
    },
    {
      "name": "smart",
      "description": "AI/vision helpers: auto-reframe on faces, silences, auto-zoom, descriptions, B-roll and emoji suggestions."
    },
    {
      "name": "enrich",
      "description": "B-roll (Pexels/upload) and music library. Starter plan and above."
    },
    {
      "name": "billing",
      "description": "Plans catalogue, Stripe checkout/portal, subscription, invoices."
    }
  ],
  "paths": {
    "/api/auth/register": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "register",
        "security": [],
        "summary": "Create an account",
        "description": "Creates a user (free plan), opens a 30-day session and seeds a demo video. Returns the session token (also set as the `session` cookie).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "name",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "name": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string",
                    "minLength": 8
                  }
                }
              },
              "example": {
                "email": "a@b.c",
                "name": "Ana",
                "password": "secret12"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Session"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/auth/login": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "login",
        "security": [],
        "summary": "Log in with email and password",
        "description": "Returns `{user, token}`; use `token` as `Authorization: Bearer <token>` (session, 30 days). Accounts created with Google OAuth get 400 « Connecte-toi avec Google ».",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Session"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/auth/logout": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "logout",
        "summary": "Log out (delete the session)",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          }
        }
      }
    },
    "/api/auth/providers": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "getAuthProviders",
        "security": [],
        "summary": "Which OAuth providers are configured",
        "responses": {
          "200": {
            "description": "Providers",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "google": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/google": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "googleAuthStart",
        "security": [],
        "summary": "Start Google OAuth (browser only)",
        "description": "302 to Google's consent screen. Not usable by agents; use an API key instead.",
        "responses": {
          "302": {
            "description": "Redirect to Google"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/auth/google/callback": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "googleAuthCallback",
        "security": [],
        "summary": "Google OAuth callback (browser only)",
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to `/` or `/onboarding` with the session cookie, or `/login?error=google`"
          }
        }
      }
    },
    "/api/auth/forgot": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "forgotPassword",
        "security": [],
        "summary": "Send a password reset email",
        "description": "Always returns `{ok:true}` (does not reveal whether the email exists). Link valid 1 hour.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          }
        }
      }
    },
    "/api/auth/reset": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "resetPassword",
        "security": [],
        "summary": "Set a new password from a reset token",
        "description": "Invalidates all sessions of the user.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "password"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string",
                    "minLength": 8
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/me": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "getMe",
        "summary": "Current user and quota",
        "description": "Call this first to know the plan and remaining quota (`quota.videos_used` vs `quota.videos_limit`) and which features are unlocked (`api_mcp`, `translate`, `broll_music`, `premiere_xml`…).",
        "responses": {
          "200": {
            "description": "User + quota",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "user",
                    "quota"
                  ],
                  "properties": {
                    "user": {
                      "$ref": "#/components/schemas/User"
                    },
                    "quota": {
                      "$ref": "#/components/schemas/Quota"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "patch": {
        "tags": [
          "account"
        ],
        "operationId": "updateMe",
        "summary": "Update profile (name, locale)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "locale": {
                    "type": "string",
                    "description": "UI locale, e.g. `fr` or `en`"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated user",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/onboarding": {
      "post": {
        "tags": [
          "account"
        ],
        "operationId": "completeOnboarding",
        "summary": "Save onboarding answers",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "role",
                  "videos_per_month",
                  "source"
                ],
                "properties": {
                  "role": {
                    "type": "string"
                  },
                  "role_other": {
                    "type": "string"
                  },
                  "videos_per_month": {
                    "type": "string"
                  },
                  "source": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Done",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "onboarded": {
                      "type": "integer",
                      "const": 1
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/onboarding/skip": {
      "post": {
        "tags": [
          "account"
        ],
        "operationId": "skipOnboarding",
        "summary": "Skip onboarding",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          }
        }
      }
    },
    "/api/feedback": {
      "post": {
        "tags": [
          "account"
        ],
        "operationId": "sendFeedback",
        "summary": "Rate the clips detected for a video",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "video_id",
                  "rating"
                ],
                "properties": {
                  "video_id": {
                    "$ref": "#/components/schemas/Uuid"
                  },
                  "rating": {
                    "type": "integer",
                    "enum": [
                      1,
                      2,
                      3
                    ],
                    "description": "1 = bad, 2 = ok, 3 = great"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "$ref": "#/components/schemas/Uuid"
                    },
                    "rating": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/me/api-key": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "getApiKey",
        "summary": "Whether an API key exists (prefix only)",
        "responses": {
          "200": {
            "description": "Key status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "has_key": {
                      "type": "boolean"
                    },
                    "prefix": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "example": "ck_ab12…"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "account"
        ],
        "operationId": "createApiKey",
        "summary": "Create (or regenerate) the API key",
        "description": "Returns the full key **once** (`ck_` + 64 hex). Use it as `Authorization: Bearer ck_…` on every `/api/*` route and on `POST /mcp`. Requires a plan with `api_mcp` (Starter and above) → 403 otherwise. Regenerating revokes the previous key.",
        "responses": {
          "200": {
            "description": "New key",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "api_key"
                  ],
                  "properties": {
                    "api_key": {
                      "type": "string",
                      "pattern": "^ck_[0-9a-f]{64}$"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "delete": {
        "tags": [
          "account"
        ],
        "operationId": "deleteApiKey",
        "summary": "Revoke the API key",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          }
        }
      }
    },
    "/api/me/subtitle-preset": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "getSubtitlePreset",
        "summary": "Account subtitle preset (built-in defaults if none)",
        "responses": {
          "200": {
            "description": "Preset",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subtitles"
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": [
          "account"
        ],
        "operationId": "setSubtitlePreset",
        "summary": "Set the subtitle preset applied to every new clip edit",
        "description": "Fields are merged over the built-in defaults (`{}` resets), validated like `updateClipEdit`, then stored. Applied when a `clip_edits` row is created (first `getClip`/`updateClipEdit`/export of a clip+orientation); existing edits are not changed. Same plan gating as `updateClipEdit` for `lang`/`overrides`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubtitlesPatch"
              },
              "example": {
                "style": "karaoke",
                "color": "yellow",
                "size": 3,
                "words_per_line": 3
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored preset",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subtitles"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/me/logo": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "getLogo",
        "summary": "Custom logo URL (replaces the watermark on exports)",
        "responses": {
          "200": {
            "description": "Logo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Logo"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "account"
        ],
        "operationId": "uploadLogo",
        "summary": "Upload a logo (png/jpg ≤ 2 MB)",
        "description": "Converted to PNG ≤ 400 px wide. Rendered bottom-right of exports instead of the watermark (Starter plan and above).",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Logo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Logo"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "delete": {
        "tags": [
          "account"
        ],
        "operationId": "deleteLogo",
        "summary": "Remove the logo",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          }
        }
      }
    },
    "/api/videos": {
      "get": {
        "tags": [
          "videos"
        ],
        "operationId": "listVideos",
        "summary": "List my videos (newest first)",
        "description": "Each item includes `status`, `progress`, `duration_s`, `clips_count`. Poll this or `getVideo` every ~10 s while `status == \"processing\"`.",
        "responses": {
          "200": {
            "description": "Videos",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Video"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "videos"
        ],
        "operationId": "uploadVideo",
        "summary": "Upload a video file (multipart)",
        "description": "Field `file`: mp4/mov/webm, streamed to disk, ≤ `quota.max_bytes` of the plan (3 GB free … 20 GB pro). Checks **before** receiving the body: 402 if the monthly video quota is reached or `Content-Length` exceeds the plan; free plan also refuses videos longer than 1200 s (20 min). Returns the video with `status: processing`; processing = probing → transcribing → detecting, usually 1-5 min for a 20 min video. Then call `getVideo` to read the clips.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created video (processing)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Video"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          }
        }
      }
    },
    "/api/videos/url": {
      "post": {
        "tags": [
          "videos"
        ],
        "operationId": "importVideoUrl",
        "summary": "Import a video from a public URL (YouTube, etc.)",
        "description": "Downloads with yt-dlp (≤ 1080p). Same quota checks as upload (402). Returns the video immediately with `status: processing`, `progress: downloading`, `title` = the URL until metadata is known. Duration/size limits are enforced after download: the video becomes `failed` with a clear `error`. Poll `getVideo` until `status` is `ready` or `failed`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "example": "https://www.youtube.com/watch?v=e7Zl5VWejQc"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created video (processing)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Video"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          }
        }
      }
    },
    "/api/videos/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/VideoId"
        }
      ],
      "get": {
        "tags": [
          "videos"
        ],
        "operationId": "getVideo",
        "summary": "Video with its detected clips",
        "description": "`state` (queued|running|ready|failed), `queue_position` and `eta_s` tell how long to wait (poll every ~10 s, or use the MCP `wait_video` tool). Clips are sorted chronologically (`position`). Pick by `score` (0-100, estimated viral potential) and read `reason`. `description`/`hashtags` are null until `describeClip` is called. `thumbnail_url` may be null while thumbnails are generated.",
        "responses": {
          "200": {
            "description": "Video + clips",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VideoDetail"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "videos"
        ],
        "operationId": "deleteVideo",
        "summary": "Delete a video, its clips, exports and B-roll files",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/videos/{id}/export-all": {
      "parameters": [
        {
          "$ref": "#/components/parameters/VideoId"
        },
        {
          "$ref": "#/components/parameters/Orientation"
        }
      ],
      "post": {
        "tags": [
          "clips"
        ],
        "operationId": "exportAllClips",
        "summary": "Render every clip of a video (one export per clip)",
        "description": "Same rules as `exportClip` for each clip, in chronological order; untouched clips get the account subtitle preset. Exports render one at a time: poll `getExport` on each id. 400 if the video has no clips yet.",
        "responses": {
          "200": {
            "description": "Created exports",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportBatch"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/videos/{id}/retry": {
      "post": {
        "tags": [
          "videos"
        ],
        "operationId": "retryVideo",
        "summary": "Re-run processing of a failed video",
        "description": "Only for `status: failed` (400 otherwise). Re-downloads if the source came from a URL and the file is gone. 402 if the user is above quota.",
        "parameters": [
          {
            "$ref": "#/components/parameters/VideoId"
          }
        ],
        "responses": {
          "200": {
            "description": "Video (processing)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Video"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/videos/{id}/regenerate": {
      "post": {
        "tags": [
          "videos"
        ],
        "operationId": "regenerateClips",
        "summary": "Delete the clips and re-run clip detection",
        "description": "Reuses the existing transcript (no new transcription), only the AI clip detection is redone. 400 if the video is already processing.",
        "parameters": [
          {
            "$ref": "#/components/parameters/VideoId"
          }
        ],
        "responses": {
          "200": {
            "description": "Video (processing)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Video"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          }
        }
      }
    },
    "/api/videos/{id}/search": {
      "post": {
        "tags": [
          "smart"
        ],
        "operationId": "findMoment",
        "summary": "Find a moment from a natural-language query (creates a clip)",
        "description": "AI search over the transcript; creates a new clip appended to the list and returns it. Requires a transcript (video `ready`). Free plan: 5 searches/day → 402 beyond.",
        "parameters": [
          {
            "$ref": "#/components/parameters/VideoId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "query"
                ],
                "properties": {
                  "query": {
                    "type": "string",
                    "example": "the part about pricing"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New clip",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Clip"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          }
        }
      }
    },
    "/api/videos/{id}/clips/{clip_id}/description": {
      "post": {
        "tags": [
          "smart"
        ],
        "operationId": "describeClip",
        "summary": "Generate a ready-to-post description and hashtags for a clip",
        "description": "2-3 sentences with a hook on the first line, in the transcript language, plus 5-8 hashtags without `#`. Cached on the clip: a second call returns the same result without AI.",
        "parameters": [
          {
            "$ref": "#/components/parameters/VideoId"
          },
          {
            "name": "clip_id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Description",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "description",
                    "hashtags"
                  ],
                  "properties": {
                    "description": {
                      "type": "string"
                    },
                    "hashtags": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/videos/{id}/filmstrip": {
      "get": {
        "tags": [
          "videos"
        ],
        "operationId": "getVideoFilmstrip",
        "summary": "Thumbnail sheet of the whole video (timeline)",
        "description": "One JPEG with ~120 frames tiled in `cols` columns. Frame n is at column `n % cols`, row `n / cols`, time `n * interval_s`. 400 while the duration is unknown.",
        "parameters": [
          {
            "$ref": "#/components/parameters/VideoId"
          }
        ],
        "responses": {
          "200": {
            "description": "Filmstrip",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Filmstrip"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/videos/{id}/clips.csv": {
      "get": {
        "tags": [
          "videos"
        ],
        "operationId": "exportClipsCsv",
        "summary": "Clips list as CSV",
        "description": "Columns: position,title,start,end,duration_s (start/end as m:ss).",
        "parameters": [
          {
            "$ref": "#/components/parameters/VideoId"
          }
        ],
        "responses": {
          "200": {
            "description": "CSV file",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ClipId"
        }
      ],
      "get": {
        "tags": [
          "clips"
        ],
        "operationId": "getClip",
        "summary": "Clip, its video and the edit state for an orientation",
        "description": "Creates the default edit for the orientation if missing (layout = orientation, one full-frame keyframe, subtitles = account preset). `updateClipEdit` is a partial patch, so reading first is optional.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "responses": {
          "200": {
            "description": "Clip + video + edit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClipDetail"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "clips"
        ],
        "operationId": "updateClipBounds",
        "summary": "Move the clip bounds (start_s / end_s)",
        "description": "Absolute seconds within the video, duration ≥ 3 s. Keyframes are shifted, cuts/B-roll/emojis outside the new range are dropped, the clip filmstrip is invalidated.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "start_s": {
                    "type": "number",
                    "minimum": 0
                  },
                  "end_s": {
                    "type": "number"
                  }
                }
              },
              "example": {
                "start_s": 120.5,
                "end_s": 165
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated clip",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClipBase"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "clips"
        ],
        "operationId": "deleteClip",
        "summary": "Delete a clip (edits, exports, files); running exports are canceled",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/edit": {
      "put": {
        "tags": [
          "clips"
        ],
        "operationId": "updateClipEdit",
        "summary": "Patch the edit state for an orientation",
        "description": "Partial update: only the given fields change (`subtitles` merged field by field, lists replaced, `music: null` = removed); the edit row is created with the account subtitle preset if missing. Plan gating (403): `subtitles.overrides` non-empty needs `edit_captions`; `subtitles.lang != \"original\"` needs `translate`; non-empty `broll`/`music` needs `broll_music`. Validation (400, the message names the field): zones inside 0..1, keyframe zone count must match the layout (vertical/spotlight/centered 1, split 2, trio 3, horizontal 0), cuts `end > start`, B-roll/emojis inside the clip bounds. Returns the full stored edit.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EditInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored edit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Edit"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/export": {
      "post": {
        "tags": [
          "clips"
        ],
        "operationId": "exportClip",
        "summary": "Render the clip (MP4) with its current edit",
        "description": "The edit row is created with the account subtitle preset if the clip was never edited; the export carries `eta_s`. Enqueues a render job and returns the export with `status: queued`. Poll `getExport` every ~5 s until `done` (then `url`) or `failed` (`error`). A 60 s clip renders in ~30-90 s. 403 on the free plan for the demo video.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "responses": {
          "200": {
            "description": "Export (queued)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Export"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/exports/{id}": {
      "get": {
        "tags": [
          "clips"
        ],
        "operationId": "getExport",
        "summary": "Export status, progress, eta_s and download URL",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Export id returned by `exportClip`",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Export",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Export"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "clips"
        ],
        "operationId": "cancelExport",
        "summary": "Cancel a queued or running export",
        "description": "Queued: removed from the render queue. Running: the ffmpeg process is killed within ~2 s and the partial file deleted. Either way the export stays readable with `status: canceled`. 400 if already done/failed/canceled.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Export id returned by `exportClip`",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Canceled export",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Export"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/exports/{id}/frame": {
      "get": {
        "tags": [
          "clips"
        ],
        "operationId": "getExportFrame",
        "summary": "One JPEG frame of a finished export",
        "description": "Extracts the frame at `t` seconds from the start of the rendered clip (longest side 360 px) so an agent can check framing and subtitles. 400 if the export is not `done` or `t` is past the end.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Export id returned by `exportClip`",
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          },
          {
            "name": "t",
            "in": "query",
            "required": false,
            "description": "Seconds from the start of the export",
            "schema": {
              "type": "number",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JPEG",
            "content": {
              "image/jpeg": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/transcript": {
      "get": {
        "tags": [
          "clips"
        ],
        "operationId": "getClipTranscript",
        "summary": "Transcript segments overlapping the clip",
        "description": "Absolute source seconds. `words` carries word-level timings when available (used for karaoke subtitles).",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          }
        ],
        "responses": {
          "200": {
            "description": "Segments",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Segment"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/filmstrip": {
      "get": {
        "tags": [
          "clips"
        ],
        "operationId": "getClipFilmstrip",
        "summary": "Dense thumbnail sheet of the clip (~160 frames)",
        "description": "Frame n is at time `start_s + n * interval_s`. Cached per (clip, bounds).",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          }
        ],
        "responses": {
          "200": {
            "description": "Filmstrip",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Filmstrip"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "start_s": {
                          "type": "number",
                          "description": "Absolute time of frame 0"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/premiere.xml": {
      "get": {
        "tags": [
          "clips"
        ],
        "operationId": "getPremiereXml",
        "summary": "Premiere Pro / FCP7 XMEML sequence of the clip",
        "description": "Source path plus in/out points (cuts become segments). Requires `premiere_xml` in the plan (Pro) → 403 otherwise.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "responses": {
          "200": {
            "description": "XML file",
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/clips/{id}/auto-reframe": {
      "post": {
        "tags": [
          "smart"
        ],
        "operationId": "autoReframeClip",
        "summary": "Detect faces/speakers and write layout + keyframes",
        "description": "Synchronous (10 s – 2 min). Two stable faces (> 60 % of the time) → one `split` keyframe; otherwise `vertical` keyframes following the active speaker (`ease: smooth`); `horizontal` → full frame. Overwrites `layout` and `keyframes` of the edit for this orientation. Results are cached per clip bounds. 500 if the face model is missing on the server.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "responses": {
          "200": {
            "description": "Reframe result",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "layout",
                    "keyframes",
                    "faces",
                    "speakers"
                  ],
                  "properties": {
                    "layout": {
                      "$ref": "#/components/schemas/Layout"
                    },
                    "keyframes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Keyframe"
                      }
                    },
                    "faces": {
                      "type": "integer",
                      "description": "Number of face tracks found"
                    },
                    "speakers": {
                      "type": "integer",
                      "description": "Number of stable speakers"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/faces": {
      "get": {
        "tags": [
          "smart"
        ],
        "operationId": "getClipFaces",
        "summary": "Face tracks of the clip",
        "description": "Boxes as `[t, x, y, w, h]` (t relative to the clip, x/y/w/h fractions 0..1 of the source); `activity` as `[t, v]` (mouth-motion variance, higher = speaking).",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          }
        ],
        "responses": {
          "200": {
            "description": "Tracks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FaceTrack"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/silences": {
      "post": {
        "tags": [
          "smart"
        ],
        "operationId": "detectSilences",
        "summary": "Detect silences (returns cuts, does not apply them)",
        "description": "ffmpeg `silencedetect` on the clip; each silence trimmed by 80 ms on both sides, those shorter than `min_ms` dropped. Returns `{cuts}` in absolute source seconds — merge them into `cuts` of `updateClipEdit` to apply.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "name": "min_ms",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 400,
              "minimum": 0
            },
            "description": "Minimum silence length (ms) after trimming"
          },
          {
            "name": "threshold_db",
            "in": "query",
            "schema": {
              "type": "number",
              "default": -35
            },
            "description": "Level under which audio counts as silence (dBFS)"
          }
        ],
        "responses": {
          "200": {
            "description": "Silences",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "cuts"
                  ],
                  "properties": {
                    "cuts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Cut"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/clips/{id}/auto-zoom": {
      "post": {
        "tags": [
          "smart"
        ],
        "operationId": "autoZoomClip",
        "summary": "Add punch-in zoom keyframes at sentence starts (saved)",
        "description": "Alternates current framing / punch-in (zone shrunk by `strength` around the same centre) on every `every`-th sentence start. Single-zone layouts only (vertical, spotlight, centered) → 400 for split/trio/horizontal. Writes `keyframes` of the edit.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          },
          {
            "name": "strength",
            "in": "query",
            "schema": {
              "type": "number",
              "default": 0.15,
              "minimum": 0,
              "maximum": 1
            },
            "description": "Zoom amount as a fraction of the zone (0.15 = 15 % tighter)"
          },
          {
            "name": "every",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 2,
              "minimum": 1
            },
            "description": "Zoom on every Nth sentence"
          }
        ],
        "responses": {
          "200": {
            "description": "Keyframes written",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "keyframes"
                  ],
                  "properties": {
                    "keyframes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Keyframe"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/broll/search": {
      "get": {
        "tags": [
          "enrich"
        ],
        "operationId": "searchBroll",
        "summary": "Search stock B-roll videos (Pexels)",
        "description": "12 results, portrait for `vertical`, landscape for `horizontal`. Pass `pexels_id` (or `video_url`) to `addBroll`. Starter plan and above (403).",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "city skyline night"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "responses": {
          "200": {
            "description": "Results",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BrollResult"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/clips/{id}/broll/suggest": {
      "post": {
        "tags": [
          "smart"
        ],
        "operationId": "suggestBroll",
        "summary": "AI suggestions of B-roll moments for the clip",
        "description": "≤ 3 moments `{start, end, query}` (2-5 s each, absolute seconds, English 2-4 word query). Nothing is applied: call `searchBroll` then `addBroll`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          }
        ],
        "responses": {
          "200": {
            "description": "Suggestions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "suggestions"
                  ],
                  "properties": {
                    "suggestions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "start",
                          "end",
                          "query"
                        ],
                        "properties": {
                          "start": {
                            "type": "number"
                          },
                          "end": {
                            "type": "number"
                          },
                          "query": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/clips/{id}/broll": {
      "post": {
        "tags": [
          "enrich"
        ],
        "operationId": "addBroll",
        "summary": "Add a B-roll segment (Pexels id or https URL)",
        "description": "Downloads the clip to the server (`-an`, short side 720) and appends it to `edit.broll` of the orientation. `start`/`end` absolute seconds inside the clip. Returns the full B-roll list.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "start",
                  "end"
                ],
                "properties": {
                  "start": {
                    "type": "number"
                  },
                  "end": {
                    "type": "number"
                  },
                  "pexels_id": {
                    "type": "integer"
                  },
                  "video_url": {
                    "type": "string",
                    "format": "uri",
                    "description": "https only; alternative to `pexels_id`"
                  },
                  "query": {
                    "type": "string",
                    "description": "Search query to remember (informational)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "B-roll list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Broll"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/clips/{id}/broll/upload": {
      "post": {
        "tags": [
          "enrich"
        ],
        "operationId": "uploadBroll",
        "summary": "Upload your own B-roll file (≤ 200 MB)",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "start",
                  "end"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "start": {
                    "type": "number"
                  },
                  "end": {
                    "type": "number"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "B-roll list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Broll"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/clips/{id}/broll/{broll_id}": {
      "delete": {
        "tags": [
          "enrich"
        ],
        "operationId": "deleteBroll",
        "summary": "Remove a B-roll segment (and its file)",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          },
          {
            "name": "broll_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Orientation"
          }
        ],
        "responses": {
          "200": {
            "description": "B-roll list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Broll"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/clips/{id}/emojis/suggest": {
      "post": {
        "tags": [
          "smart"
        ],
        "operationId": "suggestEmojis",
        "summary": "AI emoji reactions for the clip (not applied)",
        "description": "≤ 1 emoji per ~6 s, 1.5-2.5 s each, absolute seconds. Add `x`, `y`, `size` and pass them as `emojis` to `updateClipEdit`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ClipId"
          }
        ],
        "responses": {
          "200": {
            "description": "Suggestions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "emojis"
                  ],
                  "properties": {
                    "emojis": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "start",
                          "end",
                          "emoji"
                        ],
                        "properties": {
                          "start": {
                            "type": "number"
                          },
                          "end": {
                            "type": "number"
                          },
                          "emoji": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/music": {
      "get": {
        "tags": [
          "enrich"
        ],
        "operationId": "listMusic",
        "summary": "My music library",
        "responses": {
          "200": {
            "description": "Tracks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MusicTrack"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "tags": [
          "enrich"
        ],
        "operationId": "uploadMusic",
        "summary": "Upload a music track (mp3/wav/m4a ≤ 20 MB)",
        "description": "Stored as mp3. Use the returned `id` as `music.music_id` in `updateClipEdit`.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Track",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MusicTrack"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/music/{id}": {
      "delete": {
        "tags": [
          "enrich"
        ],
        "operationId": "deleteMusic",
        "summary": "Delete a music track",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/billing/plans": {
      "get": {
        "tags": [
          "billing"
        ],
        "operationId": "listPlans",
        "security": [],
        "summary": "Plans catalogue with limits and prices (USD)",
        "responses": {
          "200": {
            "description": "Plans",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Plan"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/billing/checkout": {
      "post": {
        "tags": [
          "billing"
        ],
        "operationId": "createCheckout",
        "summary": "Stripe Checkout URL for a plan",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "plan",
                  "interval"
                ],
                "properties": {
                  "plan": {
                    "type": "string",
                    "enum": [
                      "starter",
                      "pro",
                      "business"
                    ]
                  },
                  "interval": {
                    "type": "string",
                    "enum": [
                      "monthly",
                      "yearly"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Url"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/billing/subscription": {
      "get": {
        "tags": [
          "billing"
        ],
        "operationId": "getSubscription",
        "summary": "Current subscription",
        "responses": {
          "200": {
            "description": "Subscription",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "plan": {
                      "$ref": "#/components/schemas/PlanId"
                    },
                    "interval": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "monthly",
                        "yearly",
                        null
                      ]
                    },
                    "status": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Stripe status (active, past_due, canceled…)"
                    },
                    "current_period_end": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "cancel_at_period_end": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/billing/invoices": {
      "get": {
        "tags": [
          "billing"
        ],
        "operationId": "listInvoices",
        "summary": "Stripe invoices of the customer",
        "responses": {
          "200": {
            "description": "Invoices",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "string"
                      },
                      "date": {
                        "type": "string"
                      },
                      "amount_cents": {
                        "type": "integer"
                      },
                      "currency": {
                        "type": "string"
                      },
                      "status": {
                        "type": "string"
                      },
                      "pdf_url": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "hosted_url": {
                        "type": [
                          "string",
                          "null"
                        ]
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/billing/portal": {
      "post": {
        "tags": [
          "billing"
        ],
        "operationId": "createBillingPortal",
        "summary": "Stripe customer portal URL",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Url"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/billing/webhook": {
      "post": {
        "tags": [
          "billing"
        ],
        "operationId": "stripeWebhook",
        "security": [],
        "summary": "Stripe webhook (server-to-server, not for agents)",
        "description": "Signed with `Stripe-Signature`. Handles `checkout.session.completed`, `customer.subscription.*`, `invoice.paid`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "received": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/me/usage": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "AI usage & cost of the account",
        "description": "One row per (kind, model) over the last `days` days: calls, tokens, audio seconds and estimated USD cost (Claude list prices, Whisper 0.006 $/min). `all=1` returns a per-user breakdown and is restricted to the ADMIN_EMAIL account.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 30,
              "minimum": 1,
              "maximum": 365
            }
          },
          {
            "name": "all",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Usage",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "days": {
                      "type": "integer"
                    },
                    "total_usd": {
                      "type": "number"
                    },
                    "rows": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string"
                          },
                          "calls": {
                            "type": "integer"
                          },
                          "input_tokens": {
                            "type": "integer"
                          },
                          "output_tokens": {
                            "type": "integer"
                          },
                          "seconds": {
                            "type": "number"
                          },
                          "cost_usd": {
                            "type": "number"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "`all=1` from a non-admin account"
          }
        }
      }
    },
    "/api/me/affiliate": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "getAffiliate",
        "summary": "Affiliate link and stats of the account (30% for life)",
        "description": "Creates the `ref_code` on first call. `link` = `PUBLIC_URL/?ref=CODE`; a visitor who signs up (email or Google) within 60 days of clicking it is attributed to this account. Every Stripe `invoice.paid` of a referred user adds a payout of 30% of `amount_paid`. If the affiliate is a Stripe customer the amount is credited to their balance automatically (`credited`, deducted from their next invoices); otherwise it stays `pending` and the founder pays monthly (PayPal/bank transfer) and marks it `paid` via `PATCH /api/admin/payouts/{id}`.",
        "responses": {
          "200": {
            "description": "Affiliate info",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ref_code": {
                      "type": "string",
                      "example": "K7QM2XJ4"
                    },
                    "link": {
                      "type": "string"
                    },
                    "signups": {
                      "type": "integer"
                    },
                    "paying": {
                      "type": "integer"
                    },
                    "pending_cents": {
                      "type": "integer",
                      "description": "cents"
                    },
                    "paid_cents": {
                      "type": "integer",
                      "description": "cents"
                    },
                    "rate_percent": {
                      "type": "integer",
                      "example": 30
                    },
                    "payouts": {
                      "type": "array",
                      "description": "20 most recent",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "amount_cents": {
                            "type": "integer",
                            "description": "cents"
                          },
                          "commission_cents": {
                            "type": "integer",
                            "description": "cents"
                          },
                          "currency": {
                            "type": "string",
                            "example": "usd"
                          },
                          "status": {
                            "type": "string",
                            "enum": [
                              "pending",
                              "credited",
                              "paid"
                            ]
                          },
                          "created_at": {
                            "type": "string"
                          },
                          "paid_at": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "stripe_balance_txn_id": {
                            "type": [
                              "string",
                              "null"
                            ]
                          }
                        }
                      }
                    },
                    "credited_cents": {
                      "type": "integer",
                      "description": "cents"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/admin/payouts": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "listPayouts",
        "summary": "Admin: referral payouts by status (ADMIN_EMAIL only)",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "credited",
                "paid"
              ],
              "default": "pending"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Payouts, ordered by affiliate email",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "integer"
                      },
                      "amount_cents": {
                        "type": "integer",
                        "description": "cents"
                      },
                      "commission_cents": {
                        "type": "integer",
                        "description": "cents"
                      },
                      "currency": {
                        "type": "string",
                        "example": "usd"
                      },
                      "status": {
                        "type": "string",
                        "enum": [
                          "pending",
                          "credited",
                          "paid"
                        ]
                      },
                      "created_at": {
                        "type": "string"
                      },
                      "paid_at": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "affiliate_email": {
                        "type": "string"
                      },
                      "referred_email": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Not the admin account"
          }
        }
      }
    },
    "/api/admin/payouts/{id}": {
      "patch": {
        "tags": [
          "account"
        ],
        "operationId": "updatePayout",
        "summary": "Admin: mark a referral payout paid (or back to pending)",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "paid",
                      "pending"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Not the admin account"
          },
          "404": {
            "description": "Unknown payout"
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "oauthProtectedResourceMetadata",
        "summary": "OAuth protected resource metadata (RFC 9728) for the MCP server",
        "description": "Also served at `/.well-known/oauth-protected-resource/mcp`. Referenced by the `WWW-Authenticate: Bearer resource_metadata=\"…\"` header on every 401 from `POST /mcp`.",
        "security": [],
        "responses": {
          "200": {
            "description": "Metadata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "resource": {
                      "type": "string",
                      "example": "https://cliponit.app/mcp"
                    },
                    "authorization_servers": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "https://cliponit.app"
                      ]
                    },
                    "scopes_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "clips"
                      ]
                    },
                    "bearer_methods_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "oauthAuthorizationServerMetadata",
        "summary": "OAuth authorization server metadata (RFC 8414)",
        "security": [],
        "responses": {
          "200": {
            "description": "Metadata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "issuer": {
                      "type": "string"
                    },
                    "authorization_endpoint": {
                      "type": "string"
                    },
                    "token_endpoint": {
                      "type": "string"
                    },
                    "registration_endpoint": {
                      "type": "string"
                    },
                    "scopes_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "response_types_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "code"
                      ]
                    },
                    "grant_types_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "authorization_code",
                        "refresh_token"
                      ]
                    },
                    "code_challenge_methods_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "S256"
                      ]
                    },
                    "token_endpoint_auth_methods_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "none"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/oauth/register": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "oauthRegisterClient",
        "summary": "Dynamic client registration (RFC 7591), public clients only",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "redirect_uris"
                ],
                "properties": {
                  "client_name": {
                    "type": "string",
                    "example": "Claude"
                  },
                  "redirect_uris": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uri"
                    },
                    "description": "https URLs, or http on localhost/127.0.0.1 (port ignored at authorization time).",
                    "example": [
                      "https://claude.ai/api/mcp/auth_callback"
                    ]
                  },
                  "token_endpoint_auth_method": {
                    "type": "string",
                    "enum": [
                      "none"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registered client",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "client_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "client_name": {
                      "type": "string"
                    },
                    "redirect_uris": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "token_endpoint_auth_method": {
                      "type": "string",
                      "example": "none"
                    },
                    "grant_types": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "response_types": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "client_id_issued_at": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_redirect_uri` or `invalid_client_metadata`",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "invalid_grant"
                    },
                    "error_description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/oauth/authorize": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "oauthAuthorize",
        "summary": "Authorization endpoint (code + PKCE S256): consent page, or redirect to /login?next=…",
        "description": "Browser endpoint. Without a web session → `303 /login?next=/oauth/authorize?…`. With a session → HTML consent page (FR/EN) naming the client and the redirect host. Unknown `client_id` or unregistered `redirect_uri` → 400 page (no redirect). Other errors are returned to `redirect_uri` as `?error=…&state=…` (RFC 6749).",
        "security": [
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "code"
              ]
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uri"
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 43
            }
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "S256"
              ]
            }
          },
          {
            "name": "state",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "scope",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "clips"
            }
          },
          {
            "name": "resource",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "https://cliponit.app/mcp"
            },
            "description": "RFC 8707; must be the MCP server URL if present."
          }
        ],
        "responses": {
          "200": {
            "description": "Consent page",
            "content": {
              "text/html": {}
            }
          },
          "303": {
            "description": "Redirect to /login (no session)"
          },
          "400": {
            "description": "Unknown client or unregistered redirect_uri",
            "content": {
              "text/html": {}
            }
          }
        }
      },
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "oauthConsent",
        "summary": "Consent form submit → `redirect_uri?code=…&state=…` (code: 10 min, single use)",
        "security": [
          {
            "SessionCookie": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "client_id",
                  "redirect_uri",
                  "code_challenge",
                  "code_challenge_method",
                  "decision"
                ],
                "properties": {
                  "response_type": {
                    "type": "string",
                    "enum": [
                      "code"
                    ]
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "redirect_uri": {
                    "type": "string"
                  },
                  "code_challenge": {
                    "type": "string"
                  },
                  "code_challenge_method": {
                    "type": "string",
                    "enum": [
                      "S256"
                    ]
                  },
                  "state": {
                    "type": "string"
                  },
                  "scope": {
                    "type": "string"
                  },
                  "resource": {
                    "type": "string"
                  },
                  "decision": {
                    "type": "string",
                    "enum": [
                      "allow",
                      "deny"
                    ],
                    "description": "`deny` → `redirect_uri?error=access_denied&state=…`"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "303": {
            "description": "Redirect to redirect_uri with `code` (or `error`)"
          },
          "401": {
            "description": "No session"
          }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "oauthToken",
        "summary": "Token endpoint: authorization_code + code_verifier, or refresh_token (rotation)",
        "description": "Form-encoded (RFC 6749). Returns `{access_token:\"cko_…\", token_type:\"Bearer\", expires_in:3600, refresh_token:\"ckr_…\", scope}` with `Cache-Control: no-store`. Codes are single use (consumed even on failure). Refresh tokens rotate: the previous one is invalidated in the same call. Errors: `invalid_request`, `invalid_grant`, `invalid_client` (401), `unsupported_grant_type`.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "grant_type"
                ],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "authorization_code",
                      "refresh_token"
                    ]
                  },
                  "code": {
                    "type": "string"
                  },
                  "code_verifier": {
                    "type": "string"
                  },
                  "redirect_uri": {
                    "type": "string"
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "refresh_token": {
                    "type": "string"
                  },
                  "resource": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tokens",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string",
                      "example": "cko_…"
                    },
                    "token_type": {
                      "type": "string",
                      "example": "Bearer"
                    },
                    "expires_in": {
                      "type": "integer",
                      "example": 3600
                    },
                    "refresh_token": {
                      "type": "string",
                      "example": "ckr_…"
                    },
                    "scope": {
                      "type": "string",
                      "example": "clips"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_grant` (bad/used/expired code, PKCE mismatch, bad refresh token), `invalid_request`, `unsupported_grant_type`",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "invalid_grant"
                    },
                    "error_description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`invalid_client` (client_id mismatch)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "invalid_grant"
                    },
                    "error_description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/me/oauth": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "listOauthGrants",
        "summary": "Connectors (OAuth clients) authorized on this account",
        "responses": {
          "200": {
            "description": "One row per client with live tokens",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "client_id": {
                        "type": "string"
                      },
                      "name": {
                        "type": "string",
                        "example": "Claude"
                      },
                      "since": {
                        "type": "string",
                        "example": "2026-10-03 16:46:51"
                      },
                      "until": {
                        "type": "string",
                        "description": "Latest refresh-token expiry"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/me/oauth/{client_id}": {
      "delete": {
        "tags": [
          "account"
        ],
        "operationId": "revokeOauthGrant",
        "summary": "Revoke a connector: deletes all its tokens and pending codes for this account",
        "parameters": [
          {
            "name": "client_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "revoked": {
                      "type": "integer",
                      "description": "token rows deleted"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "ck_<64 hex>",
        "description": "Personal API key from `POST /api/me/api-key` (Starter plan and above). Also accepted: the session `token` returned by `login`/`register`. Send `Authorization: Bearer ck_…`."
      },
      "SessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "session",
        "description": "HttpOnly cookie set by `register`/`login` (browser clients)."
      },
      "OAuth2": {
        "type": "oauth2",
        "description": "OAuth 2.1 for MCP connectors (Claude.ai / Claude Desktop / Claude Code / ChatGPT): discovery at `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`, dynamic client registration (RFC 7591) at `/oauth/register`, PKCE S256 required, public clients only. Access tokens (`cko_…`, 1 h) are accepted everywhere a `ck_…` key is; refresh tokens (`ckr_…`, 90 days) rotate on use.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://cliponit.app/oauth/authorize",
            "tokenUrl": "https://cliponit.app/oauth/token",
            "refreshUrl": "https://cliponit.app/oauth/token",
            "scopes": {
              "clips": "Import videos, read/edit clips, run exports (everything the API key allows)"
            }
          }
        }
      }
    },
    "parameters": {
      "VideoId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Video id (UUID)",
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        }
      },
      "ClipId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Clip id (UUID)",
        "schema": {
          "$ref": "#/components/schemas/Uuid"
        }
      },
      "Orientation": {
        "name": "orientation",
        "in": "query",
        "required": false,
        "description": "Which edit/export: `vertical` (9:16, default), `horizontal` (16:9), `square` (1:1) or `portrait45` (4:5). Each clip has one independent edit per orientation.",
        "schema": {
          "$ref": "#/components/schemas/Orientation"
        }
      }
    },
    "responses": {
      "Ok": {
        "description": "Done",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "ok": {
                  "type": "boolean",
                  "const": true
                }
              }
            }
          }
        }
      },
      "Url": {
        "description": "Redirect URL to open in a browser",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "url"
              ],
              "properties": {
                "url": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          }
        }
      },
      "Session": {
        "description": "User + session token (also set as `session` cookie)",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "user",
                "token"
              ],
              "properties": {
                "user": {
                  "$ref": "#/components/schemas/User"
                },
                "token": {
                  "type": "string",
                  "description": "Session token (64 hex), usable as `Authorization: Bearer <token>` for 30 days"
                }
              }
            }
          }
        }
      },
      "BadRequest": {
        "description": "Validation error — the message names the field, e.g. `champ subtitles.size invalide : attendu 1..3`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid credentials",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "unauthorized"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Feature not included in the user's plan (upgrade at /billing)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "L'API & MCP nécessitent le plan Starter"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found or not owned by the user",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "clip 6f1c2d6e-… introuvable (ou n'appartient pas à ce compte)"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "Plan quota reached (monthly videos, daily searches, file size/duration on free)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "Quota mensuel atteint : passe au plan supérieur"
            }
          }
        }
      }
    },
    "schemas": {
      "Uuid": {
        "type": "string",
        "format": "uuid",
        "example": "6f1c2d6e-3b1a-4c0f-9a0e-2f5d1c7b8e90"
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable message (French or English)"
          }
        }
      },
      "Orientation": {
        "type": "string",
        "enum": [
          "vertical",
          "horizontal",
          "square",
          "portrait45"
        ],
        "default": "vertical",
        "description": "vertical = 9:16 1080x1920 (TikTok/Reels/Shorts), horizontal = 16:9 1920x1080 (YouTube), square = 1:1 1080x1080 (feed), portrait45 = 4:5 1080x1350 (Instagram feed). auto-reframe supports vertical|horizontal only."
      },
      "Hook": {
        "type": "object",
        "description": "Title card burned at the top of the frame during the first `duration_s` seconds.",
        "properties": {
          "enabled": {
            "type": "boolean",
            "default": false
          },
          "text": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 120,
            "description": "null = the clip title"
          },
          "duration_s": {
            "type": "number",
            "minimum": 0.5,
            "maximum": 10,
            "default": 2.5
          },
          "style": {
            "type": "string",
            "enum": [
              "card",
              "plain"
            ],
            "default": "card"
          }
        }
      },
      "PlanId": {
        "type": "string",
        "enum": [
          "free",
          "starter",
          "pro",
          "business"
        ]
      },
      "Layout": {
        "type": "string",
        "enum": [
          "vertical",
          "horizontal",
          "split",
          "trio",
          "spotlight",
          "centered"
        ],
        "description": "How source zones fill the output frame. `vertical`: 1 zone cropped to 9:16. `horizontal`: full frame (0 zones). `split`: 2 zones stacked (top, bottom). `trio`: 3 zones stacked. `spotlight`: 1 zone over a blurred full-frame background. `centered`: 1 square zone on a blue (#1e3a8a) background."
      },
      "User": {
        "type": "object",
        "required": [
          "id",
          "email",
          "name",
          "locale",
          "plan",
          "onboarded"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string"
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "locale": {
            "type": "string",
            "example": "fr"
          },
          "plan": {
            "$ref": "#/components/schemas/PlanId"
          },
          "onboarded": {
            "type": "integer",
            "enum": [
              0,
              1
            ]
          }
        }
      },
      "Quota": {
        "type": "object",
        "description": "Plan limits and current usage. Booleans tell which features the plan unlocks (403 otherwise).",
        "required": [
          "plan",
          "videos_used",
          "videos_limit",
          "max_bytes",
          "retention_days",
          "searches_today",
          "watermark",
          "logo",
          "premiere_xml",
          "edit_captions",
          "translate",
          "broll_music",
          "api_mcp"
        ],
        "properties": {
          "plan": {
            "$ref": "#/components/schemas/PlanId"
          },
          "videos_used": {
            "type": "integer",
            "description": "Non-demo videos created this calendar month"
          },
          "videos_limit": {
            "type": "integer",
            "description": "Monthly video quota (free: 2 total)"
          },
          "max_bytes": {
            "type": "integer",
            "description": "Max upload size per video, in bytes"
          },
          "max_video_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Max duration per video in seconds (free: 1200), null = unlimited"
          },
          "retention_days": {
            "type": ["integer", "null"],
          "description": "Days before automatic deletion; null on paid plans (nothing expires while the subscription is active)",
            "description": "Videos older than this are deleted"
          },
          "searches_today": {
            "type": "integer"
          },
          "searches_limit": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Daily `findMoment` quota (free: 5), null = unlimited"
          },
          "watermark": {
            "type": "boolean",
            "description": "true = exports carry the ClipOnIt watermark"
          },
          "logo": {
            "type": "boolean",
            "description": "Custom logo on exports"
          },
          "premiere_xml": {
            "type": "boolean"
          },
          "edit_captions": {
            "type": "boolean",
            "description": "Subtitle text overrides"
          },
          "translate": {
            "type": "boolean",
            "description": "Subtitles in another language"
          },
          "broll_music": {
            "type": "boolean"
          },
          "api_mcp": {
            "type": "boolean",
            "description": "API key + MCP server access"
          }
        }
      },
      "Logo": {
        "type": "object",
        "properties": {
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "example": "/files/logos/<user_id>.png"
          }
        }
      },
      "Video": {
        "type": "object",
        "required": [
          "id",
          "title",
          "size_bytes",
          "status",
          "state",
          "is_demo",
          "created_at",
          "clips_count",
          "url"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "title": {
            "type": "string"
          },
          "path": {
            "type": "string",
            "description": "Server-side file path (informational)"
          },
          "url": {
            "type": "string",
            "description": "Source file, relative to the server, e.g. `/files/uploads/<id>.mp4`"
          },
          "thumbnail_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "thumbnail_path": {
            "type": [
              "string",
              "null"
            ]
          },
          "size_bytes": {
            "type": "integer"
          },
          "duration_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Seconds, null until probed"
          },
          "width": {
            "type": [
              "integer",
              "null"
            ]
          },
          "height": {
            "type": [
              "integer",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "processing",
              "ready",
              "failed"
            ]
          },
          "state": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "ready",
              "failed"
            ],
            "description": "`processing` split by the job queue: `queued` (waiting behind other videos) or `running`; otherwise = `status`"
          },
          "queue_position": {
            "type": [
              "integer",
              "null"
            ],
            "description": "1-based position in the processing queue while `state` is `queued`"
          },
          "eta_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Estimated seconds until `ready` (0.25 × (duration + durations of the videos ahead)); null when not processing"
          },
          "progress": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "downloading",
              "probing",
              "transcribing",
              "detecting",
              "demo",
              null
            ],
            "description": "Current step while processing; null when ready/failed. `demo` = demo copy waiting for the master"
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why it failed"
          },
          "is_demo": {
            "type": "integer",
            "enum": [
              0,
              1
            ],
            "description": "1 = the sample video seeded at signup (cannot be exported on the free plan)"
          },
          "created_at": {
            "type": "string"
          },
          "clips_count": {
            "type": "integer"
          }
        }
      },
      "ClipBase": {
        "type": "object",
        "required": [
          "id",
          "video_id",
          "position",
          "title",
          "start_s",
          "end_s",
          "created_at"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "video_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "position": {
            "type": "integer",
            "description": "Chronological order"
          },
          "title": {
            "type": "string"
          },
          "start_s": {
            "type": "number",
            "description": "Absolute start in the source video (seconds)"
          },
          "end_s": {
            "type": "number",
            "description": "Absolute end (seconds)"
          },
          "created_at": {
            "type": "string"
          }
        }
      },
      "Clip": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ClipBase"
          },
          {
            "type": "object",
            "properties": {
              "score": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 0,
                "maximum": 100,
                "description": "Estimated viral potential (AI)"
              },
              "reason": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "One sentence explaining the score, in the transcript language"
              },
              "description": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Ready-to-post caption (null until `describeClip`)"
              },
              "hashtags": {
                "type": [
                  "array",
                  "null"
                ],
                "items": {
                  "type": "string"
                },
                "description": "Without `#`"
              },
              "thumbnail_url": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "`/files/thumbs/clips/<clip_id>.jpg`, 320 px frame at start_s + 1"
              }
            }
          }
        ]
      },
      "VideoDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Video"
          },
          {
            "type": "object",
            "required": [
              "clips"
            ],
            "properties": {
              "clips": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Clip"
                }
              }
            }
          }
        ]
      },
      "ClipVideo": {
        "type": "object",
        "description": "Source video summary attached to a clip",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "title": {
            "type": "string"
          },
          "duration_s": {
            "type": [
              "number",
              "null"
            ]
          },
          "width": {
            "type": [
              "integer",
              "null"
            ]
          },
          "height": {
            "type": [
              "integer",
              "null"
            ]
          },
          "is_demo": {
            "type": "integer"
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO code detected by the transcription (e.g. `fr`)"
          },
          "url": {
            "type": "string"
          }
        }
      },
      "ClipDetail": {
        "type": "object",
        "required": [
          "clip",
          "video",
          "edit"
        ],
        "properties": {
          "clip": {
            "$ref": "#/components/schemas/ClipBase"
          },
          "video": {
            "$ref": "#/components/schemas/ClipVideo"
          },
          "edit": {
            "$ref": "#/components/schemas/Edit"
          }
        }
      },
      "Zone": {
        "type": "object",
        "required": [
          "x",
          "y",
          "w",
          "h"
        ],
        "description": "Rectangle of the SOURCE frame, fractions 0..1 (x+w ≤ 1, y+h ≤ 1). Its aspect should match the cell it fills (9:16 for vertical, 9:8 per cell in split, 1:1 for centered).",
        "properties": {
          "x": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "y": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "w": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 1
          },
          "h": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 1
          }
        },
        "example": {
          "x": 0.3,
          "y": 0,
          "w": 0.4,
          "h": 1
        }
      },
      "Keyframe": {
        "type": "object",
        "required": [
          "t",
          "layout",
          "zones"
        ],
        "description": "Framing from time `t` (seconds **relative to the clip start**, before cuts) until the next keyframe. The first keyframe should be at t = 0. The number of zones must match the layout (vertical/spotlight/centered 1, split 2, trio 3, horizontal 0).",
        "properties": {
          "t": {
            "type": "number",
            "minimum": 0
          },
          "layout": {
            "$ref": "#/components/schemas/Layout"
          },
          "zones": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Zone"
            }
          },
          "ease": {
            "type": "string",
            "enum": [
              "cut",
              "smooth",
              ""
            ],
            "default": "cut",
            "description": "`cut` = hard switch; `smooth` = zones glide from the previous keyframe over the 0.6 s before `t` (same layout only)"
          }
        },
        "example": {
          "t": 0,
          "layout": "vertical",
          "zones": [
            {
              "x": 0.3,
              "y": 0,
              "w": 0.4,
              "h": 1
            }
          ],
          "ease": "cut"
        }
      },
      "Cut": {
        "type": "object",
        "required": [
          "start",
          "end"
        ],
        "description": "Range removed from the clip, absolute source seconds (end > start)",
        "properties": {
          "start": {
            "type": "number"
          },
          "end": {
            "type": "number"
          }
        }
      },
      "SubtitleOverride": {
        "type": "object",
        "required": [
          "start",
          "end",
          "text"
        ],
        "description": "Replaces the transcript text for a range (absolute seconds). Requires `edit_captions`.",
        "properties": {
          "start": {
            "type": "number"
          },
          "end": {
            "type": "number"
          },
          "text": {
            "type": "string"
          }
        }
      },
      "Subtitles": {
        "type": "object",
        "required": [
          "enabled",
          "color",
          "case",
          "lang"
        ],
        "description": "Burned-in subtitles. All fields except the required ones have defaults and are always returned by `getClip`.",
        "properties": {
          "enabled": {
            "type": "boolean"
          },
          "color": {
            "type": "string",
            "enum": [
              "yellow",
              "white",
              "green",
              "cyan",
              "pink",
              "orange",
              "rainbow"
            ],
            "description": "Accent colour (current word in karaoke, text otherwise)"
          },
          "case": {
            "type": "string",
            "enum": [
              "normal",
              "upper",
              "lower"
            ]
          },
          "lang": {
            "type": "string",
            "default": "original",
            "description": "`original` or an ISO 639-1 code (`en`, `es`…) to translate — requires `translate` in the plan"
          },
          "overrides": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SubtitleOverride"
            },
            "default": []
          },
          "style": {
            "type": "string",
            "enum": [
              "karaoke",
              "boxed",
              "outline",
              "glow",
              "minimal"
            ],
            "default": "karaoke"
          },
          "font": {
            "type": "string",
            "enum": [
              "Inter",
              "Montserrat",
              "Bangers",
              "Poppins",
              "Arial"
            ],
            "default": "Inter"
          },
          "position": {
            "type": "string",
            "enum": [
              "bottom",
              "middle",
              "top"
            ],
            "default": "bottom"
          },
          "size": {
            "type": "integer",
            "enum": [
              1,
              2,
              3
            ],
            "default": 2,
            "description": "52/64/80 px in 9:16 (×7/8 in 16:9)"
          },
          "words_per_line": {
            "type": "integer",
            "minimum": 3,
            "maximum": 8,
            "default": 5
          },
          "animate": {
            "type": "boolean",
            "default": false,
            "description": "Pop ×1.10 on the current word"
          },
          "hook": {
            "$ref": "#/components/schemas/Hook"
          }
        },
        "example": {
          "enabled": true,
          "color": "yellow",
          "case": "normal",
          "lang": "original",
          "overrides": [],
          "style": "karaoke",
          "font": "Inter",
          "position": "bottom",
          "size": 2,
          "words_per_line": 5,
          "animate": true
        }
      },
      "Broll": {
        "type": "object",
        "required": [
          "id",
          "start",
          "end",
          "src",
          "path"
        ],
        "description": "Stock/uploaded footage replacing the picture between `start` and `end` (absolute seconds inside the clip); source audio is kept.",
        "properties": {
          "id": {
            "type": "string"
          },
          "start": {
            "type": "number"
          },
          "end": {
            "type": "number"
          },
          "src": {
            "type": "string",
            "enum": [
              "pexels",
              "upload"
            ]
          },
          "path": {
            "type": "string",
            "description": "Server path under data/broll/ (send back unchanged)"
          },
          "preview_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "query": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "Music": {
        "type": [
          "object",
          "null"
        ],
        "required": [
          "music_id"
        ],
        "description": "Background music from the user's library (`listMusic`), looped, faded, ducked under the voice.",
        "properties": {
          "music_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "volume": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "default": 0.25
          },
          "ducking": {
            "type": "boolean",
            "default": true,
            "description": "Lower the music while someone speaks"
          },
          "fade_s": {
            "type": "number",
            "minimum": 0,
            "maximum": 10,
            "default": 1.5,
            "description": "Fade in/out in seconds"
          }
        }
      },
      "Emoji": {
        "type": "object",
        "required": [
          "start",
          "end",
          "emoji"
        ],
        "description": "Twemoji overlay. `start`/`end` absolute seconds inside the clip; `x`/`y` = centre as fractions 0..1 of the OUTPUT frame.",
        "properties": {
          "start": {
            "type": "number"
          },
          "end": {
            "type": "number"
          },
          "emoji": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2,
            "example": "🔥"
          },
          "x": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "default": 0.5
          },
          "y": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "default": 0.58,
            "description": "0.58 = just above the subtitles"
          },
          "size": {
            "type": "integer",
            "enum": [
              1,
              2,
              3
            ],
            "default": 2,
            "description": "10/14/18 % of the output width"
          }
        }
      },
      "Edit": {
        "type": "object",
        "required": [
          "layout",
          "keyframes",
          "cuts",
          "subtitles",
          "broll",
          "music",
          "emojis"
        ],
        "description": "Complete edit state of a clip for one orientation, as returned by `getClip` / `updateClipEdit`.",
        "properties": {
          "layout": {
            "$ref": "#/components/schemas/Layout"
          },
          "keyframes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Keyframe"
            }
          },
          "cuts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Cut"
            }
          },
          "subtitles": {
            "$ref": "#/components/schemas/Subtitles"
          },
          "broll": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Broll"
            }
          },
          "music": {
            "$ref": "#/components/schemas/Music"
          },
          "emojis": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Emoji"
            }
          }
        }
      },
      "SubtitlesPatch": {
        "type": "object",
        "description": "Partial subtitles patch: every field optional, merged field by field over the current values (or over the built-in defaults for the account preset). Same enums as `Subtitles`.",
        "properties": {
          "enabled": {
            "type": "boolean"
          },
          "color": {
            "type": "string",
            "enum": [
              "yellow",
              "white",
              "green",
              "cyan",
              "pink",
              "orange",
              "rainbow"
            ]
          },
          "case": {
            "type": "string",
            "enum": [
              "normal",
              "upper",
              "lower"
            ]
          },
          "lang": {
            "type": "string"
          },
          "overrides": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SubtitleOverride"
            }
          },
          "style": {
            "type": "string",
            "enum": [
              "karaoke",
              "boxed",
              "outline",
              "glow",
              "minimal"
            ]
          },
          "font": {
            "type": "string",
            "enum": [
              "Inter",
              "Montserrat",
              "Bangers",
              "Poppins",
              "Arial"
            ]
          },
          "position": {
            "type": "string",
            "enum": [
              "bottom",
              "middle",
              "top"
            ]
          },
          "size": {
            "type": "integer",
            "enum": [
              1,
              2,
              3
            ]
          },
          "words_per_line": {
            "type": "integer",
            "minimum": 3,
            "maximum": 8
          },
          "animate": {
            "type": "boolean"
          },
          "hook": {
            "$ref": "#/components/schemas/Hook"
          }
        }
      },
      "EditInput": {
        "type": "object",
        "description": "Body of `updateClipEdit`: a PATCH-like merge. Every field optional (absent = unchanged); `subtitles` is merged field by field; `keyframes`/`cuts`/`broll`/`emojis` replace their list; `music: null` removes it.",
        "properties": {
          "layout": {
            "$ref": "#/components/schemas/Layout"
          },
          "keyframes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Keyframe"
            }
          },
          "cuts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Cut"
            }
          },
          "subtitles": {
            "$ref": "#/components/schemas/SubtitlesPatch"
          },
          "broll": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Broll"
            }
          },
          "music": {
            "$ref": "#/components/schemas/Music"
          },
          "emojis": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Emoji"
            }
          }
        },
        "example": {
          "subtitles": {
            "style": "boxed",
            "color": "yellow"
          },
          "cuts": [
            {
              "start": 130.2,
              "end": 131.0
            }
          ]
        }
      },
      "ExportBatch": {
        "type": "object",
        "required": [
          "exports"
        ],
        "properties": {
          "exports": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "clip_id",
                "export_id"
              ],
              "properties": {
                "clip_id": {
                  "$ref": "#/components/schemas/Uuid"
                },
                "export_id": {
                  "$ref": "#/components/schemas/Uuid"
                }
              }
            }
          }
        }
      },
      "Export": {
        "type": "object",
        "required": [
          "id",
          "clip_id",
          "orientation",
          "status",
          "progress",
          "created_at"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "clip_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "user_id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "orientation": {
            "$ref": "#/components/schemas/Orientation"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "done",
              "failed",
              "canceled"
            ]
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Render progress in %"
          },
          "eta_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Estimated seconds left while `queued`/`running`: moving-average render ratio of the last 10 finished exports × (remaining clip seconds + clips ahead in the queue); null otherwise"
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "`/files/exports/<id>.mp4` once `done` (relative to the server base URL)"
          },
          "output_path": {
            "type": [
              "string",
              "null"
            ]
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string"
          }
        }
      },
      "Word": {
        "type": "object",
        "required": [
          "start",
          "end",
          "word"
        ],
        "properties": {
          "start": {
            "type": "number"
          },
          "end": {
            "type": "number"
          },
          "word": {
            "type": "string"
          }
        }
      },
      "Segment": {
        "type": "object",
        "required": [
          "start",
          "end",
          "text"
        ],
        "description": "Transcript segment, absolute source seconds",
        "properties": {
          "start": {
            "type": "number"
          },
          "end": {
            "type": "number"
          },
          "text": {
            "type": "string"
          },
          "words": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/Word"
            }
          }
        }
      },
      "Filmstrip": {
        "type": "object",
        "required": [
          "url",
          "cols",
          "rows",
          "interval_s",
          "frame_w",
          "frame_h"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "JPEG sheet under /files/thumbs/"
          },
          "cols": {
            "type": "integer"
          },
          "rows": {
            "type": "integer"
          },
          "interval_s": {
            "type": "number",
            "description": "Seconds between two frames"
          },
          "frame_w": {
            "type": "integer"
          },
          "frame_h": {
            "type": "integer"
          }
        }
      },
      "FaceTrack": {
        "type": "object",
        "required": [
          "id",
          "boxes",
          "activity"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "boxes": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "minItems": 5,
              "maxItems": 5
            },
            "description": "[t, x, y, w, h]"
          },
          "activity": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "minItems": 2,
              "maxItems": 2
            },
            "description": "[t, mouth-motion variance]"
          }
        }
      },
      "BrollResult": {
        "type": "object",
        "properties": {
          "pexels_id": {
            "type": "integer"
          },
          "preview_url": {
            "type": "string"
          },
          "video_url": {
            "type": "string"
          },
          "duration": {
            "type": "number",
            "description": "Seconds"
          },
          "width": {
            "type": "integer"
          },
          "height": {
            "type": "integer"
          }
        }
      },
      "MusicTrack": {
        "type": "object",
        "required": [
          "id",
          "name",
          "url",
          "duration_s"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Uuid"
          },
          "name": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "example": "/files/music/<user_id>/<id>.mp3"
          },
          "duration_s": {
            "type": "number"
          }
        }
      },
      "PlanLimits": {
        "type": "object",
        "properties": {
          "videos_month": {
            "type": "integer"
          },
          "max_bytes": {
            "type": "integer"
          },
          "retention_days": {
            "type": ["integer", "null"],
          "description": "Days before automatic deletion; null on paid plans (nothing expires while the subscription is active)"
          },
          "watermark": {
            "type": "boolean"
          },
          "logo": {
            "type": "boolean"
          },
          "premiere_xml": {
            "type": "boolean"
          },
          "edit_captions": {
            "type": "boolean"
          },
          "translate": {
            "type": "boolean"
          },
          "broll_music": {
            "type": "boolean"
          },
          "api_mcp": {
            "type": "boolean"
          },
          "seats": {
            "type": "integer"
          },
          "max_video_s": {
            "type": [
              "number",
              "null"
            ]
          },
          "searches_day": {
            "type": [
              "integer",
              "null"
            ]
          }
        }
      },
      "Plan": {
        "type": "object",
        "required": [
          "id",
          "name",
          "price_monthly",
          "price_yearly",
          "limits"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/PlanId"
          },
          "name": {
            "type": "string"
          },
          "badge": {
            "type": [
              "string",
              "null"
            ]
          },
          "price_monthly": {
            "type": "integer",
            "description": "USD"
          },
          "price_yearly": {
            "type": "integer",
            "description": "USD (10 × monthly)"
          },
          "strike_monthly": {
            "type": [
              "integer",
              "null"
            ]
          },
          "limits": {
            "$ref": "#/components/schemas/PlanLimits"
          },
          "headline_key": {
            "type": "string"
          },
          "features": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "key": {
                  "type": "string"
                },
                "included": {
                  "type": "boolean"
                }
              }
            }
          },
          "feature_keys": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      }
    }
  }
}
