openapi: 3.1.0
info:
  title: Swappr Public API
  version: 1.4.2
  description: |
    API REST publique Swappr v1.4.2 — Studio image/vidéo/audio, Face Swap,
    Camera Roll, personas, bibliothèque, uploads, jobs, catalogue modèles, NSFW.

    **Roadmap** : publication Instagram (scope `publish`), webhooks job.completed.

    Authentification : header `Authorization: Bearer sk_swp_<key>`.
    Idempotency (POST génératifs) : header `Idempotency-Key` (8–100 car., TTL 24 h).
    Créez vos clés sur https://swappr.fr/dashboard/api-keys.
  contact:
    name: Swappr
    url: https://swappr.fr
servers:
  - url: https://swappr.fr
    description: Production
security:
  - bearerAuth: []
tags:
  - name: account
    description: Profil utilisateur, crédits, abonnement
  - name: studio
    description: Génération image et vidéo
  - name: jobs
    description: Suivi des générations asynchrones
  - name: library
    description: Bibliothèque de médias
  - name: uploads
    description: Upload de fichiers de référence
  - name: nsfw
    description: Statut mode adulte (plan + consentement)
  - name: faceswap
    description: Face Swap HD et finition
  - name: camera-roll
    description: Galerie iPhone-style (photos spontanées)
  - name: models
    description: Personas IA enregistrés
