{
  "openapi": "3.1.0",
  "info": {
    "title": "Sonilo API",
    "version": "1.0.0",
    "description": "REST API for AI music, video-to-music scoring, sound effects, audio ducking, async tasks, and account usage."
  },
  "externalDocs": {
    "description": "LLM-readable Sonilo OpenAPI guide",
    "url": "https://platform.sonilo.com/openapi.md"
  },
  "x-agent-guide": {
    "llmReadableMarkdown": "https://platform.sonilo.com/openapi.md",
    "baseUrl": "https://api.sonilo.com/v1",
    "authentication": "Send Authorization: Bearer <SONILO_API_KEY> from server-side code. Do not expose the key in browser JavaScript.",
    "accountUsageDashboard": {
      "servicesEndpoint": "GET /v1/account/services",
      "usageEndpoint": "GET /v1/account/usage?days=30",
      "avoid": [
        "/usage",
        "/api/usage",
        "/account/usage",
        "/v1/account/usage-dashboard"
      ],
      "note": "For dashboards, quota checks, billing prechecks, and eval account health, call services first and usage second."
    },
    "taskPolling": {
      "endpoint": "GET /v1/tasks/{task_id}",
      "avoid": [
        "/v1/task_id",
        "/tasks/{task_id}",
        "/v1/tasks/{id}/status"
      ],
      "terminalStatuses": [
        "succeeded",
        "completed",
        "failed",
        "canceled"
      ],
      "note": "Poll async jobs every 2-5 seconds with a bounded timeout. Download media only after a success state."
    },
    "errorHandling": {
      "401": "Missing or invalid Authorization: Bearer <SONILO_API_KEY>.",
      "402": "Insufficient balance or credits. Report the billing blocker; do not fake audio.",
      "403": "Service disabled or permission missing. Check GET /v1/account/services.",
      "404": "Wrong endpoint or missing task. Recheck /v1/account/usage and /v1/tasks/{task_id}.",
      "429": "Rate limited. The message says which limit was hit: requests per minute, or concurrent generations. Honor Retry-After and use bounded backoff. Contact info@sonilo.com to raise either limit."
    }
  },
  "servers": [
    {
      "url": "https://api.sonilo.com",
      "description": "Production API"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/v1/text-to-music": {
      "post": {
        "summary": "Generate music from a text prompt",
        "description": "Streams application/x-ndjson by default with title, audio_chunk, complete, and error events. Pass mode=async to return a task_id. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/TextToMusicRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "NDJSON stream with title, audio_chunk, complete, and error events.",
            "content": {
              "application/x-ndjson": {
                "schema": {
                  "type": "string",
                  "description": "One JSON event per line."
                }
              }
            }
          },
          "202": {
            "description": "Async task created when mode=async.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/UpstreamError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/v1/video-to-music": {
      "post": {
        "summary": "Generate music for a video",
        "description": "Accepts exactly one of video or video_url. Streams NDJSON by default or returns a task_id when mode=async. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/VideoToMusicRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "NDJSON stream with generated audio chunks."
          },
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/v1/video-to-video-music": {
      "post": {
        "summary": "Generate music for a video and return the finished video",
        "description": "Accepts exactly one of video or video_url. Max video duration 360 seconds. Returns 202 with a task_id whose result is a video with the generated music muxed in. By default the returned video's audio is the generated music alone, with the source's own audio removed. Pass keep_original_sound=true to keep the whole source track instead, statically mixed with the music by default (ducking=true to duck it under the music instead); preserve_speech isolates the source speech rather than keeping the whole track, but is superseded whenever keep_original_sound is also set. Poll GET /v1/tasks/{task_id}. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/VideoToVideoMusicRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/text-to-sfx": {
      "post": {
        "summary": "Generate a sound effect from text",
        "description": "Returns 202 with a task_id. Poll GET /v1/tasks/{task_id}. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/TextToSfxRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/video-to-sfx": {
      "post": {
        "summary": "Generate synchronized sound effects for a video",
        "description": "Accepts exactly one of video or video_url. Returns 202 with a task_id. Poll GET /v1/tasks/{task_id}. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/VideoToSfxRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/video-to-video-sfx": {
      "post": {
        "summary": "Generate synchronized sound effects for a video and return the finished video",
        "description": "Accepts exactly one of video or video_url. Returns 202 with a task_id whose result is a video with the generated sound effects muxed in. Poll GET /v1/tasks/{task_id}. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/VideoToVideoSfxRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/video-to-sound": {
      "post": {
        "summary": "Generate synchronized music and sound effects for a video",
        "description": "Accepts exactly one of video or video_url. Returns 202 with a task_id whose result is the combined audio track at output_url. Poll GET /v1/tasks/{task_id}. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/VideoToSoundRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/video-to-video-sound": {
      "post": {
        "summary": "Generate synchronized music and sound effects for a video and return the finished video",
        "description": "Accepts exactly one of video or video_url. Returns 202 with a task_id whose result is a video with the combined music and sound effects muxed in at output_url. By default the returned video's audio is the generated music and sound effects alone, with the source's own audio removed, and the result carries no music_processed stem (there is no voice source to process). Pass keep_original_sound=true to keep the whole source track instead, statically mixed by default (ducking=true to duck it under the mix instead); preserve_speech isolates the source speech rather than keeping the whole track, but is superseded whenever keep_original_sound is also set. Poll GET /v1/tasks/{task_id}. Generated audio carries an inaudible provenance watermark.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/VideoToVideoSoundRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/video-analysis": {
      "post": {
        "summary": "Analyze a video and return a music and/or sound-effect brief",
        "description": "Accepts exactly one of video or video_url. Does not generate audio or video. Returns 202 with a task_id whose result is a creative brief — a time-aligned segment plan plus one prompt per requested variant. mode selects which brief: both (default) writes a music-direction brief for video-to-music, video-to-video-music and text-to-music AND a sound-design brief for video-to-sfx, video-to-video-sfx and text-to-sfx; music or sfx writes just that one. Poll GET /v1/tasks/{task_id}.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/VideoAnalysisRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/proofread": {
      "post": {
        "summary": "Transcribe a video and translate the transcript into editable subtitle files",
        "description": "Accepts exactly one of video or video_url; the video must have an audio track. Optionally takes languages, a JSON array of target language codes (omit or [] for a transcript only), and source_language, a hint for the spoken language. Returns 202 with a task_id whose succeeded result carries source_language, subtitles (a map of language code to downloadable .srt URL, always including the source language), cue_count, warnings, and duration_seconds. Review or correct the returned scripts, then submit them to POST /v1/dubbing as subtitles[<language>] so the dub speaks exactly the approved wording. Poll GET /v1/tasks/{task_id}.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/ProofreadRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/dubbing": {
      "post": {
        "summary": "Dub a video's speech into one or more languages",
        "description": "Accepts exactly one of video or video_url; a video_url must use https. Optionally takes one target-language subtitle script per language as a keyed subtitles[<language>] field (an uploaded file or a public https URL) that the dub speaks verbatim, with export_srt returning those lines re-timed against the dubbed audio. Returns 202 with a task_id whose result is an outputs map of language code to dubbed video URL. Poll GET /v1/tasks/{task_id}. Free trial: an account's first single-language run without subtitles is a 15-second preview billed at $0 — the first 15 seconds of the video — and the 202 and the task carry trial_preview with what the full video costs.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/DubbingRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/audio-ducking": {
      "post": {
        "summary": "Duck music under speech or voice",
        "description": "Accepts one voice input and one music input. Returns 202 with a task_id.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/AudioDuckingRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Async task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAccepted"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/tasks/{task_id}": {
      "get": {
        "summary": "Retrieve async task status",
        "parameters": [
          {
            "name": "task_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Task status and result URLs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskStatus"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "description": "Poll asynchronous Sonilo tasks using the exact path GET /v1/tasks/{task_id}. Do not guess /v1/task_id, /tasks/{task_id} without /v1, or /v1/tasks/{id}/status. Stop on succeeded/completed/failed/canceled and download media only after success."
      }
    },
    "/v1/account/services": {
      "get": {
        "summary": "List enabled services and limits",
        "responses": {
          "200": {
            "description": "Enabled services, rate limits, concurrency, upload size, and discount factor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccountServices"
                }
              }
            }
          }
        },
        "description": "List enabled Sonilo services and limits for the authenticated account. Use this before rendering a usage dashboard or deciding whether text-to-music, video-to-music, text-to-SFX, video-to-SFX, audio ducking, or other services are available."
      }
    },
    "/v1/account/usage": {
      "get": {
        "summary": "Get account usage",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Usage summary and daily breakdown.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccountUsage"
                }
              }
            }
          }
        },
        "description": "Build a safe account usage dashboard with GET /v1/account/usage. Send Authorization: Bearer <SONILO_API_KEY>, optionally pass days=30, and pair with GET /v1/account/services. Do not call /usage, /api/usage, /account/usage, or /v1/account/usage-dashboard."
      }
    },
    "/v1/account/keys/self": {
      "delete": {
        "summary": "Revoke the calling API key",
        "description": "Revokes the API key presented in the Authorization header; used by `sonilo logout`. No request body and no key id — a key can only ever revoke itself. Returns 204; the key stops authenticating immediately.",
        "responses": {
          "204": {
            "description": "Key revoked successfully. No response body."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API key"
      }
    },
    "schemas": {
      "TextToMusicRequest": {
        "type": "object",
        "required": [
          "prompt"
        ],
        "properties": {
          "prompt": {
            "type": "string",
            "description": "Music prompt."
          },
          "duration": {
            "type": "integer",
            "minimum": 5,
            "maximum": 360
          },
          "mode": {
            "type": "string",
            "enum": [
              "stream",
              "async"
            ],
            "default": "stream"
          },
          "output_format": {
            "type": "string",
            "enum": [
              "m4a",
              "wav",
              "mp3"
            ],
            "default": "m4a",
            "description": "`m4a` (default, AAC), `wav` (16-bit PCM) or `mp3` (320 kbps CBR). `wav` and `mp3` are transcoded server-side after generation and require mode=async — the streaming response is always m4a. The delivered MIME is echoed in `audio[].content_type`."
          },
          "variants_num": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "default": 1,
            "description": "How many distinct music variants to generate in one request. Each variant is a separate creative direction, returned as its own entry in the async result's `audio` array. Cost scales linearly — `variants_num=3` costs three times a single-variant request. Values above 1 require mode=async; the default of 1 is valid in stream mode. Values above 1 are never covered by the free trial and are always billed."
          },
          "segments": {
            "type": "string",
            "description": "JSON string with segment prompts."
          },
          "stems": {
            "type": "boolean",
            "default": false,
            "description": "Off by default. Set to true to also return a `stems` array — each generated track split into four separated instrument tracks (`drums`, `bass`, `vocals`, `other`), each normally following the requested `output_format`, with `content_type` reporting what was actually delivered — alongside the clean `audio`. Free of charge. Requires mode=async. Separation runs after generation and typically adds 2-6 min to the wait and gives up after 30 min; that ceiling is reached by a retry or a busy queue rather than by track length, so do not size a client timeout off the duration. It never fails the task: if separation fails, `audio` is unaffected and the response carries `stems_error` — on its own, or alongside the streams that did separate."
          }
        }
      },
      "VideoToMusicRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Max file size: default 300MB. Max duration: 6 minutes."
          },
          "video_url": {
            "type": "string",
            "description": "Max file size: default 300MB. Max duration: 6 minutes."
          },
          "prompt": {
            "type": "string"
          },
          "segments": {
            "type": "string"
          },
          "mode": {
            "type": "string",
            "enum": [
              "stream",
              "async"
            ],
            "default": "stream"
          },
          "output_format": {
            "type": "string",
            "enum": [
              "m4a",
              "wav",
              "mp3"
            ],
            "default": "m4a",
            "description": "`m4a` (default, AAC), `wav` (16-bit PCM) or `mp3` (320 kbps CBR). Applies to `audio`, to the `ducked` track when ducking ran, to the `mux` track when `preserve_speech` is used, and normally to every entry in `stems` (each stem's `content_type` reports what was actually delivered); the isolated `vocals` stem `preserve_speech` returns is unaffected. `wav` and `mp3` are transcoded server-side after generation and require mode=async — the streaming response is always m4a. The delivered MIME is echoed in `audio[].content_type`."
          },
          "preserve_speech": {
            "type": "boolean"
          },
          "ducking": {
            "type": "boolean",
            "default": false,
            "description": "Off by default. Set to true to also return a `ducked` audio track — the generated music dipped under the video's speech so dialogue stays intelligible — alongside the clean `audio`. Free of charge. Requires mode=async."
          },
          "prompt_influence": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "default": 0.5,
            "description": "How strongly the generated music follows the prompt: lower values let the video lead, higher values follow the prompt more literally. Omit to keep the default behavior. Free of charge."
          },
          "variants_num": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "default": 1,
            "description": "How many distinct music variants to generate in one request. Each variant is a separate creative direction, returned as its own entry in the async result's `audio` array. Cost scales linearly — `variants_num=3` costs three times a single-variant request. Values above 1 require mode=async; the default of 1 is valid in stream mode. Values above 1 are never covered by the free trial and are always billed."
          },
          "stems": {
            "type": "boolean",
            "default": false,
            "description": "Off by default. Set to true to also return a `stems` array — the generated music split into four separated instrument tracks (`drums`, `bass`, `vocals`, `other`), each normally following the requested `output_format`, with `content_type` reporting what was actually delivered — alongside the clean `audio`. It splits the generated music, never the video's own audio. Free of charge. Requires mode=async. Separation runs after generation and typically adds 2-6 min to the wait and gives up after 30 min; that ceiling is reached by a retry or a busy queue rather than by track length, so do not size a client timeout off the duration. It never fails the task: if separation fails, `audio` is unaffected and the response carries `stems_error` — on its own, or alongside the streams that did separate."
          }
        }
      },
      "VideoToVideoMusicRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Accepted formats: mp4, mov, m4v, or webm carrying H.264, H.265/HEVC, VP9, or AV1 video. The source picture is copied into the result without re-encoding, so animated gif and VP8 webm are not supported. Max 300MB by default; max duration 360 seconds."
          },
          "video_url": {
            "type": "string",
            "description": "Downloaded before generation starts, so the same accepted formats, 300MB size limit, and 360-second duration limit apply as for video. An over-size download is rejected with 413."
          },
          "prompt": {
            "type": "string"
          },
          "segments": {
            "type": "string"
          },
          "preserve_speech": {
            "type": "boolean",
            "description": "Isolates the source video's speech and keeps only that as the voice source. Superseded by keep_original_sound: when that is also set, the whole original track is kept as the voice source instead of just the isolated speech."
          },
          "isolate_vocals": {
            "type": "boolean",
            "description": "Legacy alias for preserve_speech; the two are ORed together, not separate behaviors."
          },
          "ducking": {
            "type": "boolean",
            "default": false,
            "description": "Selects how the voice source combines with the generated music in the returned video's audio: off (default) mixes it in statically, on ducks it dynamically under the music. The voice source is the whole original track when keep_original_sound is set, the isolated vocals when only preserve_speech is set, or nothing at all when neither is set — with no voice source, ducking has no effect and the returned audio is the generated music alone. Best-effort: falls back to music-only audio if voice isolation or the duck/mix pass fails."
          },
          "keep_original_sound": {
            "type": "boolean",
            "default": false,
            "description": "Keeps the whole source video's audio track as the voice source, instead of the isolated vocals (preserve_speech) or no voice source at all. Defaults to false: by default the returned video's audio is the generated music alone, with the source's own audio removed. Supersedes preserve_speech when both are set."
          },
          "prompt_influence": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "default": 0.5,
            "description": "How strongly the generated music follows the prompt: lower values let the video lead, higher values follow the prompt more literally. Omit to keep the default behavior. Free of charge."
          },
          "variants_num": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "default": 1,
            "description": "How many distinct music variants to generate in one request. Each variant is a separate creative direction, returned as its own entry in the async result's `videos` array. Cost scales linearly — `variants_num=3` costs three times a single-variant request. Values above 1 are never covered by the free trial and are always billed."
          }
        }
      },
      "TextToSfxRequest": {
        "type": "object",
        "required": [
          "prompt"
        ],
        "properties": {
          "prompt": {
            "type": "string"
          },
          "duration": {
            "type": "number",
            "minimum": 0.5,
            "maximum": 180,
            "default": 8
          },
          "audio_format": {
            "type": "string"
          }
        }
      },
      "VideoToSfxRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "video_url": {
            "type": "string",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "prompt": {
            "type": "string"
          },
          "segments": {
            "type": "string"
          },
          "audio_format": {
            "type": "string"
          }
        }
      },
      "VideoToVideoSfxRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "video_url": {
            "type": "string",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "prompt": {
            "type": "string"
          },
          "segments": {
            "type": "string"
          }
        }
      },
      "VideoToSoundRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "video_url": {
            "type": "string",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "music_prompt": {
            "type": "string"
          },
          "sfx_prompt": {
            "type": "string"
          },
          "segments": {
            "type": "string"
          },
          "preserve_speech": {
            "type": "boolean"
          },
          "isolate_vocals": {
            "type": "boolean",
            "description": "Legacy alias for preserve_speech; the two are ORed together, not separate behaviors."
          },
          "ducking": {
            "type": "boolean",
            "default": false,
            "description": "Off by default, in which case the video's own speech is not carried into the mix at all and the deliverable is the generated music and sound effects alone. Set to true to keep that speech and dip the generated music under it before the music is combined with the sound effects. Ignored when preserve_speech is used, which keeps the isolated vocals as the voice source instead. Free of charge."
          },
          "output_format": {
            "type": "string",
            "enum": [
              "wav",
              "m4a",
              "mp3"
            ],
            "default": "wav",
            "description": "`wav` (default, 16-bit PCM), `m4a` (AAC) or `mp3` (320 kbps CBR). Sets the container of the combined `output_url` track and is echoed in its `content_type`."
          },
          "variants_num": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "default": 1,
            "description": "How many distinct soundtrack variants to generate in one request. Each variant is a separate creative direction, returned as its own entry in the async result's `outputs` array. Cost scales linearly — `variants_num=3` costs three times a single-variant request. Values above 1 are never covered by the free trial and are always billed."
          }
        }
      },
      "VideoToVideoSoundRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "video_url": {
            "type": "string",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "music_prompt": {
            "type": "string"
          },
          "sfx_prompt": {
            "type": "string"
          },
          "segments": {
            "type": "string"
          },
          "preserve_speech": {
            "type": "boolean",
            "description": "Isolates the source video's speech and keeps only that as the voice source. Superseded by keep_original_sound: when that is also set, the whole original track is kept as the voice source instead of just the isolated speech."
          },
          "isolate_vocals": {
            "type": "boolean",
            "description": "Legacy alias for preserve_speech; the two are ORed together, not separate behaviors."
          },
          "ducking": {
            "type": "boolean",
            "default": false,
            "description": "Selects how the voice source combines with the generated music and sound effects in the returned video's audio: off (default) mixes it in statically, on ducks it dynamically. The voice source is the whole original track when keep_original_sound is set, the isolated vocals when only preserve_speech is set, or nothing at all when neither is set — with no voice source, ducking has no effect and the returned audio is the generated music and sound effects alone. Best-effort: falls back to that alone if voice isolation or the duck/mix pass fails."
          },
          "keep_original_sound": {
            "type": "boolean",
            "default": false,
            "description": "Keeps the whole source video's audio track as the voice source, instead of the isolated vocals (preserve_speech) or no voice source at all. Defaults to false: by default the returned video's audio is the generated music and sound effects alone, with the source's own audio removed, and the result carries no music_processed stem (there is no voice source to process). Supersedes preserve_speech when both are set."
          },
          "variants_num": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "default": 1,
            "description": "How many distinct soundtrack variants to generate in one request. Each variant is a separate creative direction, returned as its own entry in the async result's `outputs` array. Cost scales linearly — `variants_num=3` costs three times a single-variant request. Values above 1 are never covered by the free trial and are always billed."
          }
        }
      },
      "ProofreadRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Provide either video or video_url, not both. The video must contain an audio track. Max file size: default 300MB. Max duration: 300 sec."
          },
          "video_url": {
            "type": "string",
            "description": "Provide either video or video_url, not both. The video must contain an audio track. Max file size: default 300MB. Max duration: 300 sec."
          },
          "languages": {
            "type": "string",
            "description": "Optional JSON array of target language codes, e.g. [\"ja\",\"zh_cn\"]. Same codes as Video translation. Omit it, or send [], to get the source-language transcript only. Billed per language: two languages cost twice a transcript-only request."
          },
          "source_language": {
            "type": "string",
            "description": "Optional. Tells transcription which language to expect, which helps on short, noisy or mixed-language audio. Omit it to have the language detected. Either way, source_language in the task response reports the language the transcript is in, and the source-language subtitle file is keyed by that code."
          }
        }
      },
      "DubbingRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary"
          },
          "video_url": {
            "type": "string",
            "description": "Must use https when supplied instead of video."
          },
          "languages": {
            "type": "string",
            "description": "JSON array of language codes. Defaults to a standard set when omitted."
          },
          "ducking": {
            "type": "boolean",
            "default": false,
            "description": "Off by default: the dubbed voice is mixed over the bed unchanged. Set to true to apply Sonilo ducking — the background music/effects bed is lowered while the dubbed voice is speaking."
          },
          "lipsync": {
            "type": "boolean",
            "default": true,
            "description": "On by default: the speaker's mouth is re-rendered to match the dubbed speech. Set to false to leave the picture completely untouched instead — the video is returned at its original resolution and frame rate rather than re-rendered, and only the audio is replaced, so the mouths keep moving to the original language."
          },
          "subtitles": {
            "type": "object",
            "additionalProperties": {
              "oneOf": [
                {
                  "type": "string",
                  "format": "binary"
                },
                {
                  "type": "string",
                  "format": "uri"
                }
              ]
            },
            "description": "One script per requested language, sent as subtitles[<language>]: an uploaded .srt/.vtt file (max 1 MiB) or a public https URL. Validated before billing — a rejected script returns 422 with code SUBTITLE_PREFLIGHT_BLOCKED and the reports keyed by language under details."
          },
          "export_srt": {
            "type": "boolean",
            "default": false,
            "description": "Off by default. Requires subtitles[<language>]. Returns every language's lines re-timed against its dubbed audio as subtitles on the finished task."
          }
        }
      },
      "VideoAnalysisRequest": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "format": "binary",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "video_url": {
            "type": "string",
            "description": "Max file size: default 300MB. Max duration: 480 sec."
          },
          "prompt": {
            "type": "string",
            "maxLength": 2000
          },
          "variants_num": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5,
            "default": 1,
            "description": "How many distinct creative-brief variations to generate. Cost scales linearly with the count."
          },
          "mode": {
            "type": "string",
            "enum": [
              "both",
              "music",
              "sfx"
            ],
            "default": "both",
            "description": "Which brief to write. both (default): a music-direction brief in segments/variations plus a sound-design brief in sfx_segments/sfx_prompt, in one call. music: the scoring plan only. sfx: the sound-design plan only. All three cost the same."
          }
        }
      },
      "AudioDuckingRequest": {
        "type": "object",
        "properties": {
          "voice_file": {
            "type": "string",
            "format": "binary"
          },
          "voice_url": {
            "type": "string"
          },
          "music_file": {
            "type": "string",
            "format": "binary"
          },
          "music_url": {
            "type": "string"
          }
        }
      },
      "TaskAccepted": {
        "type": "object",
        "required": [
          "task_id",
          "status"
        ],
        "properties": {
          "task_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "processing"
            ]
          }
        }
      },
      "TaskStatus": {
        "type": "object",
        "required": [
          "task_id",
          "status"
        ],
        "properties": {
          "task_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "processing",
              "succeeded",
              "failed"
            ]
          },
          "audio": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaResult"
            }
          },
          "stems": {
            "type": "array",
            "items": {
              "type": "object",
              "description": "One stream's four separated stems. The `vocals` here is a separated part of the GENERATED music; it is not the same thing as the `vocals` a `preserve_speech` request returns, which is speech isolated from the source video.",
              "properties": {
                "stream_index": {
                  "type": "integer"
                },
                "drums": {
                  "$ref": "#/components/schemas/MediaResult"
                },
                "bass": {
                  "$ref": "#/components/schemas/MediaResult"
                },
                "vocals": {
                  "$ref": "#/components/schemas/MediaResult"
                },
                "other": {
                  "$ref": "#/components/schemas/MediaResult"
                }
              },
              "required": [
                "stream_index",
                "drums",
                "bass",
                "vocals",
                "other"
              ]
            },
            "description": "Present only when the request set `stems=true`: one entry per stream that separated successfully. Look entries up by `stream_index` rather than by position — a stream whose separation failed is absent, so this array can be shorter than `audio`."
          },
          "stems_error": {
            "type": "string",
            "description": "Present when stem separation failed, wholly or in part, or was skipped. The task still succeeded and `audio` is unaffected — this names how many streams failed to separate and which, or reports that separation was skipped entirely. It can arrive alongside a shorter `stems` array rather than instead of one."
          },
          "video": {
            "$ref": "#/components/schemas/MediaResult"
          },
          "videos": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaResult"
            },
            "description": "video-to-video-music / video-to-video-sfx: the scored video, one entry per variant. `video` is an alias for the first entry."
          },
          "outputs": {
            "description": "Two different shapes, never at once. Dubbing: an object mapping language code to dubbed video URL. video-to-sound / video-to-video-sound: an array with one entry per variant, each carrying `variant_index`, `output_url`, `output_type`, `output_bytes`, `music` and `sfx`, plus `music_processed` when a voice source was in play; the top-level `output_url`, `music` and `sfx` are aliases for the first entry.",
            "oneOf": [
              {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                }
              },
              {
                "type": "array",
                "items": {
                  "type": "object"
                }
              }
            ]
          },
          "subtitles": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Dubbing with export_srt only: an object mapping language code to a temporary download URL for that language's re-timed .srt."
          },
          "subtitle_preflight": {
            "type": "object",
            "description": "Dubbing with subtitle scripts only: per language, the validation report — status, cue_count, issues, changes_count and report_url."
          },
          "trial_preview": {
            "type": "object",
            "description": "Dubbing only, when the run was the account's free 15-second preview: preview_seconds, source_duration_seconds, trimmed, languages, full_video_cost_usd (what translating the full video costs) and message. Present in every task state.",
            "properties": {
              "preview_seconds": { "type": "integer" },
              "source_duration_seconds": { "type": "number" },
              "trimmed": { "type": "boolean" },
              "languages": { "type": "integer" },
              "full_video_cost_usd": { "type": "number" },
              "message": { "type": "string" }
            }
          },
          "subtitle_export": {
            "type": "object",
            "description": "Dubbing with export_srt only: per language, an object with status (exported, exported_review_required or blocked), issues, alignment_loss, error and report_url; a blocked export never fails the task."
          },
          "output_url": {
            "type": "string",
            "description": "video-to-sound and video-to-video-sound only: URL of the combined output."
          },
          "output_type": {
            "type": "string",
            "enum": [
              "audio",
              "video"
            ],
            "description": "video-to-sound and video-to-video-sound only."
          },
          "output_bytes": {
            "type": "integer",
            "description": "video-to-sound and video-to-video-sound only: size in bytes of the file at output_url."
          },
          "music": {
            "$ref": "#/components/schemas/MediaResult"
          },
          "sfx": {
            "$ref": "#/components/schemas/MediaResult"
          },
          "music_processed": {
            "$ref": "#/components/schemas/MediaResult"
          },
          "mode": {
            "type": "string",
            "enum": [
              "both",
              "music",
              "sfx"
            ],
            "description": "video-analysis only: which brief the result carries, echoed from the request. both adds sfx_segments and sfx_prompt to segments and variations."
          },
          "segments": {
            "type": "array",
            "description": "video-analysis only.",
            "items": {
              "$ref": "#/components/schemas/VideoAnalysisSegment"
            }
          },
          "variations": {
            "type": "array",
            "description": "video-analysis only.",
            "items": {
              "$ref": "#/components/schemas/VideoAnalysisVariation"
            }
          },
          "sfx_segments": {
            "type": "array",
            "description": "video-analysis, mode both only: the sound-design segment plan, one shot-sized entry per segment; label is always none.",
            "items": {
              "$ref": "#/components/schemas/VideoAnalysisSegment"
            }
          },
          "sfx_prompt": {
            "type": "string",
            "description": "video-analysis, mode both only: the whole-clip sound-design prompt. Authored once regardless of variants_num."
          },
          "error": {
            "$ref": "#/components/schemas/Error"
          }
        }
      },
      "VideoAnalysisSegment": {
        "type": "object",
        "properties": {
          "start": {
            "type": "number",
            "description": "Seconds."
          },
          "end": {
            "type": "number",
            "description": "Seconds."
          },
          "label": {
            "type": "string"
          },
          "prompt": {
            "type": "string"
          }
        }
      },
      "VideoAnalysisVariation": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "Full music-generation prompt for this scoring direction."
          }
        },
        "required": [
          "prompt"
        ]
      },
      "MediaResult": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string"
          },
          "content_type": {
            "type": "string"
          },
          "file_size": {
            "type": "integer"
          },
          "stream_index": {
            "type": "integer"
          }
        }
      },
      "AccountServices": {
        "type": "object",
        "properties": {
          "available_services": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "rpm_limit": {
            "type": "integer"
          },
          "concurrency_limit": {
            "type": "integer"
          },
          "discount_factor": {
            "type": "number"
          },
          "max_upload_size_mb": {
            "type": "integer"
          }
        }
      },
      "AccountUsage": {
        "type": "object",
        "properties": {
          "summary": {
            "type": "object"
          },
          "daily": {
            "type": "array",
            "items": {
              "type": "object"
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "details": {
            "type": "object",
            "description": "Optional structured context; for a rejected dubbing subtitle script, the validation report."
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid request.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key. Send Authorization: Bearer <SONILO_API_KEY> from server-side code.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "payment_required. If message contains Insufficient balance, classify as insufficient_balance and do not retry until balance is added. A POC trial key returns the same insufficient_balance code with a different message that does not contain that substring, so treat the code field as authoritative, not the message text.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "code": "payment_required",
              "message": "Insufficient balance: balance=0.0000 < needed=0.0675."
            }
          }
        }
      },
      "UnprocessableEntity": {
        "description": "unprocessable_entity. Valid JSON or form data that cannot be processed — an over-limit duration, or a source with no video track. Every endpoint taking video or video_url requires a real video stream, so an audio-only file is rejected before anything is generated or billed; renaming an .m4a or .mp3 to .mp4 does not add one, and embedded cover art (a single still frame) does not count as one. Never retry unchanged.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "noVideoStream": {
                "summary": "Source carries no video track",
                "value": {
                  "code": "unprocessable_entity",
                  "message": "This file has no video track. video-to-* endpoints need a real video; provide a video file or a URL that serves one."
                }
              },
              "durationExceeded": {
                "summary": "Video longer than the endpoint's limit",
                "value": {
                  "code": "unprocessable_entity",
                  "message": "Video duration 370.0s exceeds maximum of 360s"
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "rate_limit_exceeded. Two limits return this status: the per-minute request limit and the concurrent-generation limit. The message says which one was hit — slow down for the first, wait for a running generation to finish for the second. Respect Retry-After when present. To raise either limit, contact info@sonilo.com.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "requestsPerMinute": {
                "summary": "Per-minute request limit",
                "value": {
                  "code": "rate_limit_exceeded",
                  "message": "Rate limit exceeded: your account allows 60 requests per minute. Please retry after 1 minute. To raise your limit, please contact info@sonilo.com."
                }
              },
              "concurrentGenerations": {
                "summary": "Concurrent generation limit",
                "value": {
                  "code": "rate_limit_exceeded",
                  "message": "Too many concurrent generations: 5 of 5 in progress. Please wait for one to finish before starting another. To raise your limit, please contact info@sonilo.com."
                }
              }
            }
          }
        }
      },
      "UpstreamError": {
        "description": "Transient upstream generation failure.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Endpoint or task not found. Recheck the canonical endpoint map in https://platform.sonilo.com/openapi.md before guessing alternate paths.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "A requested optional capability is not configured on this deployment — e.g. `stems=true` where the stem-separation service is unavailable. The request was not charged; retry without it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
