{
 "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. It also generates **explainer videos** (vertical, voice-over + pixel-art or low-poly 3D scenes) from a topic, a JSON script or the user's own voice-over: `/api/explainer*`, `/api/agent/produce`, `/api/jobs*` (contract in https://cliponit.app/llms-api.txt). Integration guide with copy-paste examples (curl, JavaScript, Python, MCP clients, webhooks): https://cliponit.app/integrations.\n\n**MCP**: the same features are exposed as 70 tools at `POST https://cliponit.app/mcp` (Streamable HTTP, OAuth 2.1 or the same API key; discovery at `/.well-known/mcp.json`).\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."
  },
  {
   "name": "explainer",
   "description": "Fully generated vertical explainer videos (voice-over, subtitles, pixel-art scenes) from a JSON script that references catalogue elements by name. Contract: docs/explainer.md."
  },
  {
   "name": "agents",
   "description": "Autonomous production: one call composes, checks and renders a video; jobs, live progress, templates."
  },
  {
   "name": "tiktok",
   "description": "Connexion, publication consentie, programmation et statistiques TikTok. Session utilisateur uniquement ; aucun outil MCP."
  },
  {
   "name": "live",
   "description": "ClipOnIt Live : podcast en direct (hôte + invités par lien + sources OBS/RTMP), enregistrement par morceaux, marqueurs, manifeste post-live."
  },
  {
   "name": "agent-tools",
   "description": "Composable creation tools and server-run execution plans (docs/outils-creation.md)."
  },
  {
   "name": "montage",
   "description": "Montage by reference / house formats"
  }
 ],
 "paths": {
  "/api/consent": {
   "post": {
    "tags": [
     "consent"
    ],
    "operationId": "setAdsConsent",
    "summary": "Choix du bandeau cookies publicitaires",
    "description": "Appelé par /assets/consent.js à chaque choix. Connecté : mémorise le choix sur le compte (événement ads_consent) ; anonyme : sans effet. Les conversions publicitaires serveur (inscription, 1re vidéo, abonnement) ne partent que si le dernier choix est accordé. Voir README « Pixels publicitaires ».",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "ads"
        ],
        "properties": {
         "ads": {
          "type": "boolean"
         }
        }
       }
      }
     }
    },
    "responses": {
     "204": {
      "description": "Enregistré (ou ignoré si non connecté)"
     }
    }
   }
  },
  "/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": {
           "allOf": [
            {
             "$ref": "#/components/schemas/User"
            },
            {
             "type": "object",
             "properties": {
              "delete_after": {
               "type": [
                "string",
                "null"
               ],
               "description": "Scheduled account deletion date, null if none"
              },
              "notify_ready": {
               "type": "boolean",
               "description": "E-mail when a video finishes processing (settable with PATCH /api/me)"
              },
              "live": {
               "type": "boolean",
               "description": "Live studio open for this account (admins, or LIVE_ENABLED and a paid plan)"
              }
             }
            }
           ]
          },
          "quota": {
           "$ref": "#/components/schemas/Quota"
          }
         }
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     }
    }
   },
   "patch": {
    "tags": [
     "account"
    ],
    "operationId": "updateMe",
    "summary": "Update profile (name, locale, e-mail notifications)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "name": {
          "type": "string"
         },
         "locale": {
          "type": "string",
          "description": "UI locale, e.g. `fr` or `en`"
         },
         "notify_ready": {
          "type": "boolean",
          "description": "E-mail me when a video finishes processing (clips ready, or processing failed). Default true."
         }
        }
       }
      }
     }
    },
    "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. The plan byte limit is also checked before writing each received chunk, including requests without Content-Length. Video insertion, quota check and job enqueue commit atomically; a queue failure leaves no new video or consumed quota. Optional text fields `goal`, `brief`, `resources` (JSON) set the clip objective (see `setVideoObjective`).",
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "required": [
         "file"
        ],
        "properties": {
         "file": {
          "type": "string",
          "format": "binary"
         },
         "goal": {
          "type": "string",
          "enum": [
           "views",
           "clients",
           "comments",
           "followers"
          ]
         },
         "brief": {
          "type": "string",
          "maxLength": 1000
         },
         "resources": {
          "type": "string",
          "description": "JSON array of Resource, e.g. [{\"kind\":\"site\",\"url\":\"https://…\"}]"
         }
        }
       }
      }
     }
    },
    "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`. URLs with credentials, local hostnames or literal private/reserved IPs are rejected. Downloads have a one-hour deadline, a 30-second socket timeout and partial-file size monitoring. Initial URL validation does not replace egress protection against redirects or DNS rebinding. Video insertion, quota check and job enqueue commit atomically. Optional `goal`, `brief`, `resources`: the clip objective (see `setVideoObjective`), stored with the video in the same transaction.",
    "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"
         },
         "goal": {
          "type": [
           "string",
           "null"
          ],
          "enum": [
           "views",
           "clients",
           "comments",
           "followers",
           null
          ],
          "description": "What the clips are for. Changes the moments picked, the score (sub-score `goal_fit`, 30 % of the score), the titles, and adds a caption CTA per clip (`cta`)."
         },
         "brief": {
          "type": [
           "string",
           "null"
          ],
          "maxLength": 1000,
          "description": "Free text: what you want"
         },
         "resources": {
          "type": [
           "array",
           "null"
          ],
          "maxItems": 5,
          "items": {
           "$ref": "#/components/schemas/Resource"
          }
         }
        }
       }
      }
     }
    },
    "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"
     }
    },
    "parameters": [
     {
      "name": "tics",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "light",
        "normal",
        "aggressive"
       ]
      },
      "description": "Remove ums, hesitations, repeats and dead air just before rendering (same as `/tics?apply=true`; manual cuts kept). A detection failure does not fail the export."
     },
     {
      "name": "style",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "standard",
        "premium"
       ],
       "default": "standard"
      },
      "description": "`standard` (default) = the edit as is. `premium` (paid plans, 403 on free) = edited for the scroll at render time, the stored edit is untouched: white hook card on the first 3 s quoting a 3-8 word phrase actually said in the clip (verified word for word, else the clip title), lead-in and tail trimmed to the speech, dark clips brightened, big uppercase captions of up to 3 words with keywords (picked by the model among the clip's words) as the only yellow accent, no caption when nobody speaks, ums and silences cut (`tics=normal`), illustration shots from the video (`/broll/illustrate`), light punch-ins on keywords (one every 5 s at most, single-zone layouts), audio normalized to -14 LUFS. Any premium step that fails is skipped, never the export."
     },
     {
      "name": "montage",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "auto",
        "entrepreneur",
        "podcasteur",
        "formateur",
        "ecole",
        "marque"
       ]
      },
      "description": "Montage pro (paid plans; 404 while the feature is not open to the account, see GET /api/montage). Forces the Premium render, then edits it like an ad by recipe: cold open on the strongest verified line, TikTok loop with rewind, musical beat (a wordless moment of the source chosen by loudness/motion), interlude shot from the video's shot index, boxed title, rhythm (ums/silences cut). `auto` = recipe and options read from `brief`. The export gains `montage` (request) and `montage_report` (what was applied or skipped, hook check score 0-3, ms)."
     },
     {
      "name": "brief",
      "in": "query",
      "schema": {
       "type": "string",
       "maxLength": 300
      },
      "description": "With montage=auto: the request in plain words (« façon pub avec une boucle »)."
     },
     {
      "name": "cold_open",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `cold_open` on/off (default: the recipe's)."
     },
     {
      "name": "loop",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `loop` on/off (default: the recipe's)."
     },
     {
      "name": "moment",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `moment` on/off (default: the recipe's)."
     },
     {
      "name": "intermede",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `intermede` on/off (default: the recipe's)."
     },
     {
      "name": "title",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `title` on/off (default: the recipe's)."
     },
     {
      "name": "n",
      "in": "query",
      "schema": {
       "type": "string",
       "pattern": "^[0-9]+$"
      },
      "description": "1..20: only the n best clips (score), e.g. « make me 3 shorts edited like ads » with `montage`."
     },
     {
      "name": "music",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0"
       ]
      },
      "description": "`0` = « Sans musique »: no music added by ClipOnIt in this render (the edit's music bed, the Montage pro library track); the clip's own sound and voice are kept and the loudness target is unchanged. Meant for posting first, then picking a trending licensed sound inside TikTok / Instagram. Remembered with the clip edit (`no_music`, per orientation) for later renders; `1` puts the music back; absent = unchanged."
     }
    ]
   }
  },
  "/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 and queues detection atomically with the status change. Existing clips, edits and files remain available until the new detection replaces them successfully in one transaction. Concurrent requeues cannot create duplicate active jobs. 400 when already processing; 402 when above the plan 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"
     }
    }
   }
  },
  "/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": "Rename the clip and/or move its bounds (start_s / end_s)",
    "description": "`title` is cleaned (first line, 80 chars max) and must not be empty. Bounds: 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. Unknown fields are rejected (400).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "start_s": {
          "type": "number",
          "minimum": 0
         },
         "end_s": {
          "type": "number"
         },
         "title": {
          "type": "string"
         }
        },
        "additionalProperties": false
       },
       "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. B-roll id/path pairs must be registered for this clip and account, independently of the active edit list. Removed segments can be restored by Undo or another orientation. Client-supplied paths alone are not proof of ownership; render validates the same registry.",
    "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. Required subtitle/watermark filters missing from FFmpeg cause an explicit failed export. Overlapping cuts are merged when computing duration and progress. At most 512 retained audio ranges are allowed after merging cuts; a larger fragmented edit fails explicitly before FFmpeg starts. Export creation and render-job enqueue commit atomically.",
    "parameters": [
     {
      "$ref": "#/components/parameters/ClipId"
     },
     {
      "$ref": "#/components/parameters/Orientation"
     },
     {
      "name": "tics",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "light",
        "normal",
        "aggressive"
       ]
      },
      "description": "Remove ums, hesitations, repeats and dead air just before rendering (same as `/tics?apply=true`; manual cuts kept). A detection failure does not fail the export."
     },
     {
      "name": "style",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "standard",
        "premium"
       ],
       "default": "standard"
      },
      "description": "`standard` (default) = the edit as is. `premium` (paid plans, 403 on free) = edited for the scroll at render time, the stored edit is untouched: white hook card on the first 3 s quoting a 3-8 word phrase actually said in the clip (verified word for word, else the clip title), lead-in and tail trimmed to the speech, dark clips brightened, big uppercase captions of up to 3 words with keywords (picked by the model among the clip's words) as the only yellow accent, no caption when nobody speaks, ums and silences cut (`tics=normal`), illustration shots from the video (`/broll/illustrate`), light punch-ins on keywords (one every 5 s at most, single-zone layouts), audio normalized to -14 LUFS. Any premium step that fails is skipped, never the export."
     },
     {
      "name": "montage",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "auto",
        "entrepreneur",
        "podcasteur",
        "formateur",
        "ecole",
        "marque"
       ]
      },
      "description": "Montage pro (paid plans; 404 while the feature is not open to the account, see GET /api/montage). Forces the Premium render, then edits it like an ad by recipe: cold open on the strongest verified line, TikTok loop with rewind, musical beat (a wordless moment of the source chosen by loudness/motion), interlude shot from the video's shot index, boxed title, rhythm (ums/silences cut). `auto` = recipe and options read from `brief`. The export gains `montage` (request) and `montage_report` (what was applied or skipped, hook check score 0-3, ms)."
     },
     {
      "name": "brief",
      "in": "query",
      "schema": {
       "type": "string",
       "maxLength": 300
      },
      "description": "With montage=auto: the request in plain words (« façon pub avec une boucle »)."
     },
     {
      "name": "cold_open",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `cold_open` on/off (default: the recipe's)."
     },
     {
      "name": "loop",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `loop` on/off (default: the recipe's)."
     },
     {
      "name": "moment",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `moment` on/off (default: the recipe's)."
     },
     {
      "name": "intermede",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `intermede` on/off (default: the recipe's)."
     },
     {
      "name": "title",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "true",
        "false"
       ]
      },
      "description": "Montage pro: force `title` on/off (default: the recipe's)."
     },
     {
      "name": "music",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0"
       ]
      },
      "description": "`0` = « Sans musique »: no music added by ClipOnIt in this render (the edit's music bed, the Montage pro library track); the clip's own sound and voice are kept and the loudness target is unchanged. Meant for posting first, then picking a trending licensed sound inside TikTok / Instagram. Remembered with the clip edit (`no_music`, per orientation) for later renders; `1` puts the music back; absent = unchanged."
     }
    ],
    "responses": {
     "200": {
      "description": "Export (queued)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Export"
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    },
    "requestBody": {
     "required": false,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "plan": {
          "type": "object",
          "description": "Edited montage plan from montagePlan, rendered as is"
         }
        }
       }
      }
     }
    }
   }
  },
  "/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. Cancellation committed before final render publication wins; retries and worker restart do not resurrect canceled exports. If completion commits first, the finished export remains available.",
    "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, active speaker, slides and camera cuts, write layout + keyframes",
    "description": "Synchronous (~5 s per 30 s of clip in release). The clip is split into segments at camera cuts (ffmpeg scene > 0.3) and at face-less gaps >= 4 s; a shorter gap inside a shot keeps the last zone. Face-less segment -> `spotlight` 1x1 (whole source on blurred background). mode=auto (default, also applied in 9:16 to every clip detected after import): keynote if a slide + a speaker are detected, else speaker_tracking. mode=speaker_tracking: `vertical` on whoever is TALKING (audio speech level x mouth movement at 10 fps), clean cut at the start of each turn, turns < 1.4 s ignored, `split` during rapid exchanges (>= 3 speaker changes in 6 s). mode=keynote_split: layout `keynote` (whole slide on top in 16:9 + speaker below); mode=keynote_speaker: `vertical` on the speaker, slide cropped out; without a detected slide both fall back to speaker_tracking. mode=follow (legacy): `split` while the clip's stable duo is on screen in that segment, otherwise `vertical` following the active speaker. mode=mix (vertical only): 1 face -> `vertical`; 2 -> `split` (left person on top), or `vertical` on someone talking alone >= 4 s; 3 -> `trio`; 4+ -> wide shot. Segment changes are `ease: cut` keyframes on the camera cut; moves inside a segment are `smooth`; a segment < 2 s after a `vertical` stays `vertical` when it has a face. framing: tight (zone >= 40 % of source height), medium (default, face + shoulders, >= 55 %), wide (full source height); face centre at ~38 % from the top of the zone. Overwrites `layout` and `keyframes` of the edit for this orientation. square (1:1) and portrait45 (4:5) get the same plan as vertical, zones cut at their own cell ratio; horizontal = full frame. Results are cached per clip bounds. 400 on unknown mode/framing. 500 if the face model is missing on the server.",
    "parameters": [
     {
      "$ref": "#/components/parameters/ClipId"
     },
     {
      "$ref": "#/components/parameters/Orientation"
     },
     {
      "name": "mode",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "auto",
        "speaker_tracking",
        "keynote_split",
        "keynote_speaker",
        "follow",
        "mix"
       ],
       "default": "auto"
      },
      "description": "auto = keynote if slides detected else speaker_tracking; speaker_tracking = frame whoever talks; keynote_split = slide on top + speaker; keynote_speaker = speaker only; follow = legacy; mix = mixed auto layout shot by shot (vertical only)"
     },
     {
      "name": "framing",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "tight",
        "medium",
        "wide"
       ],
       "default": "medium"
      },
      "description": "Crop closeness; zoom is capped to avoid grain"
     }
    ],
    "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"
          },
          "shots": {
           "type": "integer",
           "description": "Number of camera shots detected in the clip"
          },
          "mode": {
           "type": "string",
           "description": "mode actually applied"
          },
          "slides": {
           "type": "boolean",
           "description": "a presentation slide + speaker was detected"
          }
         }
        }
       }
      }
     },
     "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": "Audio energy on the clip (150 Hz high-pass, 10 ms frames). Without `threshold_db` the threshold adapts to the background noise (noise floor + max(6 dB, 30 % of the floor-to-speech range)), so it also works in a car or a street. Each silence trimmed by 80 ms on both sides, those shorter than `min_ms` dropped. Returns `{cuts, threshold_db}` 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"
      },
      "description": "Fixed level under which audio counts as silence (dBFS). Omit for the adaptive threshold."
     }
    ],
    "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 from the edit",
    "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"
     }
    },
    "description": "Detaches the segment from the requested orientation. The source and ownership record remain available for Undo and other orientations, until clip/video/account deletion or retention. A file shared between clips of the same account is removed after its last reference."
   }
  },
  "/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"
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/extension/token": {
   "post": {
    "tags": [
     "account"
    ],
    "operationId": "createExtensionToken",
    "summary": "Create a Chrome extension token (`cke_…`)",
    "description": "Returns an opaque token **once** (`cke_` + 64 hex), stored SHA-256 only. **Requires the web `session` cookie**: a Bearer (session token, `ck_…` API key or OAuth `cko_…`) is rejected with 401, so a stolen token cannot mint more. The token replaces the session cookie inside the Chrome extension (`Authorization: Bearer cke_…`) and therefore works on **every plan, free included** — unlike the `ck_…` API key, which unlocks the public API and MCP and needs a paid plan. Sliding expiry: 180 days, pushed back on every authenticated call. Max 5 live tokens per account (the oldest is revoked beyond that); rate-limited to 10/h per account and 30/h per IP.",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "requestBody": {
     "required": false,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "label": {
          "type": "string",
          "maxLength": 60,
          "description": "Optional name shown in `GET /api/me/extensions`",
          "example": "Chrome · macOS"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "New token (shown once)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "token"
         ],
         "properties": {
          "token": {
           "type": "string",
           "pattern": "^cke_[0-9a-f]{64}$"
          },
          "expires_in_days": {
           "type": "integer",
           "example": 180
          }
         }
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "429": {
      "description": "Too many tokens created, retry later"
     }
    }
   }
  },
  "/api/me/extensions": {
   "get": {
    "tags": [
     "account"
    ],
    "operationId": "listExtensionTokens",
    "summary": "Chrome extensions connected to this account",
    "description": "Live tokens only. The token itself is never returned (only its SHA-256 is stored).",
    "responses": {
     "200": {
      "description": "Connected extensions",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "type": "object",
          "properties": {
           "id": {
            "type": "string",
            "format": "uuid"
           },
           "label": {
            "type": [
             "string",
             "null"
            ],
            "example": "Chrome · macOS"
           },
           "created_at": {
            "type": "string",
            "example": "2026-10-16 09:12:00"
           },
           "last_used_at": {
            "type": [
             "string",
             "null"
            ],
            "description": "null until the extension has made one call"
           },
           "expires_at": {
            "type": "string",
            "description": "sliding: +180 days on every authenticated call"
           }
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/me/extensions/{id}": {
   "delete": {
    "tags": [
     "account"
    ],
    "operationId": "revokeExtensionToken",
    "summary": "Revoke one extension token",
    "description": "Only tokens of the calling account; an unknown id returns `{revoked: 0}`.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Revoked",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "revoked": {
           "type": "integer",
           "description": "rows deleted (0 or 1)"
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/explainer/library": {
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "getExplainerLibrary",
    "summary": "Element catalogue",
    "description": "Catalogue of one style as JSON (`?style=<id from /api/explainer/styles>`, default pixel-news), `&compact=1` → `{style, text}` (~2-3k tokens, for an LLM), or `?all=1` → merged catalogue `{styles, families, entries:[{concept, family, style (null when shared), shared, variants:[{style, el, kind (2d|3d), description, params, parts, preview}]}], animations, camera_presets, rule}`: a concept present in several styles is one entry (same family) with one variant per style. A variant identical to the first one (same element redrawn by another style) has `same: <style>` and omits description, parts, path, tags, source. Animated previews under `/explainer-assets/<style>/` (`el/<name>.webp`, `anim/{in,loop,exit,camera}_<name>.webp`, `library.json`), merged `/explainer-assets/library-all.json`; legacy `/explainer-assets/el/…` = pixel-news. Public, cached 1 day. Unknown style: 400. Guest accounts: 403. For an AI (`{style, text}`; ~10,000 elements in a taxonomy): `&describe=a,b`, `&search=words&limit=n` (lines end with ` | path=… | tags=… | preview=…`, both styles), `?browse=<path>` (empty = 48 root categories with counts, a node = sub-nodes + elements when ≤ 80), `?suggest=<narration>&limit=n` (best elements for a scene). For browse/suggest, no style = all styles (`style: null`).",
    "parameters": [
     {
      "name": "compact",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "all",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "style",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "pixel-news",
        "archibald"
       ],
       "default": "pixel-news"
      }
     },
     {
      "name": "full",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      },
      "description": "With compact=1: every line of the catalogue (~100k tokens)"
     },
     {
      "name": "describe",
      "in": "query",
      "schema": {
       "type": "string",
       "maxLength": 4000
      },
      "description": "Comma-separated element names → their full lines"
     },
     {
      "name": "search",
      "in": "query",
      "schema": {
       "type": "string",
       "maxLength": 200
      },
      "description": "Keyword search"
     },
     {
      "name": "browse",
      "in": "query",
      "schema": {
       "type": "string",
       "maxLength": 200
      },
      "description": "Taxonomy path ('/'-separated, letters, digits, _ - / ' &; no '..'); empty = root categories"
     },
     {
      "name": "suggest",
      "in": "query",
      "schema": {
       "type": "string",
       "maxLength": 500
      },
      "description": "Narration sentence → most relevant elements"
     },
     {
      "name": "limit",
      "in": "query",
      "schema": {
       "type": "integer",
       "minimum": 1,
       "maximum": 60
      },
      "description": "Max results: search 1..60 (default 25), suggest 1..30 (default 10)"
     }
    ],
    "responses": {
     "200": {
      "description": "Library (JSON) or {text}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     }
    }
   }
  },
  "/api/explainer/check": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "checkExplainerScript",
    "summary": "Validate a script",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "script"
        ],
        "properties": {
         "script": {
          "$ref": "#/components/schemas/ExplainerScript"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "`errors` empty = valid (warnings do not block the render)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "errors": {
           "type": "array",
           "items": {}
          },
          "warnings": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    },
    "description": "`errors` (blocking) and `warnings` (non-blocking: element touching the subtitles / caption area, outside the style's `safe_area`…). An unknown word anchor in `in`/`out` is an error."
   }
  },
  "/api/explainer/preview": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "previewExplainerScene",
    "summary": "Still frame of a scene (JPEG 1080x1920)",
    "description": "Scene `scene` (index from 0) at `t` seconds from its start. No voice: word timings are estimated. ~1 s.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "script"
        ],
        "properties": {
         "script": {
          "$ref": "#/components/schemas/ExplainerScript"
         },
         "scene": {
          "type": "integer",
          "minimum": 0,
          "default": 0
         },
         "t": {
          "type": "number",
          "minimum": 0,
          "default": 0
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "JPEG",
      "content": {
       "image/jpeg": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/explainer/compose": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "composeExplainerScript",
    "summary": "Let Claude write a script about a topic",
    "description": "Up to ~3 min. Validated against the library. Paid plans (403 otherwise).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "topic"
        ],
        "properties": {
         "topic": {
          "type": "string"
         },
         "selection": {
          "type": "object",
          "description": "Optional subset of the catalogue"
         },
         "style": {
          "type": "string",
          "enum": [
           "pixel-news",
           "archibald"
          ],
          "default": "pixel-news",
          "description": "Style: its own system prompt and compact catalogue are used"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "script": {
           "$ref": "#/components/schemas/ExplainerScript"
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/explainers": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "renderExplainer",
    "summary": "Render an explainer video",
    "description": "Validates the script, then enqueues the `render_explainer` job (TTS + Whisper + render, ~4 min, one at a time). Poll `getExplainer` until `done` (`url`) or `failed`. Paid plans (403 otherwise). Warnings from the check never block: they come back in the response. The object, applicable quota reservation and job commit atomically. An identical queued/running script returns the existing render without consuming another credit, including at the final monthly credit.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "script"
        ],
        "properties": {
         "script": {
          "$ref": "#/components/schemas/ExplainerScript"
         }
        }
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Queued",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string",
           "format": "uuid"
          },
          "status": {
           "type": "string",
           "const": "queued"
          },
          "warnings": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid script (`errors` lists the problems)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "error": {
           "type": "string"
          },
          "errors": {
           "type": "array",
           "items": {}
          },
          "warnings": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   },
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "listExplainers",
    "summary": "List explainer videos (newest first)",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Explainer"
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/explainers/{id}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "getExplainer",
    "summary": "Get an explainer video (with its script)",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Explainer"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   },
   "delete": {
    "tags": [
     "explainer"
    ],
    "operationId": "deleteExplainer",
    "summary": "Delete an explainer video and its file",
    "responses": {
     "200": {
      "$ref": "#/components/responses/Ok"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/styles": {
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "getExplainerStyles",
    "summary": "Explainer styles",
    "description": "List of video styles: `[{id, name, description, theme {accent,bg,ink}, scene_fields {field: description}, video_fields, camera_presets {name: description} (archibald), canvas {w,h}, catalogue (assets URL prefix), library (library.json URL), safe_area (usable area in panel coordinates, e.g. pixel-news `{x:[0,180], y:[-18,150]}`: subtitles above, caption/credit below)}]`. Guest accounts: 403.",
    "responses": {
     "200": {
      "description": "Styles",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "type": "object"
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/explainer/my-elements": {
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "listUserElements",
    "summary": "List the user's own elements (newest first): pixel art (custom) and, with ?kind=3d|all, 3D objects (custom3d)",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/UserElement"
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    },
    "parameters": [
     {
      "name": "kind",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "2d",
        "3d",
        "all"
       ],
       "default": "2d"
      },
      "description": "2d = pixel art only (default, what the pixel editor shows), 3d = custom3d objects, all"
     }
    ]
   },
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "createUserElement",
    "summary": "Save a custom pixel-art element (validated by the engine, max 200 per account)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UserElementInput"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Created",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/UserElement"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/explainer/my-elements/{id}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "getUserElement",
    "summary": "Get one element",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/UserElement"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   },
   "put": {
    "tags": [
     "explainer"
    ],
    "operationId": "updateUserElement",
    "summary": "Replace an element",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UserElementInput"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/UserElement"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   },
   "delete": {
    "tags": [
     "explainer"
    ],
    "operationId": "deleteUserElement",
    "summary": "Delete an element",
    "responses": {
     "200": {
      "$ref": "#/components/responses/Ok"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/my-elements/{id}/preview.webp": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "previewUserElement",
    "summary": "Animated 360x260 WebP preview",
    "responses": {
     "200": {
      "description": "image/webp",
      "content": {
       "image/webp": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/pixelize": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "pixelizeImage",
    "summary": "Convert an image (PNG/JPG/WebP/GIF ≤ 8 MB) to {pixels, palette}; plain or transparent background removed",
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "required": [
         "file"
        ],
        "properties": {
         "file": {
          "type": "string",
          "format": "binary"
         },
         "width": {
          "type": "integer",
          "minimum": 16,
          "maximum": 128,
          "default": 48
         },
         "colors": {
          "type": "integer",
          "minimum": 2,
          "maximum": 24,
          "default": 8
         },
         "theme": {
          "type": "string",
          "enum": [
           "0",
           "1"
          ],
          "description": "1 = project onto the style's greys + accent (theme tokens)"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "pixels": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "palette": {
           "type": "object"
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/explainer/schema": {
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "explainerSchema",
    "summary": "JSON Schema (draft 2020-12) of an explainer script, generated from the catalogue (cached 10 min)",
    "parameters": [
     {
      "name": "style",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "pixel-news",
        "archibald"
       ],
       "default": "pixel-news"
      }
     },
     {
      "name": "elements",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "Comma-separated element names: short schema restricted to them (full schema ≈ 2 MB)"
     }
    ],
    "responses": {
     "200": {
      "description": "JSON Schema",
      "content": {
       "application/schema+json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     }
    }
   }
  },
  "/api/explainer/export": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "explainerExport",
    "summary": "Export a 3D object / scene (glb, obj), a 2D element (png, gif, sheet) or a script (explainer)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "format"
        ],
        "properties": {
         "script": {
          "type": "object",
          "description": "Whole script (formats explainer, glb, obj; scene = index)"
         },
         "element": {
          "type": "object",
          "description": "One element {el, ...params} or a saved data object"
         },
         "format": {
          "type": "string",
          "enum": [
           "glb",
           "obj",
           "png",
           "gif",
           "sheet",
           "explainer"
          ],
          "description": "glb = glTF 2.0 binary (one node per part), ANIMATED by default (moves, entries/exits, moving parts, camera, red highlight via KHR_animation_pointer + grey/red mesh swap, particle effects smoke/fire/explosion/cloud as animated unlit spheres); obj = zip (.obj + .mtl), static by nature; png; gif (animated, transparent); sheet = zip (spritesheet .png + .json frames); explainer = .explainer.json (scripts only)"
         },
         "scene": {
          "type": "integer",
          "minimum": 0,
          "default": 0
         },
         "t": {
          "type": "number",
          "minimum": 0,
          "default": 0,
          "description": "Seconds: pose of a static export (obj, or glb with animate=false)"
         },
         "scale": {
          "type": "integer",
          "minimum": 1,
          "maximum": 16,
          "default": 4,
          "description": "Pixel scale of png / gif / sheet"
         },
         "animate": {
          "type": "boolean",
          "description": "glb: animated (default: the scene's duration, like the preview) or false = static pose at t. obj is static by nature: true is refused (400)"
         },
         "fps": {
          "type": "integer",
          "minimum": 1,
          "maximum": 60,
          "default": 30,
          "description": "Animation sampling rate (glb)"
         },
         "all_scenes": {
          "type": "boolean",
          "default": false,
          "description": "Script -> glb: the whole video on one timeline (one root node and one animation 'Scène n' per scene + 'Vidéo complète', played first)"
         },
         "particles": {
          "type": "string",
          "enum": [
           "full",
           "lite",
           "off"
          ],
          "default": "lite",
          "description": "Animated glb: particle effects (smoke, cooling tower / factory steam, fire, explosion, cloud), one node per particle (shared icosphere, TRS + color / opacity via KHR_animation_pointer, emissive for fire). lite = at most 150 particles per effect, sampled at ≤ 15 fps; full = every particle at fps; off = none"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "The exported file (Content-Disposition: attachment)",
      "content": {
       "model/gltf-binary": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "application/zip": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "image/png": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "image/gif": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "application/json": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     }
    }
   }
  },
  "/api/explainer/my-elements/{id}/export": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "exportUserElement",
    "summary": "Export a saved element: 3D → glb / obj (or png / gif / sheet seen in orbit), 2D → png / gif / sheet",
    "parameters": [
     {
      "name": "format",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "enum": [
        "glb",
        "obj",
        "png",
        "gif",
        "sheet"
       ],
       "description": "glb = glTF 2.0 binary (one node per part), ANIMATED by default (moves, entries/exits, moving parts, camera, red highlight via KHR_animation_pointer + grey/red mesh swap, particle effects smoke/fire/explosion/cloud as animated unlit spheres); obj = zip (.obj + .mtl), static by nature; png; gif (animated, transparent); sheet = zip (spritesheet .png + .json frames); explainer = .explainer.json (scripts only)"
      }
     },
     {
      "name": "scale",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "minimum": 1,
       "maximum": 16,
       "default": 4
      }
     },
     {
      "name": "t",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "minimum": 0,
       "default": 0
      }
     },
     {
      "name": "animate",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean",
       "description": "glb animated (default, when the object moves) or false = static at t"
      }
     },
     {
      "name": "fps",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "minimum": 1,
       "maximum": 60,
       "default": 30
      }
     },
     {
      "name": "particles",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "full",
        "lite",
        "off"
       ],
       "default": "lite",
       "description": "Animated glb: particle effects (lite = ≤ 150 per effect, ≤ 15 fps; full; off)"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The exported file (Content-Disposition: attachment)",
      "content": {
       "model/gltf-binary": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "application/zip": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "image/png": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "image/gif": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       },
       "application/json": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/audio": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "importExplainerAudio",
    "summary": "Import a voice-over (file or link) for an explainer video",
    "description": "multipart `file` (audio or video, ≤ 300 MB) or JSON {url} (YouTube or direct audio link, downloaded with yt-dlp, audio only). Transcribed and split into scenes by the job `import_audio`; poll GET /api/explainer/audio/{id}. Max duration = plan limit, 30 min at most. Paid plans. Multipart `source`: import (default) | mic | idea | music. `music` = background music for `script.music`: never transcribed, outside the monthly audio minutes, hidden from GET /api/explainer/audio. An audio without speech (music, silence: Whisper's phantom “Thanks for watching!”) ends `failed` with an error starting `no_speech:`.",
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "required": [
         "file"
        ],
        "properties": {
         "file": {
          "type": "string",
          "format": "binary"
         }
        }
       }
      },
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "url"
        ],
        "properties": {
         "url": {
          "type": "string",
          "format": "uri"
         }
        }
       }
      }
     }
    },
    "responses": {
     "202": {
      "description": "Queued",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "const": "queued"
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "402": {
      "$ref": "#/components/responses/PaymentRequired"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   },
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "listExplainerAudio",
    "summary": "List imported audios (newest first)",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/ExplainerAudio"
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/explainer/audio/{id}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "getExplainerAudio",
    "summary": "Imported audio status (+ text and scenes when done)",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ExplainerAudio"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   },
   "delete": {
    "tags": [
     "explainer"
    ],
    "operationId": "deleteExplainerAudio",
    "summary": "Delete an imported audio and its files",
    "responses": {
     "200": {
      "$ref": "#/components/responses/Ok"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/audio/{id}/file": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "getExplainerAudioFile",
    "summary": "The imported audio (mp3, owner only, Range supported)",
    "responses": {
     "200": {
      "description": "audio/mpeg",
      "content": {
       "audio/mpeg": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "206": {
      "description": "Partial content (Range)"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/audio/{id}/compose": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "composeExplainerFromAudio",
    "summary": "Let Claude compose the visuals on an imported audio (text kept)",
    "description": "Audio must be done. One scene per segment; the returned script carries audio {id, segments:[[start,end],...]}: keep it, then check / render (the video plays the imported audio). Paid plans.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "style": {
          "type": "string",
          "enum": [
           "pixel-news",
           "archibald"
          ],
          "default": "pixel-news"
         },
         "topic": {
          "type": "string",
          "description": "Optional hint (≤ 500 chars)"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "script": {
           "$ref": "#/components/schemas/ExplainerScript"
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/library/elements": {
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "listLibraryElements",
    "summary": "List the shared element library (DB)",
    "parameters": [
     {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "mots cherchés dans nom, description, tags, chemin"
     },
     {
      "name": "path",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "préfixe de taxonomie"
     },
     {
      "name": "kind",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "code",
        "data"
       ]
      },
      "description": ""
     },
     {
      "name": "style",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "pixel-news",
        "archibald"
       ]
      },
      "description": ""
     },
     {
      "name": "enabled",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "0",
        "all"
       ],
       "default": "1"
      },
      "description": "0 et all : admins"
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "minimum": 1,
       "maximum": 200,
       "default": 50
      },
      "description": ""
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "minimum": 0,
       "default": 0
      },
      "description": ""
     }
    ],
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "total": {
           "type": "integer"
          },
          "offset": {
           "type": "integer"
          },
          "limit": {
           "type": "integer"
          },
          "items": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/LibraryElement"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   },
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "createLibraryElement",
    "summary": "Add a data element to the shared library (admins)",
    "description": "Spec custom (pixel-news) ou custom3d (archibald), validée par le moteur, aperçu généré ; visible dans library / search / suggest / browse et la page bibliothèque sans redéployer.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/LibraryElementInput"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/LibraryElement"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/explainer/library/elements/{style}/{name}": {
   "parameters": [
    {
     "name": "style",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "enum": [
       "pixel-news",
       "archibald"
      ]
     }
    },
    {
     "name": "name",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_]{1,59}$"
     }
    }
   ],
   "put": {
    "tags": [
     "explainer"
    ],
    "operationId": "updateLibraryElement",
    "summary": "Update a library element (admins)",
    "description": "Élément code : seul enabled. Élément data : enabled, family, path, tags, description, data.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "enabled": {
          "type": "boolean"
         },
         "family": {
          "type": "string"
         },
         "path": {
          "type": "string"
         },
         "tags": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "description": {
          "type": "string"
         },
         "data": {
          "type": "object"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/LibraryElement"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   },
   "delete": {
    "tags": [
     "explainer"
    ],
    "operationId": "deleteLibraryElement",
    "summary": "Delete a data element / disable a code element (admins)",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "deleted": {
           "type": "boolean"
          },
          "disabled": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/my-elements/{id}/publish": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "publishUserElement",
    "summary": "Propose one of my elements to the shared library (pending admin validation)",
    "requestBody": {
     "required": false,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "name": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_]{1,59}$"
         },
         "family": {
          "type": "string"
         },
         "path": {
          "type": "string"
         },
         "tags": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "description": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Created",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "status": {
           "type": "string",
           "enum": [
            "pending"
           ]
          },
          "element": {
           "$ref": "#/components/schemas/LibraryElement"
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/library/suggest-batch": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "suggestExplainerElementsBatch",
    "summary": "Suggest elements for many sentences",
    "description": "Same as `GET /api/explainer/library?suggest=` for up to 60 sentences (or short concept queries) in one engine pass. `results[i]` = the suggest text for `texts[i]`. Without `style`: all styles. Guest accounts: 403.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "texts"
        ],
        "properties": {
         "texts": {
          "type": "array",
          "minItems": 1,
          "maxItems": 60,
          "items": {
           "type": "string",
           "maxLength": 500
          }
         },
         "style": {
          "type": "string",
          "enum": [
           "pixel-news",
           "archibald"
          ]
         },
         "limit": {
          "type": "integer",
          "minimum": 1,
          "maximum": 30,
          "default": 10
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "One result per text, same order",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "results": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/agent/produce": {
   "post": {
    "tags": [
     "agents"
    ],
    "operationId": "agentProduce",
    "summary": "Produce an explainer video end to end (no human)",
    "description": "Exactly one source: `topic` (the server's Claude writes the script), `script`, `audio_id` or `audio_url` (the user's own voice-over), or `scenes`. Runs transcribe → compose → check → render in the background; poll `getJob` (or `GET /api/jobs/{id}/events`, SSE) until `done` (`url`, `editor_url`) or `failed`. 402 = monthly explainer renders or monthly AI budget reached, 403 = plan or read-only API key, 429 = 2 jobs already running.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "topic": {
          "type": "string"
         },
         "script": {
          "$ref": "#/components/schemas/ExplainerScript"
         },
         "audio_id": {
          "type": "string"
         },
         "audio_url": {
          "type": "string"
         },
         "scenes": {
          "type": "array",
          "items": {
           "type": "object"
          }
         },
         "style": {
          "type": "string",
          "enum": [
           "pixel-news",
           "archibald"
          ]
         },
         "duration_s": {
          "type": "integer",
          "minimum": 15,
          "maximum": 180
         },
         "voice": {
          "type": "string",
          "description": "ElevenLabs voice of the catalogue (templates/voices.json), by voice_id or name: N6vE6dp4ouYqQlMCBL5j Guillaume (fr), J50pwizkkVglhJNh30K8 Aslan (fr), jfEwztGDkpbpy89xeku6 Cyp (fr), TojRWZatQyy9dujEdiQ1 Koraly (fr), O31r762Gb3WFygrEOGh0 Victoire (fr), 5OnMHwgTFgvPVwE8jP6B Anaïs (fr), nPczCjzI2devNBz1zQrb Brian (en), TX3LPaxmHKxFdv7VOQHJ Liam (en), pFZP5JQG7iQjIQuC4Bku Lily (en), cgSgspJ2msm6clMCkdW9 Jessica (en). Old OpenAI names (alloy, ash, nova, onyx…) still accepted, rendered with OpenAI. Other value → 400."
         },
         "brand": {
          "type": "boolean"
         },
         "title": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "202": {
      "description": "Accepted: the job runs in the background",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "job": {
           "$ref": "#/components/schemas/AgentJob"
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "402": {
      "$ref": "#/components/responses/PaymentRequired"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "429": {
      "description": "Two productions already running: retry later"
     }
    }
   }
  },
  "/api/jobs": {
   "get": {
    "tags": [
     "agents"
    ],
    "operationId": "listJobs",
    "summary": "List agent production jobs",
    "parameters": [
     {
      "name": "active",
      "in": "query",
      "schema": {
       "type": "integer",
       "enum": [
        0,
        1
       ]
      },
      "description": "1 = only queued/running"
     }
    ],
    "responses": {
     "200": {
      "description": "Jobs, newest first",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/AgentJob"
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/jobs/{id}": {
   "get": {
    "tags": [
     "agents"
    ],
    "operationId": "getJob",
    "summary": "Get an agent production job",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Job",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AgentJob"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/jobs/{id}/cancel": {
   "post": {
    "tags": [
     "agents"
    ],
    "operationId": "cancelJob",
    "summary": "Cancel a queued or running job",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Job with status canceled",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AgentJob"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/jobs/{id}/events": {
   "get": {
    "tags": [
     "agents"
    ],
    "operationId": "jobEvents",
    "summary": "Live job progress (Server-Sent Events)",
    "description": "`text/event-stream`: one event per step / percent change (the JOB as data), closed when the job ends.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "SSE stream",
      "content": {
       "text/event-stream": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/my-templates": {
   "get": {
    "tags": [
     "agents"
    ],
    "operationId": "listMyTemplates",
    "summary": "List the account's parameterized script templates",
    "responses": {
     "200": {
      "description": "Templates",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "type": "object"
         }
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "agents"
    ],
    "operationId": "saveMyTemplate",
    "summary": "Save a script as a parameterized template ({{sujet}}, {{chiffre}}…)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "name",
         "script"
        ],
        "properties": {
         "name": {
          "type": "string"
         },
         "description": {
          "type": "string"
         },
         "script": {
          "$ref": "#/components/schemas/ExplainerScript"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Stored template",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     }
    }
   }
  },
  "/api/explainer/my-templates/{id}/instantiate": {
   "post": {
    "tags": [
     "agents"
    ],
    "operationId": "instantiateMyTemplate",
    "summary": "Fill a template's params and return the script (nothing rendered)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "values"
        ],
        "properties": {
         "values": {
          "type": "object"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{script}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/my-templates/{id}/run": {
   "post": {
    "tags": [
     "agents"
    ],
    "operationId": "runMyTemplate",
    "summary": "Produce a video from a template (fixed or recompose)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "values"
        ],
        "properties": {
         "values": {
          "type": "object"
         },
         "mode": {
          "type": "string",
          "enum": [
           "fixed",
           "recompose"
          ]
         },
         "style": {
          "type": "string"
         },
         "voice": {
          "type": "string",
          "description": "ElevenLabs voice of the catalogue (templates/voices.json), by voice_id or name: N6vE6dp4ouYqQlMCBL5j Guillaume (fr), J50pwizkkVglhJNh30K8 Aslan (fr), jfEwztGDkpbpy89xeku6 Cyp (fr), TojRWZatQyy9dujEdiQ1 Koraly (fr), O31r762Gb3WFygrEOGh0 Victoire (fr), 5OnMHwgTFgvPVwE8jP6B Anaïs (fr), nPczCjzI2devNBz1zQrb Brian (en), TX3LPaxmHKxFdv7VOQHJ Liam (en), pFZP5JQG7iQjIQuC4Bku Lily (en), cgSgspJ2msm6clMCkdW9 Jessica (en). Old OpenAI names (alloy, ash, nova, onyx…) still accepted, rendered with OpenAI. Other value → 400."
         },
         "brand": {
          "type": "boolean"
         },
         "duration_s": {
          "type": "integer",
          "minimum": 15,
          "maximum": 180
         }
        }
       }
      }
     }
    },
    "responses": {
     "202": {
      "description": "Accepted: the job runs in the background",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "job": {
           "$ref": "#/components/schemas/AgentJob"
          }
         }
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "402": {
      "$ref": "#/components/responses/PaymentRequired"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "429": {
      "description": "Two productions already running: retry later"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/videos/{id}/clips": {
   "post": {
    "tags": [
     "videos"
    ],
    "operationId": "createClip",
    "summary": "Create a clip by hand from exact bounds",
    "description": "Owner only, video `ready` (400 otherwise). `0 ≤ start_s < end_s ≤ duration`, 3 ≤ duration ≤ 180 s. With `snap` (default true) each bound within 0.6 s of a transcript sentence start/end is snapped to it, else to the nearest word boundary within 0.6 s (never further), then rounded to 0.01 s. Without `title`, the clip is returned at once with its first words as title, and an AI title (and `reason`) is written in the background a few seconds later. Inserted at its chronological position (later clips move down one rank), returned with `thumbnail_url`; `score` is null. Does not count against the monthly video quota.",
    "parameters": [
     {
      "$ref": "#/components/parameters/VideoId"
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "start_s",
         "end_s"
        ],
        "properties": {
         "start_s": {
          "type": "number",
          "minimum": 0,
          "example": 62.4
         },
         "end_s": {
          "type": "number",
          "example": 104.9
         },
         "title": {
          "type": "string",
          "maxLength": 80,
          "example": "Why the crew flies Orion by hand"
         },
         "snap": {
          "type": "boolean",
          "default": true
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "New clip",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Clip"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/videos/{id}/transcript": {
   "get": {
    "tags": [
     "videos"
    ],
    "operationId": "getVideoTranscript",
    "summary": "Full transcript of a video",
    "description": "Segments `{start, end, text, words:[{start, end, word}], speaker?}` in absolute seconds; `[]` before transcription. `speaker` (0, 1, 2… in order of appearance) comes from speaker diarization (ElevenLabs Scribe): a segment never mixes two speakers; absent when the transcript has no speakers (Whisper).",
    "parameters": [
     {
      "$ref": "#/components/parameters/VideoId"
     }
    ],
    "responses": {
     "200": {
      "description": "Segments",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "type": "object",
          "properties": {
           "start": {
            "type": "number"
           },
           "end": {
            "type": "number"
           },
           "text": {
            "type": "string"
           },
           "words": {
            "type": "array",
            "items": {
             "type": "object",
             "properties": {
              "start": {
               "type": "number"
              },
              "end": {
               "type": "number"
              },
              "word": {
               "type": "string"
              }
             }
            }
           },
           "speaker": {
            "type": "integer",
            "description": "Speaker index (diarization), absent without diarization"
           }
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/explainer/voice/take": {
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "explainerVoiceTake",
    "summary": "Synthesize one scene's voice as the render will (same per-account TTS cache; fresh = new take)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "voice",
         "say"
        ],
        "properties": {
         "voice": {
          "type": "object",
          "description": "script.voice as is: voice_id (catalogue), style 0..1, speed 0.5..2, model; legacy voice / instructions (OpenAI)"
         },
         "say": {
          "type": "string",
          "maxLength": 2000
         },
         "fresh": {
          "type": "boolean",
          "default": false,
          "description": "new take: cache entry for this text is replaced"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Take",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "url": {
           "type": "string"
          },
          "dur": {
           "type": "number"
          },
          "words": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "w": {
              "type": "string"
             },
             "red": {
              "type": "boolean"
             },
             "t0": {
              "type": "number"
             },
             "t1": {
              "type": "number"
             }
            }
           }
          },
          "cached": {
           "type": "boolean"
          },
          "cost_usd": {
           "type": "number"
          },
          "fallback": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid voice or say"
     },
     "402": {
      "description": "Daily limit of 100 syntheses reached (cached voices still served)"
     },
     "403": {
      "description": "Paid plan required"
     },
     "503": {
      "description": "No voice provider available"
     }
    }
   }
  },
  "/api/explainer/voice/take/{file}": {
   "parameters": [
    {
     "name": "file",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^v_[0-9a-f]{16}(_x[0-9.]+)?\\.wav$"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "getExplainerVoiceTake",
    "summary": "A take's wav from the caller's voice cache (Range supported)",
    "responses": {
     "200": {
      "description": "audio/wav",
      "content": {
       "audio/wav": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "206": {
      "description": "Partial content (Range)"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/tiktok/account": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "getTikTokAccount",
    "summary": "État de connexion et capacités effectives",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TikTokAccount"
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    }
   },
   "delete": {
    "tags": [
     "tiktok"
    ],
    "operationId": "disconnectTikTok",
    "summary": "Révoquer TikTok et annuler les envois en attente",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    }
   }
  },
  "/api/tiktok/connect": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "connectTikTok",
    "summary": "Commencer Login Kit (cookie de session requis)",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "307": {
      "description": "Redirection ou acquittement"
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    }
   }
  },
  "/api/tiktok/callback": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "callbackTikTok",
    "summary": "Consommer le retour OAuth lié à la session",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "303": {
      "description": "Redirection ou acquittement"
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "parameters": [
     {
      "name": "code",
      "in": "query",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "state",
      "in": "query",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "error",
      "in": "query",
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/tiktok/creator": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "getTikTokCreator",
    "summary": "Permissions actuelles et confidentialité autorisée",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TikTokCreator"
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    }
   }
  },
  "/api/tiktok/sources": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "getTikTokSources",
    "summary": "Exports terminés du compte ; id clip = identifiant export",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TikTokSource"
           }
          }
         },
         "required": [
          "items"
         ]
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    }
   }
  },
  "/api/tiktok/import": {
   "post": {
    "tags": [
     "tiktok"
    ],
    "operationId": "importTikTokVideo",
    "summary": "Importer un lien TikTok avec confirmation des droits",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Video"
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "url": {
          "type": "string"
         },
         "rights_confirmed": {
          "type": "boolean",
          "const": true
         }
        },
        "required": [
         "url",
         "rights_confirmed"
        ]
       }
      }
     }
    }
   }
  },
  "/api/tiktok/posts": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "listTikTokPosts",
    "summary": "100 dernières demandes du compte",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TikTokPost"
           }
          }
         },
         "required": [
          "items"
         ]
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    }
   },
   "post": {
    "tags": [
     "tiktok"
    ],
    "operationId": "createTikTokPost",
    "summary": "Envoyer ou programmer une vidéo revue et consentie",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements. HTTP 200 signifie demande enregistrée : lire status, qui peut valoir failed ou unknown.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TikTokPost"
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TikTokCreatePost"
       }
      }
     }
    }
   }
  },
  "/api/tiktok/posts/{id}": {
   "delete": {
    "tags": [
     "tiktok"
    ],
    "operationId": "cancelTikTokPost",
    "summary": "Annuler une demande scheduled ou queued",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/tiktok/posts/{id}/refresh": {
   "post": {
    "tags": [
     "tiktok"
    ],
    "operationId": "refreshTikTokPost",
    "summary": "Actualiser le statut et les compteurs publics",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TikTokPost"
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/tiktok/posts/{id}/metrics": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "getTikTokPostMetrics",
    "summary": "Instantanés horaires des 30 derniers jours",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "items": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/TikTokMetricSnapshot"
           }
          }
         },
         "required": [
          "items"
         ]
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/tiktok/analytics": {
   "get": {
    "tags": [
     "tiktok"
    ],
    "operationId": "getTikTokAnalytics",
    "summary": "20 vidéos publiques et leurs compteurs",
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "description": "Session ClipOnIt obligatoire. Clés API et jetons MCP refusés. Voir docs/tiktok-integration.md pour les capacités, limites et consentements.",
    "responses": {
     "200": {
      "description": "Succès",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TikTokAnalytics"
        }
       }
      }
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "parameters": [
     {
      "name": "cursor",
      "in": "query",
      "schema": {
       "type": "integer",
       "minimum": 0
      }
     }
    ]
   }
  },
  "/api/tiktok/webhook": {
   "post": {
    "tags": [
     "tiktok"
    ],
    "operationId": "receiveTikTokWebhook",
    "summary": "Notification TikTok authentifiée par signature HMAC",
    "security": [],
    "description": "Corps brut limité à 64 Ko. Signature TikTok-Signature HMAC SHA-256, horodatage ±300 s, déduplication. Aucun cookie utilisateur requis.",
    "responses": {
     "200": {
      "description": "Redirection ou acquittement"
     },
     "400": {
      "description": "Configuration ou validation refusée"
     },
     "401": {
      "description": "Session ou signature absente/invalide"
     },
     "403": {
      "description": "Permission non accordée"
     },
     "404": {
      "description": "Ressource absente ou appartenant à un autre utilisateur"
     },
     "409": {
      "description": "Conflit de demande ou publication déjà commencée"
     },
     "429": {
      "description": "Limite atteinte"
     }
    },
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "client_key": {
          "type": "string"
         },
         "event": {
          "type": "string"
         },
         "user_openid": {
          "type": "string"
         },
         "content": {
          "type": "string"
         },
         "create_time": {
          "type": "integer",
          "minimum": 0
         }
        },
        "required": [
         "client_key",
         "event",
         "user_openid",
         "content",
         "create_time"
        ]
       }
      }
     }
    },
    "parameters": [
     {
      "name": "TikTok-Signature",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/clips/{id}/broll/illustrate": {
   "post": {
    "tags": [
     "enrich"
    ],
    "operationId": "illustrateClipWithVideo",
    "summary": "B-roll from the user's own video: illustrate what is said with other moments of the same source",
    "description": "Indexes the source video on first call (one keyframe per shot, described by a light vision model; ~30-60 s, ~0.1 $, cached in `video_shots`), then picks shots that show what the speaker mentions and adds them to `edit.broll` (`src: \"video\"`, `source_s`). Rules: no shot inside the clip, no talking-head shot, 1.5-2.5 s, ≥ 4 s between insert starts, no overlap with existing B-roll; source audio is kept (inserts are muted).",
    "parameters": [
     {
      "$ref": "#/components/parameters/ClipId"
     },
     {
      "$ref": "#/components/parameters/Orientation"
     },
     {
      "name": "source_video_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "Take the shots from another of your (ready) videos instead of the clip's source (e.g. a `video` resource of the objective)"
     }
    ],
    "responses": {
     "200": {
      "description": "Inserts added",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "broll",
          "inserts",
          "shots_indexed"
         ],
         "properties": {
          "broll": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Broll"
           }
          },
          "shots_indexed": {
           "type": "integer"
          },
          "inserts": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "start": {
              "type": "number"
             },
             "end": {
              "type": "number"
             },
             "source_s": {
              "type": "number"
             },
             "shot": {
              "type": "integer"
             },
             "word": {
              "type": "string"
             },
             "description": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/clips/{id}/broll/shots": {
   "get": {
    "tags": [
     "enrich"
    ],
    "operationId": "listVideoShots",
    "summary": "Shots of the clip's source video usable as B-roll (indexes the video on first call)",
    "description": "Talking-head shots and shots inside the clip are excluded. `thumb_url` = keyframe of the shot.",
    "parameters": [
     {
      "$ref": "#/components/parameters/ClipId"
     },
     {
      "name": "source_video_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "Take the shots from another of your (ready) videos instead of the clip's source (e.g. a `video` resource of the objective)"
     }
    ],
    "responses": {
     "200": {
      "description": "Shots",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "total": {
           "type": "integer"
          },
          "shots": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "idx": {
              "type": "integer"
             },
             "start_s": {
              "type": "number"
             },
             "end_s": {
              "type": "number"
             },
             "description": {
              "type": "string"
             },
             "keywords": {
              "type": "string"
             },
             "thumb_url": {
              "type": "string"
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/clips/{id}/broll/from-video": {
   "post": {
    "tags": [
     "enrich"
    ],
    "operationId": "addVideoBroll",
    "summary": "Place a moment of the clip's own source video as a B-roll",
    "description": "Cuts `[source_s, source_s + (end - start)]` from the source (muted, short side 720) and covers the clip on `[start, end]` (absolute seconds inside the clip, 10 s max). 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",
         "source_s"
        ],
        "properties": {
         "start": {
          "type": "number"
         },
         "end": {
          "type": "number"
         },
         "source_s": {
          "type": "number",
          "minimum": 0
         },
         "shot": {
          "type": "integer",
          "description": "idx from listVideoShots (label + thumbnail)"
         },
         "source_video_id": {
          "type": "string",
          "description": "Take the passage from another of your videos (default: the clip's source)"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "B-roll list",
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Broll"
         }
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     }
    }
   }
  },
  "/api/premium": {
   "get": {
    "tags": [
     "premium"
    ],
    "operationId": "getPremium",
    "summary": "Premium (HeyGen) overview: available (paid plan), configured, simulated, balance (premium credits), pricing per minute, packs, consent texts",
    "responses": {
     "200": {
      "description": "Overview",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/premium/catalog": {
   "get": {
    "tags": [
     "premium"
    ],
    "operationId": "getPremiumCatalog",
    "summary": "Public HeyGen avatars {id,name,preview_image_url,default_voice_id}, voices {id,name,language}, translation languages",
    "responses": {
     "200": {
      "description": "Catalog",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "$ref": "#/components/responses/PaymentRequired"
     }
    }
   }
  },
  "/api/premium/avatar": {
   "post": {
    "tags": [
     "premium"
    ],
    "operationId": "createPremiumAvatar",
    "summary": "Avatar presenter video (paid plans, premium credits): an avatar reads `script` with `voice_id`, or lip-syncs the voice of a rendered explainer (`explainer_id`, then overlaid bottom-right in the explainer, consent required). dry_run:true returns {credits, duration_s, balance, consent_required, consent_text} without charging. Credits per minute on screen depend on tier (40 standard, 200 premium, 300 Avatar V twin), max 180 s.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "avatar_id"
        ],
        "properties": {
         "avatar_id": {
          "type": "string"
         },
         "voice_id": {
          "type": "string"
         },
         "script": {
          "type": "string",
          "maxLength": 5000
         },
         "explainer_id": {
          "type": "string"
         },
         "consent": {
          "type": "boolean"
         },
         "dry_run": {
          "type": "boolean"
         },
         "tier": {
          "type": "string",
          "enum": [
           "standard",
           "premium"
          ],
          "default": "premium",
          "description": "standard = Avatar III (40 credits/min), premium = Avatar IV (200; Avatar V 300 for a supporting digital twin). 400 if the look does not support the tier (catalog avatars[].tiers)."
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Premium job {id, kind: avatar|translate, status: queued|running|done|failed, credits, duration_s, avatar_id, voice_id, language, clip_id, explainer_id, consent_at, error, created_at, url (when done)}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "402": {
      "$ref": "#/components/responses/PaymentRequired"
     },
     "429": {
      "description": "2 jobs running, or the 24 h cap (30 min avatar / 60 min dubbing) reached, or HeyGen unavailable"
     }
    }
   }
  },
  "/api/premium/translate": {
   "post": {
    "tags": [
     "premium"
    ],
    "operationId": "createPremiumTranslate",
    "summary": "Translate and dub a clip with lip-sync (HeyGen; quality speed = 35 credits/min of source, precision = 65) from its latest done export. consent:true required (the speaker is you or agreed). dry_run:true = price only.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "clip_id",
         "language"
        ],
        "properties": {
         "clip_id": {
          "type": "string"
         },
         "language": {
          "type": "string",
          "description": "Language name from /api/premium/catalog (e.g. Spanish)"
         },
         "consent": {
          "type": "boolean"
         },
         "dry_run": {
          "type": "boolean"
         },
         "quality": {
          "type": "string",
          "enum": [
           "speed",
           "precision"
          ],
          "default": "speed"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Premium job {id, kind: avatar|translate, status: queued|running|done|failed, credits, duration_s, avatar_id, voice_id, language, clip_id, explainer_id, consent_at, error, created_at, url (when done)}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "402": {
      "$ref": "#/components/responses/PaymentRequired"
     },
     "429": {
      "description": "2 jobs running, or the 24 h cap (30 min avatar / 60 min dubbing) reached, or HeyGen unavailable"
     }
    }
   }
  },
  "/api/premium/jobs": {
   "get": {
    "tags": [
     "premium"
    ],
    "operationId": "listPremiumJobs",
    "summary": "Last 50 premium jobs",
    "responses": {
     "200": {
      "description": "Jobs",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/premium/jobs/{id}": {
   "get": {
    "tags": [
     "premium"
    ],
    "operationId": "getPremiumJob",
    "summary": "Premium job {id, kind: avatar|translate, status: queued|running|done|failed, credits, duration_s, avatar_id, voice_id, language, clip_id, explainer_id, consent_at, error, created_at, url (when done)}",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Job",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/premium/checkout": {
   "post": {
    "tags": [
     "premium"
    ],
    "operationId": "buyPremiumCredits",
    "summary": "Stripe Checkout URL for a premium credit pack (one-time payment)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "credits"
        ],
        "properties": {
         "credits": {
          "type": "integer",
          "enum": [
           300,
           1000,
           3000
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "$ref": "#/components/responses/Url"
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "402": {
      "$ref": "#/components/responses/PaymentRequired"
     }
    }
   }
  },
  "/api/premium/status": {
   "get": {
    "tags": [
     "premium"
    ],
    "operationId": "getPremiumStatus",
    "summary": "{enabled}: premium (HeyGen) features open to this account; false = hidden (other /api/premium routes answer 404)",
    "responses": {
     "200": {
      "description": "Status",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "enabled": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/clips/{id}/tics": {
   "post": {
    "tags": [
     "smart"
    ],
    "operationId": "removeFillerWords",
    "summary": "Find ums, hesitations, repeats, false starts and dead air",
    "description": "Verbatim word-level transcription of the clip (ElevenLabs Scribe; falls back to the video's Whisper transcript, then audio energy only) + adaptive audio energy. Untranscribed voiced gaps = hesitations; repeated 1-3 word n-grams lose their first copy (last take kept); truncated words (`pro-`) and prefixes of the next word = false starts; tics (`bah,` `du coup,` `you know,`) only in `aggressive` and only when set off by a comma. Cuts never fall inside a kept word (≥ 40 ms margin) and leave a breath of 0.35 / 0.22 / 0.12 s (light / normal / aggressive). Gaps cut: ≥ 0.8 / 0.45 / 0.3 s. `apply=true` replaces the previous automatic cuts (those with `kind`) of the orientation's edit; manual cuts are kept.",
    "parameters": [
     {
      "$ref": "#/components/parameters/ClipId"
     },
     {
      "name": "intensity",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "light",
        "normal",
        "aggressive"
       ],
       "default": "normal"
      }
     },
     {
      "name": "apply",
      "in": "query",
      "schema": {
       "type": "boolean",
       "default": false
      }
     },
     {
      "name": "orientation",
      "in": "query",
      "schema": {
       "type": "string",
       "default": "vertical"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Proposed cuts",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "cuts",
          "source",
          "removed_s",
          "duration_s",
          "applied"
         ],
         "properties": {
          "cuts": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "start": {
              "type": "number"
             },
             "end": {
              "type": "number"
             },
             "kind": {
              "type": "string"
             },
             "confidence": {
              "type": "number"
             },
             "text": {
              "type": "string"
             }
            }
           }
          },
          "source": {
           "type": "string",
           "enum": [
            "scribe",
            "transcript",
            "energy"
           ]
          },
          "removed_s": {
           "type": "number"
          },
          "duration_s": {
           "type": "number"
          },
          "applied": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Bad intensity or orientation"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/videos/{id}/objective": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string"
     }
    }
   ],
   "get": {
    "tags": [
     "videos"
    ],
    "operationId": "getVideoObjective",
    "summary": "Clip objective of a video",
    "responses": {
     "200": {
      "description": "Objective",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Objective"
        }
       }
      }
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   },
   "put": {
    "tags": [
     "videos"
    ],
    "operationId": "setVideoObjective",
    "summary": "Set (replace) the clip objective of a video",
    "description": "goal (views | clients | comments | followers), brief (≤ 1000 chars), resources (≤ 5: the offer's site, other videos of yours for B-roll). Absent field = cleared. Used by the next detection: the goal's playbook (rules measured on top shorts), the brief, the site as read and the admin-validated learned rules are added to the detection prompt; each clip gets `goal_fit` and a caption `cta`. Call `regenerateVideo` to re-detect now (transcript reused).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/ObjectiveInput"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Objective",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Objective"
        }
       }
      }
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     }
    }
   }
  },
  "/api/admin/learning/rules": {
   "get": {
    "tags": [
     "admin"
    ],
    "operationId": "listLearnedRules",
    "summary": "Learned rules (admin)",
    "description": "Rules aggregated daily from client corrections (clip_feedback: title, bounds, cuts, subtitles, deleted, exported, published, broll_accepted/rejected), per niche and goal, only above 5 cases on ≥ 2 videos. Pending first. Only `approved` rules enter the detection prompt.",
    "parameters": [
     {
      "name": "status",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "pending",
        "approved",
        "rejected"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "{rules:[{id,key,goal,niche,rule,summary,evidence,videos,status,updated_at}], min_cases, min_videos}"
     },
     "403": {
      "description": "Not admin"
     }
    }
   }
  },
  "/api/admin/learning/rules/{id}": {
   "patch": {
    "tags": [
     "admin"
    ],
    "operationId": "patchLearnedRule",
    "summary": "Approve / reject / edit a learned rule (admin)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "status"
        ],
        "additionalProperties": false,
        "properties": {
         "status": {
          "type": "string",
          "enum": [
           "approved",
           "rejected",
           "pending"
          ]
         },
         "rule": {
          "type": "string",
          "maxLength": 300,
          "description": "Edited prompt text (English)"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{rules}"
     },
     "400": {
      "$ref": "#/components/responses/BadRequest"
     },
     "403": {
      "description": "Not admin"
     },
     "404": {
      "description": "Unknown rule"
     }
    }
   }
  },
  "/api/admin/learning/aggregate": {
   "post": {
    "tags": [
     "admin"
    ],
    "operationId": "aggregateLearning",
    "summary": "Run the aggregation now (admin; otherwise daily)",
    "responses": {
     "200": {
      "description": "{found, rules}"
     },
     "403": {
      "description": "Not admin"
     }
    }
   }
  },
  "/api/admin/learning/feedback": {
   "get": {
    "tags": [
     "admin"
    ],
    "operationId": "learningFeedbackStats",
    "summary": "Client signals by kind, niche and goal (admin)",
    "parameters": [
     {
      "name": "days",
      "in": "query",
      "schema": {
       "type": "integer",
       "default": 30
      }
     }
    ],
    "responses": {
     "200": {
      "description": "[{kind, niche, goal, count}]"
     },
     "403": {
      "description": "Not admin"
     }
    }
   }
  },
  "/api/admin/learning/playbook": {
   "get": {
    "tags": [
     "admin"
    ],
    "operationId": "learningPlaybook",
    "summary": "Exact objective text injected in the detection prompt (admin)",
    "parameters": [
     {
      "name": "goal",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "views",
        "clients",
        "comments",
        "followers"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "{version, goal, learned, chars, tokens_est, text}"
     },
     "403": {
      "description": "Not admin"
     }
    }
   }
  },
  "/api/live/clock": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "liveClock",
    "summary": "Horloge serveur",
    "description": "{server_ms} pour calculer le décalage d'horloge (horloge commune). Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": []
   }
  },
  "/api/live/sessions": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "listLiveSessions",
    "summary": "Sessions live du compte",
    "description": "50 dernières + quota {minutes_used, minutes_limit}. Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   },
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "createLiveSession",
    "summary": "Créer une session live",
    "description": "Renvoie {id, status:ready, invite_url, invite_token}. Une session ouverte à la fois. Spec complète : docs/live-api.md.",
    "responses": {
     "201": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "title": {
          "type": "string",
          "maxLength": 120
         },
         "max_guests": {
          "type": "integer",
          "minimum": 1,
          "maximum": 3
         },
         "format": {
          "type": "string",
          "enum": [
           "16:9",
           "9:16"
          ],
          "default": "16:9"
         },
         "goal": {
          "type": "string",
          "enum": [
           "views",
           "clients",
           "comments",
           "followers"
          ]
         }
        },
        "required": []
       }
      }
     }
    }
   }
  },
  "/api/live/sessions/{id}": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "getLiveSession",
    "summary": "Détail d'une session",
    "description": "Session (format, goal), participants (peer_id ; sources : live et, pour l'hôte, stream_key / whip_url / rtmp_url / rtmp_key / srt_url / whep_url), pistes, 50 derniers marqueurs. Hôte ou participant (lvp_). Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/sessions/{id}/invite/rotate": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "rotateLiveInvite",
    "summary": "Nouveau lien d'invitation",
    "description": "L'ancien lien cesse de fonctionner. Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/invite/{id}": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "liveInviteInfo",
    "summary": "Infos du lien d'invitation",
    "description": "Public avec ?t=<invite_token> : titre, nom de l'hôte, places. Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": []
   }
  },
  "/api/live/sessions/{id}/join": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "joinLiveSession",
    "summary": "Rejoindre (hôte, invité, reconnexion)",
    "description": "Invité : {invite_token, name, consent:true}. Renvoie participant, secret lvp_ (une fois), sfu {url, room_id, token, peer_id, ice_servers}. Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "invite_token": {
          "type": "string"
         },
         "name": {
          "type": "string",
          "maxLength": 60
         },
         "consent": {
          "type": "boolean"
         },
         "clock_offset_ms": {
          "type": "integer"
         }
        },
        "required": []
       }
      }
     }
    }
   }
  },
  "/api/live/sessions/{id}/start": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "startLiveSession",
    "summary": "Lancer le direct",
    "description": "ready → live ; fixe started_at_ms (origine de l'horloge commune). Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/sessions/{id}/end": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "endLiveSession",
    "summary": "Terminer",
    "description": "live → ended ; réassemble les pistes, écrit le manifeste post-live. Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/sessions/{id}/leave": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "leaveLiveSession",
    "summary": "Quitter",
    "description": " Spec complète : docs/live-api.md. Auth : `Authorization: Bearer lvp_…` (secret de participant rendu par join).",
    "responses": {
     "204": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": []
   }
  },
  "/api/live/sessions/{id}/events": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "liveEvents",
    "summary": "Flux SSE temps réel",
    "description": "snapshot, participant_joined (peer_id), participant_left, session_started, session_ended, marker, track, source. ?pt=lvp_… pour EventSource. Spec complète : docs/live-api.md. Auth : `Authorization: Bearer lvp_…` (secret de participant rendu par join).",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": []
   },
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "postLiveEvent",
    "summary": "Marqueur / décision de régie",
    "description": "kind moment|scene|speaker|title|note, t_ms (horloge commune), label, data. Spec complète : docs/live-api.md. Auth : `Authorization: Bearer lvp_…` (secret de participant rendu par join).",
    "responses": {
     "201": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "kind": {
          "type": "string",
          "enum": [
           "moment",
           "scene",
           "speaker",
           "title",
           "note"
          ]
         },
         "t_ms": {
          "type": "integer"
         },
         "label": {
          "type": "string",
          "maxLength": 200
         },
         "data": {
          "type": "object"
         }
        },
        "required": [
         "kind"
        ]
       }
      }
     }
    }
   }
  },
  "/api/live/sessions/{id}/tracks": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "createLiveTrack",
    "summary": "Ouvrir une piste d'enregistrement",
    "description": "kind camera|screen|program, mime, t0_ms → {track_id}. Spec complète : docs/live-api.md. Auth : `Authorization: Bearer lvp_…` (secret de participant rendu par join).",
    "responses": {
     "201": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "kind": {
          "type": "string",
          "enum": [
           "camera",
           "screen",
           "program"
          ]
         },
         "mime": {
          "type": "string"
         },
         "t0_ms": {
          "type": "integer"
         }
        },
        "required": [
         "kind",
         "mime",
         "t0_ms"
        ]
       }
      }
     }
    }
   }
  },
  "/api/live/sessions/{id}/tracks/{track}/chunks/{n}": {
   "put": {
    "tags": [
     "live"
    ],
    "operationId": "putLiveChunk",
    "summary": "Téléverser un morceau",
    "description": "Corps brut (≤ 50 Mo), n = 0,1,2… ; hors ordre et renvoi acceptés. Spec complète : docs/live-api.md. Auth : `Authorization: Bearer lvp_…` (secret de participant rendu par join).",
    "responses": {
     "204": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": []
   }
  },
  "/api/live/sessions/{id}/tracks/{track}/complete": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "completeLiveTrack",
    "summary": "Clore une piste",
    "description": "{chunks:N} → {status: complete|incomplete, missing, bytes}. Spec complète : docs/live-api.md. Auth : `Authorization: Bearer lvp_…` (secret de participant rendu par join).",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "chunks": {
          "type": "integer"
         }
        },
        "required": [
         "chunks"
        ]
       }
      }
     }
    }
   }
  },
  "/api/live/sessions/{id}/sources": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "createLiveSource",
    "summary": "Source entrante (OBS, ATEM, RTMP/SRT/WHIP)",
    "description": "Renvoie stream_key, whip_url, rtmp_url + rtmp_key, srt_url, whep_url. Spec complète : docs/live-api.md.",
    "responses": {
     "201": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "name": {
          "type": "string",
          "maxLength": 60
         },
         "kind": {
          "type": "string",
          "enum": [
           "camera",
           "program"
          ]
         }
        },
        "required": [
         "name"
        ]
       }
      }
     }
    }
   }
  },
  "/api/live/sessions/{id}/sources/{pid}": {
   "delete": {
    "tags": [
     "live"
    ],
    "operationId": "deleteLiveSource",
    "summary": "Couper une source",
    "description": "La clé de stream est révoquée. Spec complète : docs/live-api.md.",
    "responses": {
     "204": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/sessions/{id}/manifest": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "liveManifest",
    "summary": "Manifeste post-live",
    "description": "Participants, pistes (t0_ms, path), marqueurs sur l'horloge commune. Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/sessions/{id}/episode": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "liveEpisode",
    "summary": "Épisode après le direct",
    "description": "Assemblage en tâche de fond (lancé à la fin) : {status: queued|running|ready|failed, progress 0–100, error, summary {duration_s, fallback_s, black_s}, episode_url, tracks [{id, kind, name, bytes, url}], markers, video_id}. Spec : docs/live-api.md § 10.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   },
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "liveEpisodeRebuild",
    "summary": "Relancer l'assemblage",
    "description": "Session terminée ; 409 si un assemblage est déjà en cours. Renvoie le même objet que GET.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/sessions/{id}/episode/send": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "liveEpisodeSend",
    "summary": "Envoyer à Smart Clipper",
    "description": "Crée une vidéo source depuis l'épisode prêt et lance l'analyse comme un import (mêmes quotas : 402) → {video_id, url, markers}. Les repères deviennent des clips proposés en premier (clip.live_marker). 409 tant que l'épisode n'est pas prêt ; idempotent.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/sessions/{id}/files/{name}": {
   "get": {
    "tags": [
     "live"
    ],
    "operationId": "liveFile",
    "summary": "Télécharger l'épisode ou une piste",
    "description": "name = episode (programme assemblé, MP4 16:9 ou 9:16) ou l'id d'une piste (enregistrement d'un participant). Content-Disposition: attachment, Range.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/live/ingest/auth": {
   "post": {
    "tags": [
     "live"
    ],
    "operationId": "liveIngestAuth",
    "summary": "Autorisation MediaMTX",
    "description": "Appelé par MediaMTX (authMethod http) : 200 si path = live/<clé active>. Spec complète : docs/live-api.md.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "401": {
      "description": "Non authentifié"
     },
     "402": {
      "description": "Plan gratuit ou minutes épuisées"
     },
     "403": {
      "description": "Rôle insuffisant"
     },
     "404": {
      "description": "Session inconnue ou Live fermé (live_disabled)"
     },
     "409": {
      "description": "État incompatible, session pleine ou terminée"
     }
    },
    "security": []
   }
  },
  "/api/live/captions": {
   "get": {
    "summary": "Live captions (WebSocket)",
    "description": "WebSocket upgrade. Send binary PCM s16le mono 16 kHz frames (0.1 to 1 s); receive JSON messages ready, partial {text}, final {text, lang, words:[{w,start,end,speaker?}]}, error, done {seconds, cost_usd}. Send {\"type\":\"stop\"} to flush and close. Live host only (paid plans when Live is open, admins always). Provider: ElevenLabs Scribe v2 Realtime.",
    "tags": [
     "live"
    ],
    "parameters": [
     {
      "name": "lang",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "ISO 639-1 language code; omitted = auto-detect"
     }
    ],
    "responses": {
     "101": {
      "description": "Switching protocols"
     },
     "402": {
      "description": "Paid plan required"
     },
     "404": {
      "description": "live_disabled or live_captions_disabled"
     }
    }
   }
  },
  "/api/partners/campaigns": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "listPartnerCampaigns",
    "summary": "Campagnes de clipping",
    "description": "?scope=open|mine|joined (défaut open).",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   },
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "createPartnerCampaign",
    "summary": "Créer une campagne (brouillon)",
    "description": "{title, video_ids, cpm_cents, budget_cents, min_views?, max_per_clip_cents?, max_per_clipper_cents?, max_submissions?, platforms?, rules?, goal?, brief?, ends_at?, currency? eur|usd (défaut eur), license_accepted:true}.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "getPartnerCampaign",
    "summary": "Détail d'une campagne",
    "description": "Sources, participation, copies, soumissions.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/status": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "setPartnerCampaignStatus",
    "summary": "Transition de campagne",
    "description": "{status: open|paused|ended|closed} ; open = publier (budget encaissé, simulé).",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "409": {
      "description": "Transition impossible ou doublon (duplicate)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/join": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "joinPartnerCampaign",
    "summary": "Rejoindre (1 clic)",
    "description": "Copie la source dans le Smart Clipper du clipper (hors quota) et lance la détection. Idempotent.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "409": {
      "description": "Transition impossible ou doublon (duplicate)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/submissions": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "submitPartnerPost",
    "summary": "Soumettre un post publié",
    "description": "{clip_id, url}. 409 duplicate si le post est déjà soumis.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "409": {
      "description": "Transition impossible ou doublon (duplicate)"
     },
     "429": {
      "description": "Trop de soumissions"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/refresh": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "refreshPartnerCampaign",
    "summary": "Relever les vues de la campagne",
    "description": "Toutes les soumissions actives.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "409": {
      "description": "Transition impossible ou doublon (duplicate)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/payouts": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "payPartnerCampaign",
    "summary": "Verser les gains dus",
    "description": "Implémentation simulée : aucun argent ne bouge.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "409": {
      "description": "Transition impossible ou doublon (duplicate)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/earnings": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "getPartnerEarnings",
    "summary": "Tableau des gains",
    "description": "Par clipper : estimé, validé, versé, dû (centimes USD).",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/submissions/{id}/review": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "reviewPartnerSubmission",
    "summary": "Valider / refuser / signaler",
    "description": "{status: approved|rejected|flagged, reason?} ; motif obligatoire hors validation.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "409": {
      "description": "Transition impossible ou doublon (duplicate)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/submissions/{id}/refresh": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "refreshPartnerSubmission",
    "summary": "Relever les vues d'un post",
    "description": "{views?} : relevé API immédiat + contrôles (YouTube, TikTok, Instagram : partners::views) ; views = relevé manuel (admin), affiché mais jamais payé.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "409": {
      "description": "Transition impossible ou doublon (duplicate)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/agent/tools": {
   "get": {
    "tags": [
     "agent-tools"
    ],
    "summary": "Catalogue of creation tools",
    "description": "Each tool: name, description, input_schema (JSON Schema, additionalProperties false), output. Tools: import_video, detect_moments, cut_silences_tics, reframe, illustrate_with_source, add_hook, style_captions, edit_clip, render_clip, make_loop, add_music_beat, check_dark_shots, brighten, critique_clip, predict_clip, improve_clip, produce_explainer, partners_join, publish_tiktok.",
    "responses": {
     "200": {
      "description": "{tools:[…], plan:{…}}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "description": "Invalid argument (message names the field)"
     },
     "403": {
      "description": "Paid plan required"
     }
    }
   }
  },
  "/api/agent/tools/{name}": {
   "post": {
    "tags": [
     "agent-tools"
    ],
    "summary": "Run one creation tool",
    "parameters": [
     {
      "name": "name",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "type": "object"
       }
      }
     },
     "description": "The tool's arguments (see its input_schema)"
    },
    "responses": {
     "200": {
      "description": "The tool's JSON output",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "description": "Invalid argument (message names the field)"
     },
     "403": {
      "description": "Paid plan required"
     }
    }
   }
  },
  "/api/agent/plan": {
   "post": {
    "tags": [
     "agent-tools"
    ],
    "summary": "Run an execution plan server-side",
    "description": "{steps:[{id?, tool, args}], vars?} or {template, vars}. Validated before execution; args may reference earlier outputs ($prev.<path>, $<id>.<path>, $vars.<name>). Max 30 steps, 2 running plans per account (429).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "steps": {
          "type": "array",
          "maxItems": 30,
          "items": {
           "type": "object",
           "required": [
            "tool"
           ],
           "properties": {
            "id": {
             "type": "string"
            },
            "tool": {
             "type": "string"
            },
            "args": {
             "type": "object"
            }
           }
          }
         },
         "vars": {
          "type": "object"
         },
         "template": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{plan_id, status: running, poll}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "description": "Invalid argument (message names the field)"
     },
     "403": {
      "description": "Paid plan required"
     },
     "429": {
      "description": "2 plans already running"
     }
    }
   }
  },
  "/api/agent/plan/{id}": {
   "get": {
    "tags": [
     "agent-tools"
    ],
    "summary": "State of an execution plan",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "{plan_id, status running|done|failed, error, steps:[{index, id, tool, args, ok, output|error, ms}], output}",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "404": {
      "description": "Unknown plan"
     }
    }
   }
  },
  "/api/agent/plan-templates": {
   "get": {
    "tags": [
     "agent-tools"
    ],
    "summary": "Ready-made plans",
    "description": "{templates:[{name, title, description, params (JSON Schema), steps}]}: clip_long_video_premium, import_and_clip, critique_and_improve, analyze_competitor_video, saas_ad_from_url, partners_campaign_clip.",
    "responses": {
     "200": {
      "description": "Templates",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/partners/accounts": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "listPartnerAccounts",
    "summary": "Comptes reliés (vues vérifiées)",
    "description": "Comptes YouTube / Instagram pro reliés en OAuth + compte TikTok de ClipOnIt ; 3 max par plateforme. → {max_per_platform, platforms{youtube,instagram,tiktok:{available,connect_url}}, accounts[{id,platform,external_id,name}]}.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation ou plateforme non configurée (accounts_unavailable)"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/accounts/{id}": {
   "delete": {
    "tags": [
     "partners"
    ],
    "operationId": "disconnectPartnerAccount",
    "summary": "Délier un compte",
    "description": "id = pa_… ; jeton Google révoqué. TikTok : DELETE /api/tiktok/account.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation ou plateforme non configurée (accounts_unavailable)"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/accounts/{id}/connect": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "connectPartnerAccount",
    "summary": "Relier un compte (navigateur)",
    "description": "id = youtube | instagram | tiktok. Redirige vers l'autorisation OAuth (session navigateur requise). 429 au-delà de 3 comptes.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation ou plateforme non configurée (accounts_unavailable)"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "302": {
      "description": "Redirection OAuth"
     },
     "429": {
      "description": "3 comptes au plus"
     }
    },
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/accounts/{id}/callback": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "partnerAccountCallback",
    "summary": "Retour OAuth",
    "description": "Relie le(s) compte(s) puis redirige vers /partners?accounts=ok|error&reason=….",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation ou plateforme non configurée (accounts_unavailable)"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     },
     "302": {
      "description": "Retour vers /partners"
     }
    },
    "security": [
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/code": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "partnerCaptionCode",
    "summary": "Code de campagne à mettre en légende",
    "description": "→ {code: \"#cliponit-xxxxxx\"} propre à (campagne, clipper) ; vérifié dans la légende par l'API de la plateforme. Campagne rejointe requise.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation ou plateforme non configurée (accounts_unavailable)"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/submissions/{id}/views": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "partnerSubmissionViews",
    "summary": "Vues vérifiées d'une soumission",
    "description": "→ {checks{owner,code,render,disclosure}, problem, published_at, window_ends_at (J+7), removed_at, latest, verified_views, payable_views, snapshots[]}. Seules les vues API ≤ J+7 d'un post vérifié sont payées ; relevé manuel jamais payé.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation ou plateforme non configurée (accounts_unavailable)"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/dashboard": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "getPartnerDashboard",
    "summary": "Tableau de bord marque",
    "description": "Dépense vs budget, vues relevées (courbe journalière), CPM effectif, clippers/clips/soumissions, taux d'approbation, délai moyen de revue, top clips (aperçu), répartition et CPM par plateforme (plancher, plafond, historique), alertes fraude. Créateur ou admin.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/review-queue": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "getPartnerReviewQueue",
    "summary": "File de revue",
    "description": "Soumissions (à revoir d'abord) avec aperçu du clip, contrôle du brief, signaux de fraude, échéance de validation automatique (J+7), motifs disponibles.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/export.csv": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "exportPartnerCampaignCsv",
    "summary": "Export CSV",
    "description": "Une ligne par soumission : vues, statut, motif, gains validés/estimés.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/campaigns/{id}/cpm": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "changePartnerCpm",
    "summary": "Changer le CPM d'une plateforme",
    "description": "Historisé ; une baisse ne touche pas les soumissions déjà faites. Campagne open/paused.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "platform": {
          "type": "string"
         },
         "cpm_cents": {
          "type": "integer"
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/partners/submissions/{id}/decision": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "decidePartnerSubmission",
    "summary": "Décision de revue",
    "description": "action approve | reject | correction ; reason_code obligatoire hors validation (liste de la file de revue), note facultative (obligatoire pour other). Correction : reste pending, J+7 suspendu.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "action": {
          "type": "string"
         },
         "reason_code": {
          "type": "string"
         },
         "note": {
          "type": "string"
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/partners/submissions/{id}/corrected": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "markPartnerSubmissionCorrected",
    "summary": "Correction faite (clipper)",
    "description": "Le délai de revue J+7 repart.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/partners/notifications": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "listPartnerNotifications",
    "summary": "Notifications Partenariats",
    "description": "50 dernières, compteur unread.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/notifications/read": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "readPartnerNotifications",
    "summary": "Tout marquer lu",
    "description": "",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Inconnu ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/connect/onboarding": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "partnersConnectOnboarding",
    "summary": "Compte de versement du clipper",
    "description": "Crée le compte Stripe Connect Express (mode test, si PARTNERS_STRIPE_KEY=sk_test_…) et renvoie le lien d'onboarding hébergé (KYC chez Stripe). Simulé sinon : compte sim_… actif, url null. Pays : EEE, GB, US, CA, CH.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "country"
        ],
        "properties": {
         "country": {
          "type": "string",
          "description": "code ISO à 2 lettres"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     },
     "402": {
      "description": "Erreur Stripe"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/tax-profile": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "getPartnersTaxProfile",
    "summary": "Profil fiscal DAC7",
    "description": "Données vendeur DAC7 du clipper (NIF et IBAN masqués). {complete:false} si absent : les versements sont retenus.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   },
   "put": {
    "tags": [
     "partners"
    ],
    "operationId": "putPartnersTaxProfile",
    "summary": "Enregistrer le profil fiscal DAC7",
    "description": "Nom légal, adresse, date de naissance, NIF + pays, IBAN, n° TVA. NIF et IBAN chiffrés (AES-GCM). Exigé avant le premier versement.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "legal_name",
         "address",
         "birth_date",
         "tin",
         "tin_country"
        ],
        "properties": {
         "legal_name": {
          "type": "string"
         },
         "address": {
          "type": "string"
         },
         "birth_date": {
          "type": "string",
          "format": "date"
         },
         "tin": {
          "type": "string"
         },
         "tin_country": {
          "type": "string"
         },
         "iban": {
          "type": "string"
         },
         "vat_number": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/admin/dac7": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "exportPartnersDac7",
    "summary": "Export DAC7 annuel (admin)",
    "description": "CSV par clipper et devise : identité, NIF, IBAN, montants validés, nombre d'opérations et frais retenus par trimestre.",
    "parameters": [
     {
      "name": "year",
      "in": "query",
      "required": true,
      "schema": {
       "type": "integer"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/admin/payouts/run": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "runPartnersPayouts",
    "summary": "Versements mensuels (admin)",
    "description": "Un versement par clipper, par mois et par devise, dès 20 (centimes 2000), frais de virement 2 déduits ; retenus si clipper gelé (anti-fraude), compte de versement absent ou incomplet, profil DAC7 manquant. Idempotent (clé payout:<clipper>:<période>:<devise>).",
    "requestBody": {
     "required": false,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "period": {
          "type": "string",
          "example": "2026-11"
         },
         "currency": {
          "type": "string",
          "enum": [
           "eur",
           "usd"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   }
  },
  "/api/partners/stripe/webhook": {
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "partnersStripeWebhook",
    "summary": "Webhook Stripe Connect",
    "description": "Signature Stripe-Signature vérifiée avec PARTNERS_STRIPE_WEBHOOK_SECRET (plusieurs secrets séparés par des virgules). checkout.session.completed (budget déposé), account.updated, transfer.reversed (contre-passation), payout.*. Chaque événement traité une fois.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    }
   }
  },
  "/api/partners/waitlist": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "partnersWaitlistStatus",
    "summary": "État d'ouverture des Partenariats (public)",
    "description": "`{open}` : false tant que PARTNERS_ENABLED est fermé ; la landing /partenaires affiche alors « Accès anticipé ».",
    "security": [],
    "responses": {
     "200": {
      "description": "Succès"
     }
    }
   },
   "post": {
    "tags": [
     "partners"
    ],
    "operationId": "partnersWaitlistJoin",
    "summary": "S'inscrire à l'accès anticipé (public)",
    "description": "Enregistre e-mail + rôle (brand | clipper) avec la version du consentement RGPD ; idempotent, même réponse si déjà inscrit ; cookie `ref` conservé. 10 requêtes / heure / IP. Aucun e-mail envoyé.",
    "security": [],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "email",
         "role",
         "consent"
        ],
        "properties": {
         "email": {
          "type": "string",
          "format": "email"
         },
         "role": {
          "type": "string",
          "enum": [
           "brand",
           "clipper"
          ]
         },
         "consent": {
          "type": "boolean",
          "description": "Doit valoir true"
         },
         "locale": {
          "type": "string",
          "enum": [
           "fr",
           "en"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "{ok:true}"
     },
     "400": {
      "description": "invalid_email, role ou consent manquant"
     },
     "429": {
      "description": "Trop de requêtes"
     }
    }
   }
  },
  "/api/partners/referrals": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "partnersReferrals",
    "summary": "Mon parrainage Partenariats",
    "description": "Liens clipper et marque (`/partenaires?ref=CODE#clippers|#marques`, code d'affiliation du compte), règles (5 % des sommes validées versées pendant 90 jours, plafond 50 $ par filleul, seulement après un clip validé, rien si banni ou signalé), filleuls (e-mail masqué, rôle, état, bonus_cents). Bonus simulé.",
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin)"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    }
   }
  },
  "/api/partners/email-prefs": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "partnersEmailPrefs",
    "summary": "Préférences d'e-mails Partenariats",
    "description": "`{niches[], weekly, available[]}`.",
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin)"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    }
   },
   "put": {
    "tags": [
     "partners"
    ],
    "operationId": "partnersSetEmailPrefs",
    "summary": "Modifier les préférences d'e-mails Partenariats",
    "description": "`niches` : opt-in des alertes « nouvelle campagne dans ta niche » (la niche d'une campagne = `rules.niche`) ; `weekly` : récap hebdo des campagnes (lundi).",
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "weekly"
        ],
        "properties": {
         "niches": {
          "type": "array",
          "items": {
           "type": "string",
           "enum": [
            "business",
            "finance",
            "tech",
            "gaming",
            "lifestyle",
            "sport",
            "humour",
            "education",
            "autre"
           ]
          }
         },
         "weekly": {
          "type": "boolean"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Succès"
     },
     "400": {
      "description": "Validation"
     },
     "403": {
      "description": "Réservé (admin)"
     },
     "404": {
      "description": "Partenariats fermés (partners_disabled)"
     }
    }
   }
  },
  "/api/admin/partners/waitlist": {
   "get": {
    "tags": [
     "admin"
    ],
    "operationId": "adminPartnersWaitlist",
    "summary": "Liste d'attente Partenariats (admin)",
    "description": "`{count, entries:[{email, role, locale, ref_code, created_at}]}`, plus récents d'abord.",
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé aux admins"
     }
    }
   }
  },
  "/api/partners/campaigns/{id}/kit": {
   "get": {
    "tags": [
     "partners"
    ],
    "operationId": "getPartnerKit",
    "summary": "Kit du clipper (brief appliqué)",
    "description": "Participant seulement : code unique, règles, format d'export, clips avec score, légende prête, check-list du brief, état export/TikTok/soumission, soumissions et gains estimés, chronomètre Rejoindre → Soumis. Soumet automatiquement les posts TikTok publiés via ClipOnIt.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "403": {
      "description": "Réservé (admin, créateur)"
     },
     "404": {
      "description": "Pas rejointe, inconnue ou Partenariats fermés (partners_disabled)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/broll/library": {
   "get": {
    "tags": [
     "enrich"
    ],
    "operationId": "listBrollLibrary",
    "summary": "Account B-roll library: reusable shots from all your videos (search by words)",
    "description": "Items {id, video_id, video_title, shot_idx, start_s, end_s, duration_s, description, tags, score 0..1, favorite, vertical_url (720x1280), horizontal_url (1280x720), thumb_url}; videos [{id, title, count, status none|running|done|failed}]; max = cap on the free plan.",
    "parameters": [
     {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "video_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "tag",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "favorite",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean"
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/broll/library/{id}": {
   "patch": {
    "tags": [
     "enrich"
    ],
    "operationId": "updateBrollLibraryItem",
    "summary": "Mark a library shot as favorite",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "favorite"
        ],
        "properties": {
         "favorite": {
          "type": "boolean"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "enrich"
    ],
    "operationId": "deleteBrollLibraryItem",
    "summary": "Delete a library shot and its files",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/videos/{id}/broll-library": {
   "post": {
    "tags": [
     "enrich"
    ],
    "operationId": "extractBrollLibrary",
    "summary": "Extract the reusable shots of a ready video into the library (automatic on paid plans)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "202": {
      "description": "Started; follow videos[].status on GET /api/broll/library"
     },
     "402": {
      "description": "Free plan library full"
     }
    }
   }
  },
  "/api/videos/{id}/shots": {
   "get": {
    "tags": [
     "enrich"
    ],
    "operationId": "getVideoShots",
    "summary": "Full visual index of a video as JSON (indexes on first call): shots with talking flag and library entry",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/clips/{id}/broll/library": {
   "post": {
    "tags": [
     "enrich"
    ],
    "operationId": "addLibraryBroll",
    "summary": "Place a library shot on a clip (muted, full frame)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "orientation",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "item_id",
         "start"
        ],
        "properties": {
         "item_id": {
          "type": "string"
         },
         "start": {
          "type": "number"
         },
         "end": {
          "type": "number"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/explainer/my-styles": {
   "get": {
    "tags": [
     "explainer"
    ],
    "summary": "Styles importés du compte",
    "responses": {
     "200": {
      "description": "{styles: [...], limit}"
     }
    }
   },
   "post": {
    "tags": [
     "explainer"
    ],
    "summary": "Importe ton style : références → style privé perso-<hex> + 3 scènes de démo",
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "required": [
         "files",
         "consent"
        ],
        "properties": {
         "files": {
          "type": "array",
          "items": {
           "type": "string",
           "format": "binary"
          },
          "description": "3 à 10 images (PNG/JPG/WebP ≤ 8 Mo), une vidéo courte (≤ 40 Mo, compte pour 3), une police TTF/OTF"
         },
         "consent": {
          "type": "string",
          "enum": [
           "true"
          ],
          "description": "j'ai les droits sur ces images"
         },
         "name": {
          "type": "string",
          "maxLength": 40
         }
        }
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "{id, name, base, engine, palette, knobs, font, description, candidates, demo, cost_usd, secs}"
     },
     "400": {
      "description": "références invalides ou consentement absent"
     },
     "402": {
      "description": "limite de styles du plan"
     },
     "403": {
      "description": "plan gratuit ou invité"
     }
    }
   }
  },
  "/api/explainer/my-styles/{id}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string"
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "summary": "Un style importé",
    "responses": {
     "200": {
      "description": "style"
     },
     "404": {
      "description": "introuvable"
     }
    }
   },
   "put": {
    "tags": [
     "explainer"
    ],
    "summary": "Régler / régénérer un style importé (démo re-rendue)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
         "name": {
          "type": "string",
          "maxLength": 40
         },
         "knobs": {
          "type": "object",
          "properties": {
           "trait": {
            "type": "number"
           },
           "grain": {
            "type": "number"
           },
           "intensite": {
            "type": "number"
           }
          }
         },
         "palette": {
          "type": "object",
          "properties": {
           "bg": {
            "type": "string"
           },
           "accent": {
            "type": "string"
           },
           "ink": {
            "type": "string"
           },
           "colors": {
            "type": "array",
            "items": {
             "type": "string"
            }
           }
          }
         },
         "font": {
          "type": "string"
         },
         "base": {
          "type": "string"
         },
         "regenerate": {
          "type": "boolean"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "style"
     }
    }
   },
   "delete": {
    "tags": [
     "explainer"
    ],
    "summary": "Supprimer un style importé",
    "responses": {
     "200": {
      "description": "{deleted}"
     }
    }
   }
  },
  "/api/explainer/my-styles/{id}/demo/{file}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string"
     }
    },
    {
     "name": "file",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "enum": [
       "0.jpg",
       "1.jpg",
       "2.jpg"
      ]
     }
    }
   ],
   "get": {
    "tags": [
     "explainer"
    ],
    "summary": "Scène de démo du style",
    "responses": {
     "200": {
      "description": "JPEG",
      "content": {
       "image/jpeg": {}
      }
     }
    }
   }
  },
  "/api/promos": {
   "get": {
    "tags": [
     "promo"
    ],
    "operationId": "listPromos",
    "summary": "Mes pubs SaaS",
    "description": "100 dernières : id, url, status, progress.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "404": {
      "description": "Inconnue ou pub SaaS fermée (promo_disabled)"
     },
     "403": {
      "description": "Réservé aux admins (promo_admin_only)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ]
   },
   "post": {
    "tags": [
     "promo"
    ],
    "operationId": "createPromo",
    "summary": "Créer une pub depuis l'URL d'un SaaS",
    "description": "Exploration headless du site public (robots.txt, GET seulement, données personnelles floutées), marque, storyboard fidèle au site (status storyboard), puis rendu si auto_render. 429 au-delà de 2 pubs en cours.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "404": {
      "description": "Inconnue ou pub SaaS fermée (promo_disabled)"
     },
     "403": {
      "description": "Réservé aux admins (promo_admin_only)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "url"
        ],
        "properties": {
         "url": {
          "type": "string"
         },
         "brief": {
          "type": "string",
          "maxLength": 2000
         },
         "lang": {
          "type": "string",
          "enum": [
           "fr",
           "en"
          ]
         },
         "voice": {
          "type": "string"
         },
         "duration_s": {
          "type": "integer",
          "minimum": 30,
          "maximum": 60
         },
         "auto_render": {
          "type": "boolean"
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/promos/{id}": {
   "get": {
    "tags": [
     "promo"
    ],
    "operationId": "getPromo",
    "summary": "État d'une pub",
    "description": "status exploring|storyboard|rendering|config_ready|done|failed, progress, site (marque, plans filmés, prix lus), storyboard (segments, offres, shorts, warnings), outputs (videos {16x9, 9x16}, planches, video_id, shorts [{clip_id, export_id, hook}], config_json), cost {usd, calls}.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "404": {
      "description": "Inconnue ou pub SaaS fermée (promo_disabled)"
     },
     "403": {
      "description": "Réservé aux admins (promo_admin_only)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   },
   "delete": {
    "tags": [
     "promo"
    ],
    "operationId": "deletePromo",
    "summary": "Supprimer une pub",
    "description": "Fichiers de travail et vidéos rendues effacés (les shorts restent dans le Smart Clipper).",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "404": {
      "description": "Inconnue ou pub SaaS fermée (promo_disabled)"
     },
     "403": {
      "description": "Réservé aux admins (promo_admin_only)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/promos/{id}/storyboard": {
   "put": {
    "tags": [
     "promo"
    ],
    "operationId": "editPromoStoryboard",
    "summary": "Modifier le storyboard",
    "description": "Revalidé : forme (3–12 segments, rôles, visuels, plans filmés existants) puis fidélité au site (warnings).",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "404": {
      "description": "Inconnue ou pub SaaS fermée (promo_disabled)"
     },
     "403": {
      "description": "Réservé aux admins (promo_admin_only)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "storyboard"
        ],
        "properties": {
         "storyboard": {
          "type": "object"
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/promos/{id}/render": {
   "post": {
    "tags": [
     "promo"
    ],
    "operationId": "renderPromo",
    "summary": "Monter la vidéo",
    "description": "Voix off, config du gabarit pub SaaS, rendu 16:9 + 9:16 (PROMO_RENDER_CMD ; sans moteur : status config_ready), puis 3–5 shorts exportés en vertical.",
    "responses": {
     "200": {
      "description": "Succès"
     },
     "404": {
      "description": "Inconnue ou pub SaaS fermée (promo_disabled)"
     },
     "403": {
      "description": "Réservé aux admins (promo_admin_only)"
     }
    },
    "security": [
     {
      "ApiKey": []
     },
     {
      "SessionCookie": []
     }
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/explainer/library/items": {
   "get": {
    "tags": [
     "explainer"
    ],
    "operationId": "listExplainerLibraryItems",
    "summary": "Browse the element library of a style (paged)",
    "description": "Elements of `style` in catalogue order (families, shared first), `limit` per page (≤ 120). `q`: engine search (meaning + words, French or English) first, then every element whose text contains all the words. Also returns real counts: `style_total`, `families` [{name, n}] for the current search, `styles` [{id, name, n}], `concepts`, `variants`, and the `animations` {anim, loop, exit} available in the style. Guest accounts: 403.",
    "parameters": [
     {
      "name": "style",
      "in": "query",
      "schema": {
       "type": "string",
       "default": "pixel-news"
      }
     },
     {
      "name": "family",
      "in": "query",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "q",
      "in": "query",
      "schema": {
       "type": "string",
       "maxLength": 200
      }
     },
     {
      "name": "offset",
      "in": "query",
      "schema": {
       "type": "integer",
       "minimum": 0,
       "default": 0
      }
     },
     {
      "name": "limit",
      "in": "query",
      "schema": {
       "type": "integer",
       "minimum": 1,
       "maximum": 120,
       "default": 60
      }
     }
    ],
    "responses": {
     "200": {
      "description": "`{style, total, offset, items: [{el, concept, family, kind, preview, description, tags, path, styles}], style_total, families, styles, concepts, variants, animations}`"
     },
     "400": {
      "description": "Unknown style"
     }
    }
   }
  },
  "/api/media": {
   "get": {
    "summary": "Médias téléversés du compte",
    "tags": [
     "Media"
    ],
    "responses": {
     "200": {
      "description": "OK"
     }
    }
   },
   "post": {
    "summary": "Téléverser une vidéo ou une image (200 Mo, validée par ffprobe, normalisée en MP4)",
    "tags": [
     "Media"
    ],
    "requestBody": {
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "properties": {
         "file": {
          "type": "string",
          "format": "binary"
         }
        },
        "required": [
         "file"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK"
     }
    }
   }
  },
  "/api/media/{id}": {
   "delete": {
    "summary": "Supprimer un média téléversé",
    "tags": [
     "Media"
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "OK"
     }
    }
   }
  },
  "/api/media/clips": {
   "get": {
    "summary": "Clips du compte à insérer (200 derniers)",
    "tags": [
     "Media"
    ],
    "parameters": [
     {
      "name": "q",
      "in": "query",
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "OK"
     }
    }
   }
  },
  "/api/clips/{id}/broll/media": {
   "post": {
    "summary": "Poser un média du compte (B-roll, clip, téléversement) dans le clip, avec sa façon (plein écran, incrusté, écran partagé) et son son",
    "tags": [
     "Media"
    ],
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "orientation",
      "in": "query",
      "schema": {
       "type": "string"
      }
     }
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "kind",
         "id",
         "start",
         "end"
        ],
        "properties": {
         "kind": {
          "type": "string",
          "enum": [
           "broll",
           "clip",
           "upload"
          ]
         },
         "id": {
          "type": "string"
         },
         "start": {
          "type": "number"
         },
         "end": {
          "type": "number"
         },
         "mode": {
          "type": "string",
          "enum": [
           "full",
           "pip",
           "split"
          ],
          "default": "full"
         },
         "audio": {
          "type": "boolean",
          "default": false
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "OK"
     }
    }
   }
  },
  "/api/montage": {
   "get": {
    "tags": [
     "clips"
    ],
    "operationId": "montageStatus",
    "summary": "Montage pro: open to this account? and its recipes",
    "description": "`{enabled, recipes:[{id, cold_open, loop, moment, intermede, title, tics}]}`; recipes are empty while the feature is closed to the account (admins first, then everyone on paid plans when MONTAGE_PRO_ENABLED=true).",
    "responses": {
     "200": {
      "description": "Status",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/clips/{id}/montage/plan": {
   "post": {
    "tags": [
     "clips"
    ],
    "operationId": "montagePlan",
    "summary": "Montage pro: default edit plan and its suggestions for a recipe",
    "description": "Paid plans, while the feature is open to the account (else 404/403). Returns {recipe, loop, split, roulage, tics, handle, cold_open{on,choice,options[{text,start_s,end_s,score,thumb}]}, title{on,text}, moment{on,choice,options[{source_s,duration_s,label,thumb}]}, intermede{…}, cards[{on,at_s,span_s,quote,card{kind:number|table|transfer|quote,…}}]} (absolute source seconds). Every card number is checked against the sentence it comes from. Edit it, then POST /api/clips/{id}/export?montage=<recipe> with body {plan}. ~15-20 s.",
    "parameters": [
     {
      "$ref": "#/components/parameters/ClipId"
     },
     {
      "name": "recipe",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "enum": [
        "entrepreneur",
        "podcasteur",
        "formateur",
        "ecole",
        "marque"
       ]
      }
     },
     {
      "name": "handle",
      "in": "query",
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Plan",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/explainer/audio/{id}/fit": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "format": "uuid"
     }
    }
   ],
   "post": {
    "tags": [
     "explainer"
    ],
    "operationId": "fitExplainerAudio",
    "summary": "Change the audio: fit a new imported audio (status done) onto the existing scenes, visuals kept",
    "description": "Same text (≥ 60 % of the old words found): each scene starts at its first word found; other text: cut in proportion to the old scenes, on a nearby punctuation. No AI call, no cost. Put the returned `id` and `segments` in `script.audio`, and `says[i]` in `scenes[i].say` when the sentence changed.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "says"
        ],
        "properties": {
         "says": {
          "type": "array",
          "items": {
           "type": "string"
          },
          "minItems": 1,
          "maxItems": 200,
          "description": "current text of each scene"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Fitted",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string"
          },
          "duration": {
           "type": "number"
          },
          "matched": {
           "type": "number",
           "description": "share of old words found in the new audio"
          },
          "segments": {
           "type": "array",
           "items": {
            "type": "array",
            "items": {
             "type": "number"
            }
           }
          },
          "says": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Audio not ready, no speech (`no_speech: …`) or too short for the scenes"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     }
    }
   }
  },
  "/api/montages": {
   "get": {
    "tags": [
     "montage"
    ],
    "operationId": "listMontages",
    "summary": "My montages (50 latest)",
    "description": "",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "description": "Free plan: Montage needs Starter, or monthly video quota reached"
     }
    }
   },
   "post": {
    "tags": [
     "montage"
    ],
    "operationId": "createMontage",
    "summary": "Edit rushes on the structure of a reference or a house format",
    "description": "Job: structure (reference analysed or reused from the shared recipe library, house template, or recipe) → rushes → plan → 9:16 master → regular export (captions, logo, music). Paid plans; counts 1 video of the monthly quota.",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "description": "Free plan: Montage needs Starter, or monthly video quota reached"
     }
    },
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "media_ids",
         "rights"
        ],
        "properties": {
         "media_ids": {
          "type": "array",
          "items": {
           "type": "string"
          },
          "maxItems": 12
         },
         "intent": {
          "type": "string",
          "maxLength": 1000
         },
         "reference_url": {
          "type": "string"
         },
         "template_id": {
          "type": "string"
         },
         "recipe": {
          "type": "object",
          "description": "GET /api/montage/schema"
         },
         "analyze_only": {
          "type": "boolean"
         },
         "music": {
          "type": "string",
          "enum": [
           "auto",
           "pulse",
           "chill",
           "drive",
           "none"
          ]
         },
         "rights": {
          "type": "boolean",
          "description": "Only rushes the user holds the rights to"
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/montages/{id}": {
   "get": {
    "tags": [
     "montage"
    ],
    "operationId": "getMontage",
    "summary": "Montage state",
    "description": "`{status queued|running|rendering|done|failed, stage, recipe, plan, report, url, edit_url, export_id}`",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "description": "Free plan: Montage needs Starter, or monthly video quota reached"
     }
    },
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/montage/templates": {
   "get": {
    "tags": [
     "montage"
    ],
    "operationId": "montageTemplates",
    "summary": "House viral formats (public)",
    "description": "`{templates:[{id, name{fr,en}, desc{fr,en}, recipe, preview_url}]}`",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "description": "Free plan: Montage needs Starter, or monthly video quota reached"
     }
    }
   }
  },
  "/api/montage/schema": {
   "get": {
    "tags": [
     "montage"
    ],
    "operationId": "montageSchema",
    "summary": "JSON Schema of an editing recipe",
    "description": "Same schema for analysed references and house formats.",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "description": "Free plan: Montage needs Starter, or monthly video quota reached"
     }
    }
   }
  },
  "/api/montage/search": {
   "get": {
    "tags": [
     "montage"
    ],
    "operationId": "montageSearch",
    "summary": "Find public reference shorts",
    "description": "YouTube Shorts via ytsearch: titles, thumbnails, links to originals; nothing downloaded. No AI cost.",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "description": "Free plan: Montage needs Starter, or monthly video quota reached"
     }
    },
    "parameters": [
     {
      "name": "q",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  },
  "/api/montage/recipes": {
   "get": {
    "tags": [
     "montage"
    ],
    "operationId": "montageRecipes",
    "summary": "Shared recipe library",
    "description": "Recipes already analysed from references (structure only, nothing from any account).",
    "responses": {
     "200": {
      "description": "OK",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "402": {
      "description": "Free plan: Montage needs Starter, or monthly video quota reached"
     }
    },
    "parameters": [
     {
      "name": "family",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      }
     }
    ]
   }
  }
 },
 "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",
     "keynote"
    ],
    "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. `keynote`: 2 zones, the slide on top (16:9, full width) and the speaker below (rest of the height)."
   },
   "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": "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"
     },
     "url": {
      "type": "string",
      "description": "Source file, relative to the server, e.g. `/files/uploads/<id>.mp4`"
     },
     "thumbnail_url": {
      "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"
     },
     "goal": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "views",
       "clients",
       "comments",
       "followers",
       null
      ],
      "description": "Clip goal chosen at import, null if none"
     }
    }
   },
   "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"
       },
       "score_detail": {
        "type": [
         "object",
         "null"
        ],
        "description": "Sub-scores (0-10) the score is computed from: hook, standalone, payoff, emotion, value, shareability (weights 0.25/0.20/0.20/0.15/0.10/0.10). Null for older clips and search results. With a goal: also `goal_fit` (score = 70 % grid + 30 % goal_fit)",
        "additionalProperties": {
         "type": "integer"
        }
       },
       "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"
       },
       "cta": {
        "type": [
         "string",
         "null"
        ],
        "description": "Caption call to action proposed by the detection when the video has a `goal` (appended to `description` by describeClip); null otherwise"
       }
      }
     }
    ]
   },
   "VideoDetail": {
    "allOf": [
     {
      "$ref": "#/components/schemas/Video"
     },
     {
      "type": "object",
      "required": [
       "clips"
      ],
      "properties": {
       "clips": {
        "type": "array",
        "items": {
         "$ref": "#/components/schemas/Clip"
        }
       },
       "heatmap": {
        "type": "array",
        "description": "YouTube \"Most replayed\" curve (URL imports only, about 100 slices). Absent when YouTube does not provide it (upload, other site, recent or little-watched video). `value` is relative to the most replayed slice of the video (1 = max).",
        "items": {
         "type": "object",
         "required": [
          "start_s",
          "end_s",
          "value"
         ],
         "properties": {
          "start_s": {
           "type": "number"
          },
          "end_s": {
           "type": "number"
          },
          "value": {
           "type": "number",
           "minimum": 0,
           "maximum": 1
          }
         }
        }
       }
      }
     }
    ]
   },
   "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"
     },
     "kind": {
      "type": "string",
      "enum": [
       "filler",
       "hesitation",
       "silence",
       "repetition",
       "false_start",
       "tic"
      ],
      "description": "Set on cuts proposed by /tics or /silences; absent = manual cut."
     }
    }
   },
   "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",
       "behind",
       "serif",
       "hollow",
       "highlight"
      ],
      "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"
     },
     "bw": {
      "type": "boolean",
      "default": false,
      "description": "Black & white grade of the whole image (trending styles; recommended with `behind`)."
     }
    },
    "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",
       "video"
      ]
     },
     "path": {
      "type": "string",
      "description": "Server path under data/broll/ (send back unchanged)"
     },
     "preview_url": {
      "type": [
       "string",
       "null"
      ]
     },
     "query": {
      "type": [
       "string",
       "null"
      ]
     },
     "source_s": {
      "type": "number",
      "description": "src = video: where the shot starts in the source video"
     }
    }
   },
   "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"
      }
     },
     "no_music": {
      "type": "boolean",
      "default": false,
      "description": "« Sans musique »: renders of this edit leave out the music added by ClipOnIt (clip sound kept). Set by PUT edit or by `?music=0` on export."
     }
    }
   },
   "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",
       "behind",
       "serif",
       "hollow",
       "highlight"
      ]
     },
     "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"
     },
     "bw": {
      "type": "boolean",
      "default": false,
      "description": "Black & white grade of the whole image (trending styles; recommended with `behind`)."
     }
    }
   },
   "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"
      }
     },
     "no_music": {
      "type": "boolean",
      "default": false,
      "description": "« Sans musique »: renders of this edit leave out the music added by ClipOnIt (clip sound kept). Set by PUT edit or by `?music=0` on export."
     }
    },
    "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"
     },
     "style": {
      "type": "string",
      "enum": [
       "standard",
       "premium"
      ],
      "description": "Render style chosen at export"
     }
    }
   },
   "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"
      }
     }
    }
   },
   "ExplainerScript": {
    "type": "object",
    "description": "Explainer script `{title, box, theme?, voice, cast?, gap?, subscribe_text?, scenes:[{say, date, credit, stamp?, camera?, elements:[{el, x, y, in, anim, loop, out, exit, k, color?, tint?, who?, scale?, anchor?, zoom?, …}]}]}` (see docs/explainer.md §1). `in`/`out`: seconds or `{word, occurrence?=1, offset?=0, edge?: \"end\"}` (instant that word of the scene's `say` is spoken, from the real voice timing). `cast` `{id: {hair, hair_color, beard, glasses, outfit, colors, party, label…}}` merged under any element with `who: id` (element wins). `scale` 0.25..6, `anchor` center|bottom, `zoom` `{to, at:[t0,t1], x?, y?}`; scene `camera.target` = element index. `gap` = silence between scenes (s, default 0.3, 0..2; 0 with an imported audio). `subscribe_text` = end-screen slogan (\"\" = none). `voice` = `{voice_id, model?, style?: 0..1 expressiveness, speed?: 0.5..2}` (ElevenLabs; catalogue and defaults in `GET /api/explainer/schema`, `voice.voice_id`); an old OpenAI voice `{model, voice, instructions}` still renders with OpenAI.",
    "additionalProperties": true
   },
   "Explainer": {
    "type": "object",
    "required": [
     "id",
     "status",
     "progress"
    ],
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid"
     },
     "title": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "queued",
       "running",
       "done",
       "failed"
      ]
     },
     "progress": {
      "type": "number",
      "minimum": 0,
      "maximum": 1
     },
     "url": {
      "type": [
       "string",
       "null"
      ],
      "description": "`/files/exports/explainer_<id>.mp4` when done"
     },
     "error": {
      "type": [
       "string",
       "null"
      ]
     },
     "created_at": {
      "type": "string"
     },
     "script": {
      "$ref": "#/components/schemas/ExplainerScript",
      "description": "Only on GET /api/explainers/{id}"
     }
    }
   },
   "UserElementInput": {
    "type": "object",
    "required": [
     "name"
    ],
    "properties": {
     "name": {
      "type": "string",
      "maxLength": 80
     },
     "family": {
      "type": "string",
      "maxLength": 40
     },
     "pixels": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Rows of equal length, 1 char = 1 px, '.' = transparent (max 128x100)"
     },
     "palette": {
      "type": "object",
      "additionalProperties": {
       "type": "object",
       "required": [
        "name",
        "color"
       ],
       "properties": {
        "name": {
         "type": "string",
         "description": "Recolorable part name"
        },
        "color": {
         "type": "string",
         "description": "#rrggbb or theme token: accent, accent sombre, clair, gris clair, gris, gris foncé, sombre, noir"
        }
       }
      },
      "description": "{char: {name, color}}, max 24 entries"
     },
     "frames": {
      "type": "array",
      "items": {
       "type": "array",
       "items": {
        "type": "string"
       }
      },
      "description": "Extra images, same size as pixels (max 12 images in total)"
     },
     "fps": {
      "type": "number",
      "minimum": 0.5,
      "maximum": 30,
      "default": 4
     },
     "anchor": {
      "type": "string",
      "enum": [
       "center",
       "bottom"
      ],
      "default": "center"
     },
     "kind": {
      "type": "string",
      "enum": [
       "2d",
       "3d"
      ],
      "default": "2d",
      "description": "3d = archibald custom3d object (send primitives instead of pixels/palette)"
     },
     "primitives": {
      "type": "array",
      "items": {
       "type": "object"
      },
      "description": "kind 3d: [{shape: box|cylinder|cone|sphere|dome|torus|hyperboloid|plane|prism|group, part, pos, rot, scale, color, shade, segments, motion, ...shape params}] (docs/explainer.md §1 quater; strict schema: GET /api/explainer/schema?style=archibald&elements=custom3d)"
     }
    },
    "description": "2D: pixels + palette required; 3D: kind '3d' + primitives."
   },
   "UserElement": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid"
     },
     "name": {
      "type": "string"
     },
     "family": {
      "type": "string"
     },
     "data": {
      "type": "object",
      "description": "{pixels, palette, frames, fps, anchor}"
     },
     "element": {
      "type": "object",
      "description": "Ready-to-insert scene element {el:'custom', name, pixels, palette, frames, fps, anchor}; add x, y, in, k, anim…"
     },
     "preview": {
      "type": "string",
      "description": "/api/explainer/my-elements/{id}/preview.webp"
     },
     "created_at": {
      "type": "string"
     },
     "updated_at": {
      "type": "string"
     },
     "kind": {
      "type": "string",
      "enum": [
       "2d",
       "3d"
      ]
     }
    }
   },
   "ExplainerAudio": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "name": {
      "type": "string"
     },
     "source_url": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "enum": [
       "queued",
       "running",
       "done",
       "failed"
      ]
     },
     "progress": {
      "type": "number"
     },
     "error": {
      "type": [
       "string",
       "null"
      ]
     },
     "duration": {
      "type": [
       "number",
       "null"
      ]
     },
     "created_at": {
      "type": "string"
     },
     "text": {
      "type": "string",
      "description": "Full transcript (done only)"
     },
     "language": {
      "type": "string"
     },
     "scenes": {
      "type": "array",
      "description": "done only",
      "items": {
       "type": "object",
       "properties": {
        "say": {
         "type": "string"
        },
        "start": {
         "type": "number"
        },
        "end": {
         "type": "number"
        },
        "words": {
         "type": "array",
         "items": {
          "type": "object",
          "properties": {
           "word": {
            "type": "string"
           },
           "start": {
            "type": "number"
           },
           "end": {
            "type": "number"
           }
          }
         }
        }
       }
      }
     }
    }
   },
   "LibraryElement": {
    "type": "object",
    "description": "Élément de la bibliothèque commune (table library_elements, docs/explainer.md §1 sexies)",
    "properties": {
     "style": {
      "type": "string",
      "enum": [
       "pixel-news",
       "archibald"
      ]
     },
     "name": {
      "type": "string"
     },
     "kind": {
      "type": "string",
      "enum": [
       "code",
       "data"
      ]
     },
     "family": {
      "type": "string"
     },
     "concept": {
      "type": "string"
     },
     "path": {
      "type": "string"
     },
     "tags": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "description": {
      "type": "string"
     },
     "params": {
      "type": "object"
     },
     "parts": {
      "type": "object"
     },
     "preview": {
      "type": "string"
     },
     "source": {
      "type": "string",
      "description": "module du pack Python, ou api"
     },
     "enabled": {
      "type": "boolean"
     },
     "created_by": {
      "type": [
       "string",
       "null"
      ]
     },
     "data": {
      "description": "kind=data : spec custom {pixels, palette, frames, fps, anchor} ou custom3d {kind: 3d, primitives}"
     },
     "element": {
      "type": "object",
      "description": "{el: name}, à compléter (x, y, in…)"
     },
     "created_at": {
      "type": "string"
     },
     "updated_at": {
      "type": "string"
     },
     "removed_at": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "LibraryElementInput": {
    "type": "object",
    "required": [
     "name",
     "data"
    ],
    "properties": {
     "name": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_]{1,59}$"
     },
     "style": {
      "type": "string",
      "enum": [
       "pixel-news",
       "archibald"
      ],
      "description": "défaut : déduit de data"
     },
     "family": {
      "type": "string",
      "maxLength": 40
     },
     "path": {
      "type": "string",
      "maxLength": 200
     },
     "tags": {
      "type": "array",
      "items": {
       "type": "string",
       "maxLength": 40
      },
      "maxItems": 30
     },
     "description": {
      "type": "string",
      "maxLength": 600
     },
     "data": {
      "type": "object"
     },
     "enabled": {
      "type": "boolean",
      "default": true
     }
    }
   },
   "AgentJob": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "queued",
       "running",
       "done",
       "failed",
       "canceled"
      ]
     },
     "step": {
      "type": "string",
      "enum": [
       "queued",
       "transcribe",
       "compose",
       "check",
       "render",
       "done"
      ]
     },
     "step_label": {
      "type": "string"
     },
     "step_label_en": {
      "type": "string"
     },
     "progress": {
      "type": "number"
     },
     "percent": {
      "type": "integer"
     },
     "message": {
      "type": "string"
     },
     "source": {
      "type": "string"
     },
     "title": {
      "type": "string"
     },
     "agent": {
      "type": "string"
     },
     "explainer_id": {
      "type": "string"
     },
     "doc": {
      "type": "string"
     },
     "url": {
      "type": "string"
     },
     "editor_url": {
      "type": "string"
     },
     "error": {
      "type": "string"
     },
     "cost_usd": {
      "type": "number"
     }
    }
   },
   "TikTokCapabilities": {
    "type": "object",
    "properties": {
     "direct_post": {
      "type": "boolean"
     },
     "draft": {
      "type": "boolean"
     },
     "analytics": {
      "type": "boolean"
     },
     "scheduling": {
      "type": "boolean"
     }
    }
   },
   "TikTokMetrics": {
    "type": "object",
    "properties": {
     "view_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     },
     "like_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     },
     "comment_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     },
     "share_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     }
    }
   },
   "TikTokAccount": {
    "type": "object",
    "properties": {
     "configured": {
      "type": "boolean"
     },
     "connected": {
      "type": "boolean"
     },
     "reason": {
      "type": [
       "string",
       "null"
      ]
     },
     "audited": {
      "type": "boolean"
     },
     "redirect_uri": {
      "type": [
       "string",
       "null"
      ]
     },
     "capabilities": {
      "$ref": "#/components/schemas/TikTokCapabilities"
     },
     "account": {
      "anyOf": [
       {
        "type": "object",
        "properties": {
         "display_name": {
          "type": "string"
         },
         "avatar_url": {
          "type": "string"
         },
         "open_id": {
          "type": "string"
         },
         "scopes": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "expires_at": {
          "type": [
           "string",
           "null"
          ]
         }
        }
       },
       {
        "type": "null"
       }
      ]
     }
    }
   },
   "TikTokCreator": {
    "type": "object",
    "properties": {
     "creator_avatar_url": {
      "type": "string"
     },
     "creator_username": {
      "type": "string"
     },
     "creator_nickname": {
      "type": "string"
     },
     "privacy_level_options": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "comment_disabled": {
      "type": "boolean"
     },
     "duet_disabled": {
      "type": "boolean"
     },
     "stitch_disabled": {
      "type": "boolean"
     },
     "max_video_post_duration_sec": {
      "type": "integer",
      "minimum": 0
     }
    }
   },
   "TikTokSource": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "kind": {
      "type": "string",
      "enum": [
       "clip",
       "explainer"
      ]
     },
     "title": {
      "type": "string"
     },
     "url": {
      "type": "string"
     },
     "thumbnail_url": {
      "type": [
       "string",
       "null"
      ]
     },
     "duration_s": {
      "type": [
       "number",
       "null"
      ]
     }
    },
    "required": [
     "id",
     "kind",
     "title",
     "url"
    ]
   },
   "TikTokPost": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "source_kind": {
      "type": "string"
     },
     "source_id": {
      "type": "string"
     },
     "title": {
      "type": "string"
     },
     "mode": {
      "type": "string",
      "enum": [
       "direct",
       "draft"
      ]
     },
     "privacy_level": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "enum": [
       "scheduled",
       "queued",
       "submitting",
       "processing",
       "inbox_delivered",
       "published",
       "failed",
       "canceled",
       "unknown"
      ]
     },
     "scheduled_at": {
      "type": [
       "string",
       "null"
      ]
     },
     "publish_id": {
      "type": [
       "string",
       "null"
      ]
     },
     "video_ids": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "error": {
      "type": [
       "string",
       "null"
      ]
     },
     "stats": {
      "anyOf": [
       {
        "$ref": "#/components/schemas/TikTokMetrics"
       },
       {
        "type": "null"
       }
      ]
     },
     "share_url": {
      "type": [
       "string",
       "null"
      ]
     },
     "created_at": {
      "type": "string"
     },
     "updated_at": {
      "type": "string"
     }
    }
   },
   "TikTokCreatePost": {
    "type": "object",
    "properties": {
     "source_kind": {
      "type": "string",
      "enum": [
       "clip",
       "explainer"
      ]
     },
     "source_id": {
      "type": "string"
     },
     "mode": {
      "type": "string",
      "enum": [
       "direct",
       "draft"
      ]
     },
     "title": {
      "type": "string",
      "description": "Au plus 2200 unités UTF-16."
     },
     "privacy_level": {
      "type": [
       "string",
       "null"
      ]
     },
     "scheduled_at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "Entre 1 minute et 30 jours dans le futur."
     },
     "idempotency_key": {
      "type": "string",
      "format": "uuid"
     },
     "allow_comment": {
      "type": "boolean"
     },
     "allow_duet": {
      "type": "boolean"
     },
     "allow_stitch": {
      "type": "boolean"
     },
     "brand_organic": {
      "type": "boolean"
     },
     "brand_content": {
      "type": "boolean"
     },
     "is_aigc": {
      "type": "boolean"
     },
     "consent": {
      "type": "boolean",
      "const": true
     },
     "rights_confirmed": {
      "type": "boolean",
      "const": true
     },
     "no_watermark_confirmed": {
      "type": "boolean",
      "const": true
     }
    },
    "required": [
     "source_kind",
     "source_id",
     "mode",
     "idempotency_key",
     "consent",
     "rights_confirmed",
     "no_watermark_confirmed"
    ],
    "additionalProperties": false
   },
   "TikTokMetricSnapshot": {
    "type": "object",
    "properties": {
     "video_id": {
      "type": "string"
     },
     "captured_hour": {
      "type": "integer",
      "minimum": 0
     },
     "view_count": {
      "type": "integer",
      "minimum": 0
     },
     "like_count": {
      "type": "integer",
      "minimum": 0
     },
     "comment_count": {
      "type": "integer",
      "minimum": 0
     },
     "share_count": {
      "type": "integer",
      "minimum": 0
     }
    }
   },
   "TikTokPublicVideo": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "title": {
      "type": "string"
     },
     "video_description": {
      "type": "string"
     },
     "cover_image_url": {
      "type": "string"
     },
     "share_url": {
      "type": "string"
     },
     "create_time": {
      "type": "integer",
      "minimum": 0
     },
     "duration": {
      "type": "integer",
      "minimum": 0
     },
     "view_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     },
     "like_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     },
     "comment_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     },
     "share_count": {
      "type": [
       "integer",
       "null"
      ],
      "minimum": 0
     }
    }
   },
   "TikTokAnalytics": {
    "type": "object",
    "properties": {
     "available": {
      "type": "boolean"
     },
     "videos": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/TikTokPublicVideo"
      }
     },
     "updated_at": {
      "type": [
       "string",
       "null"
      ]
     },
     "cursor": {
      "type": [
       "integer",
       "null"
      ]
     },
     "has_more": {
      "type": "boolean"
     }
    }
   },
   "Resource": {
    "oneOf": [
     {
      "type": "object",
      "required": [
       "kind",
       "url"
      ],
      "additionalProperties": false,
      "properties": {
       "kind": {
        "const": "site"
       },
       "url": {
        "type": "string",
        "format": "uri",
        "description": "https only; read server-side (public IPs only, 3 redirects max, 8 s, 1 MB) to align the CTA with the real offer"
       }
      }
     },
     {
      "type": "object",
      "required": [
       "kind",
       "video_id"
      ],
      "additionalProperties": false,
      "properties": {
       "kind": {
        "const": "video"
       },
       "video_id": {
        "type": "string",
        "description": "Another of your videos, usable as B-roll (`source_video_id` on broll/illustrate, broll/shots, broll/from-video)"
       }
      }
     }
    ]
   },
   "ObjectiveInput": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
     "goal": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "views",
       "clients",
       "comments",
       "followers",
       null
      ],
      "description": "What the clips are for. Changes the moments picked, the score (sub-score `goal_fit`, 30 % of the score), the titles, and adds a caption CTA per clip (`cta`)."
     },
     "brief": {
      "type": [
       "string",
       "null"
      ],
      "maxLength": 1000,
      "description": "Free text: what you want"
     },
     "resources": {
      "type": [
       "array",
       "null"
      ],
      "maxItems": 5,
      "items": {
       "$ref": "#/components/schemas/Resource"
      }
     }
    }
   },
   "Objective": {
    "type": "object",
    "properties": {
     "goal": {
      "type": [
       "string",
       "null"
      ]
     },
     "brief": {
      "type": [
       "string",
       "null"
      ]
     },
     "resources": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Resource"
      }
     },
     "site_summary": {
      "type": [
       "string",
       "null"
      ],
      "description": "The site as read (title, description, headings, text, ≤ 1500 chars); null until the next detection"
     },
     "playbook_version": {
      "type": "string",
      "description": "Version of the injected playbook (src/playbook)"
     }
    }
   }
  }
 }
}