paths:
  /api/v1/me:
    get:
      tags: [account]
      summary: Profil + crédits + abonnement + métadonnées clé
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/credits/balance:
    get:
      tags: [account]
      summary: Solde crédits courant
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  balance: { type: integer }
                  monthlyQuota: { type: integer }
                  usedThisMonth: { type: integer }
                  welcomeTrialRemaining: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/jobs:
    get:
      tags: [jobs]
      summary: Liste jobs actifs et récemment terminés
      parameters:
        - in: query
          name: include
          schema: { type: string, default: "active,recent", example: "active,recent" }
          description: "Filtre : `active`, `recent`, ou les deux séparés par virgule"
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 50, default: 40 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobsListResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/studio/models:
    get:
      tags: [studio]
      summary: Catalogue modèles image et vidéo (tarifs, ratios, NSFW)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StudioCatalogResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/nsfw/status:
    get:
      tags: [nsfw]
      summary: État plan NSFW + consentement signé
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NsfwStatusResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/uploads:
    post:
      tags: [uploads]
      summary: Upload fichier de référence (image, vidéo, audio)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Fichier stocké
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadResponse"
        "400": { description: Fichier invalide }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { description: Fichier trop volumineux }
        "415": { description: Type MIME non supporté }
  /api/v1/studio/video/generate:
    post:
      tags: [studio]
      summary: Lance une génération vidéo (asynchrone)
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string, minLength: 8, maxLength: 100 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt, modelId]
              properties:
                prompt: { type: string }
                modelId: { type: string, example: "kling-3" }
                qualityId: { type: string }
                aspectRatio: { type: string }
                duration: { type: number, description: "Durée en secondes" }
                referenceImageUrls: { type: array, items: { type: string, format: uri } }
                referenceStoragePaths: { type: array, items: { type: string } }
                referenceVideoUrl: { type: string, format: uri }
                referenceVideoStoragePaths: { type: array, items: { type: string } }
                nsfw: { type: boolean }
                generateAudio: { type: boolean }
      responses:
        "200":
          description: Job créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string }
                  status: { type: string, enum: [queued] }
                  creditsCharged: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { description: Crédits insuffisants }
        "403": { $ref: "#/components/responses/Forbidden" }
  /api/v1/studio/audio/tts:
    post:
      tags: [studio]
      summary: Synthèse vocale TTS (synchrone, ElevenLabs)
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text, voiceId]
              properties:
                text: { type: string, maxLength: 5000 }
                voiceId: { type: string }
                modelId: { type: string, enum: [eleven_v3, eleven_multilingual_v2, eleven_flash_v2_5] }
                languageCode: { type: string }
      responses:
        "200":
          description: Audio généré
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AudioGenerationResponse"
  /api/v1/studio/audio/dialogue:
    post:
      tags: [studio]
      summary: Dialogue multi-voix (synchrone, ElevenLabs)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [inputs]
              properties:
                inputs:
                  type: array
                  maxItems: 10
                  items:
                    type: object
                    required: [text, voiceId]
                    properties:
                      text: { type: string }
                      voiceId: { type: string }
                languageCode: { type: string, default: fr }
      responses:
        "200":
          description: Audio généré
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AudioGenerationResponse"
  /api/v1/library/items/{id}/save:
    post:
      tags: [library]
      summary: Sauvegarde permanente d'un média temporary
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                mediaType: { type: string, enum: [image, video, audio] }
                modelId: { type: string }
      responses:
        "200":
          description: Média sauvegardé
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  url: { type: string, format: uri }
                  retention: { type: string, enum: [saved] }
                  mediaType: { type: string }
  /api/v1/library/items/{id}:
    delete:
      tags: [library]
      summary: Supprime un média de la bibliothèque
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Supprimé
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string }
        "404": { description: media_not_found }
  /api/v1/models:
    get:
      tags: [models]
      summary: Personas IA enregistrés
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PersonasResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/faceswap/pricing:
    get:
      tags: [faceswap]
      summary: Grille tarifaire Face Swap
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FaceSwapPricingResponse"
  /api/v1/faceswap/generate:
    post:
      tags: [faceswap]
      summary: Lance un Face Swap HD (asynchrone)
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FaceSwapGenerateRequest"
      responses:
        "200":
          description: Job créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string }
                  status: { type: string, enum: [queued] }
                  creditsCharged: { type: integer }
        "402": { description: Crédits insuffisants }
        "403": { $ref: "#/components/responses/Forbidden" }
  /api/v1/faceswap/refine:
    post:
      tags: [faceswap]
      summary: Finition post Face Swap (asynchrone)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [faceSwapResultImageUrl]
              properties:
                faceSwapResultImageUrl: { type: string, format: uri }
                sourceJobId: { type: string }
                engine: { type: string, enum: [gpt, nano, wan27] }
                nsfw: { type: boolean }
      responses:
        "200":
          description: Job créé
  /api/v1/camera-roll/pricing:
    get:
      tags: [camera-roll]
      summary: Grille crédits Camera Roll par moteur et qualité
      responses:
        "200": { description: OK }
  /api/v1/camera-roll/scenes:
    get:
      tags: [camera-roll]
      summary: Catalogue scènes et presets (pagination)
      parameters:
        - in: query
          name: preset
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200": { description: OK }
  /api/v1/camera-roll/generate:
    post:
      tags: [camera-roll]
      summary: Lance une photo Camera Roll (asynchrone, workflow camera_roll)
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sceneId, referenceImage]
              properties:
                referenceImage:
                  type: object
                  description: "{ url } ou { storagePath } depuis POST /uploads"
                referenceImageUrl: { type: string, format: uri }
                sceneId: { type: string }
                photoIndex: { type: integer, minimum: 0 }
                totalPhotos: { type: integer, minimum: 1, maximum: 20 }
                batchParentId: { type: string }
                preset: { type: string }
                imperfectionLevel: { type: string }
                outputFormat: { type: string }
                engine: { type: string, enum: [gpt, nano] }
                quality: { type: string, enum: [1k, 2k, 4k] }
                phoneModel: { type: string }
                subjectGender:
                  type: string
                  enum: [feminine, masculine, neutral, auto]
                  default: feminine
                  description: |
                    Genre du sujet pour sélectionner les scènes/outfits adaptés.
                    `feminine` reste le défaut de compatibilité ; `auto` laisse Swappr inférer.
      responses:
        "200":
          description: Job créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string }
                  batchParentId: { type: string }
                  status: { type: string, enum: [queued] }
                  creditsCharged: { type: integer }
  /api/v1/studio/image/generate:
    post:
      tags: [studio]
      summary: Lance une génération image (asynchrone)
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string, minLength: 8, maxLength: 100 }
          description: Clé optionnelle — rejoue la même réponse pendant 24 h (anti double-facturation)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt: { type: string, example: "luxury café interior, golden hour, cinematic" }
                modelId:
                  type: string
                  enum: [gpt-image-2, nano-banana-2, seedream-45, wan-27-image, qwen-image, grok-imagine]
                  description: |
                    Moteur IA. Défaut `gpt-image-2` (safe) ou `seedream-45` si `nsfw=true`.
                    `gpt-image-2` est incompatible avec `nsfw=true` → `400 model_not_nsfw_capable`.
                qualityId: { type: string }
                imageCount: { type: integer, minimum: 1, maximum: 4, default: 1 }
                aspectRatio: { type: string, example: "4:5" }
                referenceImageUrls: { type: array, items: { type: string, format: uri } }
                referenceStoragePaths: { type: array, items: { type: string } }
                prePromptIds: { type: array, items: { type: string } }
                negativePrompt: { type: string }
                nsfw:
                  type: boolean
                  default: false
                  description: |
                    Active le mode NSFW (filtrage Kie désactivé, surcoût ×1,25 sauf exemption).
                    Exige : plan NSFW actif + consentement signé + modèle uncensoredCapable
                    (seedream-45, wan-27-image, qwen-image, grok-imagine, nano-banana-2).
      responses:
        "200":
          description: Job créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string }
                  batchId: { type: string }
                  status: { type: string, enum: [queued, running, completed] }
                  creditsCharged: { type: integer }
        "400":
          description: |
            Body invalide ou `model_not_nsfw_capable` (modèle incompatible avec nsfw=true).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "402":
          description: Crédits insuffisants
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Scope manquant, ou `nsfw_plan_required` (droit NSFW absent : Pro/Studio ou addon actif),
            ou `nsfw_consent_required` (consentement non signé).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/v1/jobs/{id}:
    get:
      tags: [jobs]
      summary: Statut et outputs d'un job
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobResponse"
        "404": { description: Job introuvable }
  /api/v1/library/items:
    get:
      tags: [library]
      summary: Liste paginée des médias de la bibliothèque
      parameters:
        - in: query
          name: kind
          schema: { type: string, enum: [saved, temporary], default: saved }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - in: query
          name: cursor
          schema: { type: string, format: date-time, description: "Lastmod ISO du dernier item de la page précédente" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/LibraryItem" }
                  nextCursor: { type: string, nullable: true }
                  hasMore: { type: boolean }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sk_swp_<key>
  responses:
    Unauthorized:
      description: Clé API manquante ou invalide
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    Forbidden:
      description: Scope insuffisant pour cet endpoint
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    RateLimited:
      description: Rate limit dépassé (par minute ou par jour)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
  schemas:
    ApiError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - invalid_body
                - missing_prompt
                - missing_or_invalid_key
                - invalid_key
                - key_expired
                - insufficient_credits
                - scope_missing
                - job_not_found
                - nsfw_plan_required
                - nsfw_consent_required
                - model_not_nsfw_capable
                - FRAUD_BLOCKED
                - rate_limit_minute
                - rate_limit_day
                - internal_error
                - invalid_file
                - unsupported_media_type
                - file_too_large
                - upload_failed
            message: { type: string }
            details: { type: object, additionalProperties: true }
    JobsListResponse:
      type: object
      properties:
        jobs:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              status: { type: string }
              workflowType: { type: string }
              label: { type: string }
              createdAt: { type: string, format: date-time }
              updatedAt: { type: string, format: date-time }
              batchId: { type: string, nullable: true }
              groupKey: { type: string }
        hasActive: { type: boolean }
    StudioCatalogResponse:
      type: object
      properties:
        image:
          type: array
          items: { $ref: "#/components/schemas/PublicImageModel" }
        video:
          type: array
          items: { $ref: "#/components/schemas/PublicVideoModel" }
    PublicImageModel:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        provider: { type: string }
        uncensoredCapable: { type: boolean }
        aspectRatios: { type: array, items: { type: string } }
        qualities:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              resolution: { type: string }
              credits: { type: integer }
    PublicVideoModel:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        provider: { type: string }
        uncensoredCapable: { type: boolean }
        duration:
          type: object
          properties:
            minSec: { type: integer }
            maxSec: { type: integer }
            defaultSec: { type: integer }
    NsfwStatusResponse:
      type: object
      properties:
        planOk: { type: boolean }
        consentSigned: { type: boolean }
        consentVersion: { type: integer }
        surchargeExempt: { type: boolean }
        consentUrl: { type: string, format: uri }
    UploadResponse:
      type: object
      properties:
        storagePath: { type: string }
        signedUrl: { type: string, format: uri }
        contentType: { type: string }
        expiresInSec: { type: integer }
    AudioGenerationResponse:
      type: object
      properties:
        jobId: { type: string }
        status: { type: string, enum: [completed] }
        outputUrl: { type: string, format: uri }
        signedUrl: { type: string, format: uri }
        storagePath: { type: string }
        creditsCharged: { type: integer }
        balance: { type: integer }
    PersonasResponse:
      type: object
      properties:
        items:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              previewUrl: { type: string, format: uri, nullable: true }
              personaDescription: { type: string }
              createdAt: { type: string, format: date-time }
        expiresInSec: { type: integer }
    FaceSwapPricingResponse:
      type: object
      properties:
        engines: { type: array, items: { type: string } }
        qualities: { type: array, items: { type: string } }
        matrix: { type: object, additionalProperties: true }
        refineCredits:
          type: object
          properties:
            safe: { type: integer }
            nsfw: { type: integer }
    FaceSwapGenerateRequest:
      type: object
      required: [baseImage, identity]
      properties:
        baseImage:
          type: object
          properties:
            storagePath: { type: string }
            url: { type: string, format: uri }
        identity:
          type: object
          properties:
            modelId: { type: string, format: uuid }
            storagePath: { type: string }
            url: { type: string, format: uri }
        aspectRatio: { type: string, enum: ["1:1", "4:5", "16:9", "9:16"] }
        quality: { type: string, enum: [2k, 4k] }
        engine: { type: string, enum: [gpt, nano, wan27] }
        userPrompt: { type: string }
        nsfw: { type: boolean }
    MeResponse:
      type: object
      properties:
        user:
          type: object
          properties:
            id: { type: string }
            email: { type: string, nullable: true }
            firstName: { type: string, nullable: true }
            lastName: { type: string, nullable: true }
            createdAt: { type: string, format: date-time, nullable: true }
        credits:
          type: object
          properties:
            balance: { type: integer }
            monthlyQuota: { type: integer }
            usedThisMonth: { type: integer }
        subscription:
          type: object
          properties:
            planId: { type: string, nullable: true }
            status: { type: string, nullable: true }
            currentPeriodEnd: { type: string, format: date-time, nullable: true }
        apiKey:
          type: object
          properties:
            id: { type: string }
            scopes: { type: array, items: { type: string, enum: [read, generate, publish] } }
            rateLimit:
              type: object
              properties:
                perMinute: { type: integer }
                perDay: { type: integer }
    JobResponse:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, queued, running, completed, failed] }
        workflowType: { type: string }
        outputs: { type: array, items: { type: string, format: uri } }
        error: { type: string, nullable: true }
        creditsCharged: { type: integer }
        batchId: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    LibraryItem:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri, nullable: true }
        mediaType: { type: string, enum: [image, video, audio] }
        retention: { type: string, enum: [saved, temporary] }
        metadata: { type: object, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        jobId: { type: string, nullable: true }
