# Video & Audio to Translated Subtitles (`lumaxys/professional-subtitle-formatter`) Actor

Turn audio and video into accurate, professionally formatted subtitles. Automatically transcribe, translate into multiple languages, and export ready-to-use SRT, VTT, TXT, and JSON files. No API keys required.

- **URL**: https://apify.com/lumaxys/professional-subtitle-formatter.md
- **Developed by:** [François Fernandez](https://apify.com/lumaxys) (community)
- **Categories:** AI, Developer tools, Videos
- **Stats:** 3 total users, 2 monthly users, 40.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $30.00 / 1,000 transcribed minutes

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are a software tools running on the Apify platform, for all kinds of web data extraction and automation use cases.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

In JavaScript/TypeScript projects, use official [JavaScript/TypeScript client](https://docs.apify.com/api/client/js/docs.md):

```bash
npm install apify-client
```

In Python projects, use official [Python client library](https://docs.apify.com/api/client/python/docs.md):

```bash
pip install apify-client
```

In shell scripts, use [Apify CLI](https://docs.apify.com/cli/docs.md):

````bash
# MacOS / Linux
curl -fsSL https://apify.com/install-cli.sh | bash
# Windows
irm https://apify.com/install-cli.ps1 | iex
```bash

In AI frameworks, you might use the [Apify MCP server](https://docs.apify.com/integrations/mcp.md).

If your project is in a different language, use the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).


# README

## Video & Audio to Translated Subtitles — documentation technique interne


> **V16 — interface clarified:** The Actor accepts either audio or video, detects the source language automatically by default, and translates when one or more output languages are selected. The processing engine and billing logic are unchanged from the validated V15.6 release.

> **Free readiness check:** Running the Actor with no media source performs a zero-cost health check. It does not call transcription or translation providers and does not charge pay-per-event events. Upload a file or provide a direct media URL for real processing.


> Document de travail développeur. Le README commercial Apify sera rédigé
> lors de la passe de publication.

### Vue d'ensemble

Pipeline : mots horodatés (STT) → `subtitle_engine.format_subtitles()` →
`SubtitleCue` validés → writers (`subtitle_writer`). Le moteur garantit :
durées strictement positives, débuts strictement croissants, aucun
chevauchement, couverture du dernier mot, arrondi milliseconde sûr.

### API publique des writers (`src/subtitle_writer.py`)

```python
write_srt(cues, *, include_speaker_labels=False) -> str
write_vtt(cues, *, include_speaker_labels=False, include_cue_identifiers=False) -> str
write_txt(cues, *, include_speaker_labels=False, preserve_cue_breaks=True) -> str
write_json(cues, *, metadata: ExportMetadata | None = None) -> str
export_all(cues, *, basename, metadata=None, include_speaker_labels=False,
           formats=None) -> dict[str, str]          # rendu en mémoire
write_export_files(output_dir, basename, cues, *, metadata=None,
                   formats=None, include_speaker_labels=False)
                   -> dict[ExportFormat, Path]      # écriture locale UTF-8
````

Les writers **sérialisent uniquement** : aucune re-segmentation, aucune
modification linguistique, aucune réparation silencieuse.

### Les quatre formats

- **SRT** : indices réémis séquentiellement depuis 1, `HH:MM:SS,mmm`,
  ligne vide entre cues, fin de fichier `\n` unique, heures > 23 permises.
- **WebVTT** : en-tête `WEBVTT` + ligne vide, `HH:MM:SS.mmm`,
  identifiants de cue optionnels (désactivés par défaut), pas de style CSS.
- **TXT** : deux modes. `preserve_cue_breaks=True` : un bloc par cue, sauts
  de ligne internes conservés. `False` (continu) : cues joints par espaces
  en paragraphes ; nouveau paragraphe sur changement de locuteur ou
  silence ≥ 1,0 s.
- **JSON** : sortie structurée principale (voir plus bas).

### Labels de locuteurs

Format unique documenté : SRT/VTT préfixent la première ligne par
`[SPEAKER_ID]` (identifiant brut en majuscules) ; TXT groupe les cues
consécutifs d'un même locuteur sous un en-tête `SPEAKER_ID:`. Le label
n'apparaît que si un locuteur est réellement présent. En JSON, le locuteur
reste dans son champ dédié et le texte n'est **jamais** préfixé.

### Structure JSON (schema\_version 1.0)

```json
{
  "schema_version": "1.0",
  "metadata": {
    "source_language": "fr" | null,
    "duration_seconds": 125.424 | null,
    "cue_count": 14,
    "speaker_count": 2,
    "timing_reconstructed": false,
    "profile": "documentary" | null,
    "source_filename": "interview.mp4" | null,
    "generated_at": "…"        // présent UNIQUEMENT si fourni explicitement
  },
  "cues": [ { "index", "start", "end", "duration", "start_timestamp",
              "end_timestamp", "text", "lines", "speaker",
              "character_count", "characters_per_second" } ]
}
```

Ordre des champs stable, UTF-8 non échappé, nombres en secondes (3
décimales), timestamps lisibles au format VTT, `NaN`/infini rejetés.
Sans `generated_at` fourni, la sortie est totalement déterministe.
Les listes de mots ne sont pas exportées : `SubtitleCue` ne les conserve
pas (choix V1 documenté).

### Politique d'arrondi et précision milliseconde

Base temporelle unique : chaque borne est convertie une seule fois en
millisecondes entières (`_to_ms`), utilisées à la fois pour la validation,
les timestamps SRT/VTT **et les champs numériques JSON** — d'où
`duration == end - start` exactement à la précision d'export. Report
complet (999,6 ms → seconde suivante → minutes → heures ; jamais
`00:00:60,000`). Une valeur négative ≥ −0,5 ms (arrondi flottant) est
ramenée à 0 ; toute valeur réellement négative, `NaN` ou infinie lève
`SubtitleExportError`.

**Validation après arrondi** : un cue positif en flottants mais de durée
nulle à la milliseconde est **refusé** (exception explicite avec les
valeurs flottantes et les millisecondes — jamais d'extension silencieuse
de 1 ms), et l'absence de chevauchement est revalidée sur les
millisecondes exportées.

### Erreurs et règles de validation

`SubtitleExportError` (dans `src/exceptions.py`, code `EXPORT_ERROR`) est
levée pour : cues non ordonnés, chevauchement (en flottants ou après
arrondi), `end <= start` (en flottants ou en millisecondes), timestamp
négatif ou non fini, texte vide, plus de 2 lignes, caractère de contrôle,
locuteur invalide, `media_duration_seconds` négatif ou non fini. Le
message indique l'index du cue, la valeur fautive et la règle violée.

**Caractères de contrôle** : chaque élément de `cue.lines` représente
exactement une ligne physique — `\n`, `\r`, tabulation, NUL, contrôles
C0/C1 et séparateurs Unicode U+2028/U+2029 sont **refusés** (la tabulation
est refusée, pas remplacée — choix le plus sûr). Le texte Unicode normal
et les emojis restent acceptés. Un identifiant de locuteur doit être une
valeur courte mono-ligne : mêmes interdictions, plus le refus des valeurs
composées uniquement d'espaces ; les espaces internes sont autorisés
(`Speaker 1` est valide).

**Sélection de formats** : `formats=None` signifie « tous les formats par
défaut » ; `formats=set()` signifie « ne rien produire » (`{}` retourné,
aucun fichier écrit ; le dossier de sortie est tout de même créé —
comportement documenté et testé).

### Indicateur `timing_reconstructed`

Les modèles ne portant pas cette information, la modification minimale est
un helper pur dans le moteur :
`subtitle_engine.timing_was_reconstructed(mots_bruts) -> bool` — vrai si un
mot source n'a aucun signal temporel utilisable (durée < 20 ms) ou si deux
mots consécutifs se chevauchent au-delà du seuil de réparation par point
milieu (0,2 s). L'appelant le transmet via
`ExportMetadata(timing_reconstructed=…)`.

### Noms de fichiers

`sanitize_basename()` : suppression du répertoire et de l'extension,
translittération des accents, minuscules, tirets, 80 caractères max,
repli sur `export`. Aucun séparateur de chemin ni `..` possible en sortie —
la traversée de répertoire est structurellement impossible.

### Tests

```bash
pytest -v        # moteur + writers
mypy src/ --strict
```

### Analyse locale des médias (`src/media_probe.py`)

Porte obligatoire **avant tout appel payant** : inspection et validation
uniquement — aucune extraction, aucune conversion, aucune écriture dans le
média, aucun téléchargement, aucune facturation réelle, et le fichier n'est
jamais chargé en mémoire.

**Dépendance système** : l'exécutable `ffprobe` (FFmpeg), appelé via
`subprocess` en liste d'arguments (`shell=False` — jamais de chaîne shell),
avec timeout configurable, sortie JSON bornée à 10 Mo et stderr nettoyé et
tronqué dans les messages d'erreur. Le chemin est passé **résolu en absolu**
comme dernier argument séparé : un nom commençant par `-` ne peut donc
jamais être interprété comme une option (ffprobe n'ayant pas de sentinelle
`--` fiable, c'est la stratégie sûre testée).

**API publique** :

```python
probe_media(path, *, limits: MediaProbeLimits | None = None,
            ffprobe_binary: str = "ffprobe") -> MediaProbeResult
```

`MediaProbeResult` expose : nom, taille, formats réels détectés (CSV
`format_name` normalisée — l'extension du fichier n'est jamais crue sur
parole), durée, pistes audio (codec, fréquence, canaux, débit, durée,
langue, indicateur default) et vidéo (codec, résolution, fréquence
d'images), piste audio principale, et minutes facturables estimées.

**Limites** (`MediaProbeLimits`) : taille ≤ 500 Mo par défaut (égalité
acceptée, vérifiée **avant** de lancer ffprobe) ; durée normale 60 min
configurable ; hard cap configurable **vers le bas uniquement** — la
constante interne `ABSOLUTE_MAX_DURATION_SECONDS = 10 800 s` (180 min) est
la règle commerciale non contournable : toute configuration au-dessus est
refusée à la construction (`InputError`), jamais ramenée en silence, et les
valeurs NaN/infinies sont rejetées. Le timeout ffprobe doit être fini,
strictement positif et ≤ `MAX_FFPROBE_TIMEOUT_SECONDS = 300 s`. La
présence d'une piste audio est **obligatoire et non configurable** (le
paramètre `require_audio_stream` a été supprimé de l'API : ce produit
transcrit de l'audio, une vidéo muette, une image ou un conteneur ne
contenant que des sous-titres/données ne doivent jamais atteindre l'étape
payante) ; un fichier audio seul est accepté ; sous-titres, données et
pièces jointes sont ignorés.

**Durées** : le résultat distingue `container_duration_seconds` (ce que
déclare le conteneur — diagnostic uniquement), `primary_audio_duration_seconds`
(la piste sélectionnée) et `processing_duration_seconds` — **LA** référence
unique pour la limite normale, le hard cap, la limite absolue et les
minutes facturables (`duration_seconds` reste un alias documenté de cette
valeur). Sélection : durée propre de la piste audio principale (y compris
le fallback `duration_ts × time_base`) → plus longue durée valide des
autres pistes audio (jamais cumulées) → durée du conteneur en dernier
recours ; `N/A`, vide, zéro, négatif, NaN et infini sont rejetés à chaque
étage. Conséquence : un conteneur de 30 s contenant une piste audio de
7 200 s est **refusé** sur la base des 7 200 s ; un conteneur de 7 200 s
avec 30 s d'audio est accepté et facturé 1 minute.
`estimated_billable_minutes = ceil(processing_duration/60)` (minimum 1).
Aucune facturation réelle à cette étape.

**Invariants garantis après un `probe_media()` réussi** :
`processing_duration_seconds > 0` et ≤ limite normale ≤ hard cap ≤ limite
absolue ; `estimated_billable_minutes == ceil(processing/60)` ;
`has_audio is True` ; au moins une piste audio ;
`primary_audio_stream_index` désigne une piste réellement présente.

**Piste principale** : déterministe — première piste `disposition.default == 1`, sinon première piste audio ; les pistes ne sont jamais mélangées.

**Erreurs** (famille `MediaProbeError`, sous `MediaError`) :
`MediaFileNotFoundError` (absent, vide, dossier), `MediaFileTooLargeError`,
`FFprobeNotFoundError`, `MediaProbeTimeoutError`, `FFprobeExecutionError`
(fichier corrompu/non reconnu), `InvalidFFprobeOutputError`,
`MediaHasNoAudioError`, `MediaDurationError`, `MediaDurationLimitError` —
chacune avec le fichier concerné, la valeur détectée et la limite violée.
Les erreurs système au lancement du binaire sont toutes typées :
`FileNotFoundError` → `FFprobeNotFoundError` ; `PermissionError` (binaire
présent mais non exécutable) et autres `OSError` → `FFprobeExecutionError`
avec message court (« Unable to launch ffprobe binary '…': permission
denied. ») et cause originale conservée (`from exc`).

**Tests** : la suite principale mocke `subprocess.run` avec des JSON
ffprobe réalistes (FFmpeg non requis) ; les tests `@pytest.mark.integration`
s'exécutent seulement si FFmpeg est installé et génèrent de petits médias
réels.

### Extraction audio (`src/audio_extractor.py`)

Transforme un média **déjà validé** en WAV prêt pour la transcription.
Aucun appel ElevenLabs, aucune transcription, aucune facturation, aucun
téléchargement à cette étape.

**Entrée** : exclusivement un `MediaProbeResult` issu de `probe_media()` —
jamais un chemin brut. L'extracteur re-vérifie les invariants (audio
présent, index principal valide, durée > 0 et ≤ limite absolue) et refuse
une source modifiée depuis le probe (comparaison de taille) : un objet
construit à la main ou corrompu est rejeté avant tout lancement de FFmpeg.

**Format de sortie (V1, imposé)** : WAV PCM 16 bits, mono, 16 000 Hz
(`pcm_s16le`). Conversion **systématique** quelle que soit la source —
robustesse avant économie : format unique et prévisible, aucun
comportement dépendant du codec source, tests déterministes. Une
optimisation « stream copy » pourra être étudiée plus tard.

**Sélection du stream** : `-map 0:<primary_audio_stream_index>` sur
l'index **global** du stream — exactement une piste, jamais mélangées,
jamais « toutes les pistes audio ».

**Commande FFmpeg** : liste d'arguments (`shell=False`), `-v error
-hide_banner -nostdin -n`, `-i` forçant l'argument suivant comme entrée
(un nom commençant par `-` reste un chemin), `-t` limité à
`processing_duration_seconds` (l'extraction ne peut jamais augmenter la
quantité facturable), `-f wav` explicite car la cible atomique
`.part-<uuid>` n'a pas d'extension reconnaissable, timeout configurable,
média jamais chargé en mémoire.

**Estimation de taille** : `durée × 16 000 × 1 × 2 octets + 1 024 o`
d'en-tête (≈ 1,92 Mo/min, 115 Mo/h, 346 Mo pour 3 h), exposée dans le
résultat et vérifiée **avant** FFmpeg quand `maximum_output_size_bytes`
est configuré.

**Réanalyse obligatoire** : le WAV produit repasse par `probe_media()`
(limites internes : plafond de durée = limite absolue de 180 min, jamais
affaiblie) puis validation stricte — codec, 16 kHz, mono, conteneur WAV
réel (pas l'extension), pas de piste vidéo, taille > en-tête nu, et durée
dans la tolérance documentée `min(max(tolérance_config, durée × 1 %),
5 s)` **dans les deux sens** (une sortie nettement plus courte signale un
média tronqué ou une mauvaise piste et est refusée aussi).

**Atomicité et nettoyage** : FFmpeg écrit vers `<nom>.part-<uuid>` unique
(pas de collision entre exécutions concurrentes) ; `os.replace()` vers le
chemin final **uniquement** après toutes les validations ; en cas d'échec
le partiel est supprimé et un fichier final préexistant conserve son
ancien contenu (écrasement refusé par défaut, autorisé via
`overwrite_existing=True`, re-vérifié avant le remplacement). Le context
manager `extracted_audio_tempfile()` supprime le WAV et son dossier
temporaire à la sortie du `with`, y compris quand le code utilisateur lève
une exception ; le nettoyage est tolérant et ne masque jamais l'erreur
principale.

**Erreurs** (famille `AudioExtractionError`) : `FFmpegNotFoundError`,
`FFmpegLaunchError` (permission/OSError, cause conservée),
`FFmpegTimeoutError`, `FFmpegExecutionError` (stderr nettoyé et tronqué),
`InvalidAudioExtractionInputError`, `AudioOutputValidationError`,
`AudioOutputTooLargeError`.

### Transcription ElevenLabs Scribe (`src/transcription.py`)

Envoie un WAV déjà validé à ElevenLabs Speech-to-Text et retourne les
objets `Word` normalisés attendus par le moteur de segmentation. Aucun
appel Apify, aucune facturation réelle, aucune traduction à cette étape.
Ce module n'appelle jamais le moteur de segmentation et ne supprime jamais
le WAV (ce rôle appartient à `extracted_audio_tempfile()`).

**Entrée** : un `AudioExtractionResult` issu de `audio_extractor.py`,
re-vérifié (fichier `.wav` présent, non vide, taille inchangée depuis
l'extraction, durée > 0 et ≤ limite absolue, format `pcm_s16le`/16 kHz/mono
déjà garanti). Un objet manifestement incohérent ou construit à la main est
refusé avant tout appel.

**Clé API** : jamais dans le code, un test, un log, une exception ou le
résultat JSON. Fournie via `TranscriptionConfig(api_key=…)` ou la variable
d'environnement `ELEVENLABS_API_KEY` (aucun autre nom lu). Elle est
enveloppée dans `SecretApiKey` dont `repr`/`str` affichent `***` ; le
`repr` de la configuration ne révèle donc jamais le secret.

**Estimation du coût (Decimal, avant appel)** :
`estimate_transcription_cost(durée, cost_per_audio_hour_usd=…)` calcule le
coût sur les **minutes potentiellement facturables**, `ceil(durée/60)`, et
non au prorata exact de la seconde — même approche prudente que
`estimated_billable_minutes` dans `media_probe`, car ElevenLabs peut
facturer à la granularité de la minute. Un clip de 10 s coûte donc une
minute pleine (0,003667 $ à 0,22 $/h), 61 s en coûtent deux. Calcul
`(minutes × tarif) / 60`, arrondi au supérieur (`ROUND_UP`, 6 décimales)
pour ne jamais sous-estimer. Le tarif horaire est **configurable,
injectable et strictement positif** (un tarif nul ou négatif est refusé,
il annulerait toute protection) — à revérifier au moment de la
publication. Si le coût estimé dépasse `maximum_cost_usd`, l'appel réseau
n'a **jamais** lieu (`TranscriptionBudgetExceededError`) ; un
`maximum_cost_usd = 0` est accepté et bloque alors tout appel payant. Ce
plafond local est obligatoire mais insuffisant seul : en production, le
plafond de dépenses côté compte ElevenLabs devra également être activé.

**Validation avant appel** : le WAV doit toujours exister, conserver la
taille validée, être en PCM 16 bits mono 16 kHz, rester sous le hard cap et
durer au moins **100 ms** (`MIN_TRANSCRIPTION_DURATION_SECONDS = 0.1`). Un
fichier plus court est refusé avant tout calcul budgétaire et avant tout
appel réseau.

**Client injectable** : `SpeechToTextClient` (Protocol) permet des tests
sans réseau ni crédits. L'implémentation réelle
`ElevenLabsSpeechToTextClient` utilise `httpx` directement (plutôt que le
SDK) pour maîtriser le timeout, le streaming multipart du fichier — envoyé
depuis un handle disque, jamais chargé en mémoire, fermé dans tous les cas
— et le mapping d'erreurs. Endpoint `POST /v1/speech-to-text`, en-tête
`xi-api-key`, `model_id` (`scribe_v2` par défaut), `timestamps_granularity=word`.

**Retries et double consommation** : sans mécanisme d'idempotence garanti
(la doc officielle n'en expose aucun ; `seed` et `webhook_metadata` n'en
sont pas), certaines erreurs survenant **après** le début de l'upload
laissent l'état de la requête inconnu — elles ne sont donc **pas
retentées par défaut** pour éviter une double facturation. Classification :

- *sûres, retentées* (l'échec précède clairement le traitement) :
  `ConnectTimeout`, `PoolTimeout`, `ConnectError` et HTTP 429 ;
- *ambiguës, non retentées par défaut* (`TranscriptionOutcomeUnknownError`)
  : `ReadTimeout`, `WriteTimeout`, déconnexion en cours d'upload, autres
  erreurs de transport au moment inconnu, **tous les HTTP 5xx** (y compris
  502, 503 et 504), et toute réponse 200 dont le parsing échoue ;
- *jamais retentées* : clé invalide (401/403), requête refusée (400/4xx),
  budget dépassé.

Le paramètre `retry_ambiguous_failures` (strictement booléen, **False par
défaut**) permet, en connaissance de cause, de retenter aussi les erreurs
ambiguës — au risque documenté d'une double consommation. Backoff
exponentiel borné `min(délai_initial × 2^n, délai_max)` avec jitter léger
injectable ; l'en-tête `Retry-After` (valeur numérique) est honoré quand
il est présent, borné par `retry_max_delay_seconds`. Nombre total d'appels
\= `1 + max_retries`. Les tests injectent `sleep`, rien ne bloque
réellement.

**Normalisation des tokens** : déterministe, ordre préservé, aucun mot
parlé supprimé, jamais de fusion entre deux locuteurs. Ponctuation
fermante (`. , ; : ! ? … ) ] } % » ”` + CJK) attachée au mot précédent,
ponctuation ouvrante (`( [ { « “` + CJK) au mot suivant, élisions
françaises (`l'`, `j'`, `qu'`) et traits d'union (`allez-vous`) recollés,
nombre + unité jamais fusionnés, caractères CJK conservés par caractère et
joints sans espace dans le texte complet. Les événements audio
(`tag_audio_events=False` par défaut) sont comptés et ignorés
(`ignored_audio_event_count`), jamais mélangés aux mots. Les timestamps
fusionnés prennent `start = min`, `end = max` ; un mot sans timestamp
hérite de l'horloge — les anomalies modérées sont transmises au moteur, qui
sait les réparer (`timing_reconstructed`).

**Locuteurs** : identifiants remappés de façon déterministe en
`speaker_0`, `speaker_1`… par ordre de première apparition ; caractères de
contrôle nettoyés ; `None` préservé.

**Silence et événements audio seuls** : une réponse valide sans parole
retourne `words=()`, `full_text=""`, `contains_speech=False` — ce n'est pas
une erreur. Une réponse ne contenant que des événements audio (musique,
applaudissements, rire) dont le texte global est justement ces libellés est
traitée de la même façon (silence valide, `ignored_audio_event_count`
correct), la décision reposant sur le **type officiel** du token et non sur
de simples parenthèses. En revanche, un texte global manifestement parlé
sans aucun mot parlé fourni (liste vide, ou uniquement un événement audio)
lève `InvalidTranscriptionResponseError`.

**Erreurs** (sous `TranscriptionError`) : `TranscriptionAuthenticationError`,
`TranscriptionRateLimitError`, `TranscriptionTimeoutError`,
`TranscriptionServiceUnavailableError`, `TranscriptionRequestError`,
`InvalidTranscriptionResponseError`, `TranscriptionBudgetExceededError`,
`TranscriptionOutcomeUnknownError` (issue incertaine après upload).
La probabilité de langue, quand elle est fournie, est validée strictement
dans `[0, 1]` (booléens refusés, chaîne numérique propre tolérée, NaN/
infini/hors plage refusés, jamais ramenée en silence à 0 ou 1).

**Écarts constatés avec la documentation officielle** (vérifiée en ligne) :
`scribe_v2` est le modèle recommandé actuel (et non `scribe_v1`, qui reste
accepté) ; côté API, `tag_audio_events` vaut **true par défaut**, donc la
valeur est toujours envoyée explicitement pour respecter notre défaut à
false ; `diarize` vaut false par défaut côté API ; taille de fichier max
5 Go, durée min 100 ms ; aucune clé d'idempotence documentée.

**Tests d'intégration réels** : `@pytest.mark.elevenlabs_integration`,
**désactivés par défaut**. Le premier (sinusoïde générée < 5 s) valide
authentification, upload, média sans parole et parsing dès que
`ELEVENLABS_API_KEY` est défini. Le second transcrit un clip **parlé**
fourni via `ELEVENLABS_TEST_AUDIO_PATH` (les deux variables doivent être
définies et le fichier exister) et vérifie qu'au moins un mot est retourné,
`contains_speech=True`, `full_text` non vide, timestamps présents et
compatibilité avec le moteur — sans jamais supposer une phrase exacte.
Aucun des deux n'affiche la clé, la transcription complète ni la réponse
brute ; seuls nombre de mots, langue, durée et présence de locuteurs sont
journalisés.

### Traduction contextuelle DeepL (`src/translation.py`)

Cette brique traduit des `SubtitleCue` déjà validés sans déplacer leurs
frontières source. Chaque cue est envoyé comme une entrée `text` distincte,
ce qui conserve une correspondance exacte avec ses timestamps ; les cues
voisins sont fournis via le paramètre DeepL `context`, partagé par toutes
les entrées du lot et non facturé. Les textes sont regroupés en lots
respectant une limite prudente de 50 entrées et la limite absolue officielle
de 128 Kio par corps de requête. Si le contexte fait dépasser cette limite,
il est réduit avant de refuser le texte source : le contexte est une aide de
qualité, jamais une raison de perdre une traduction.

**API publique principale** :

```python
translate_subtitle_cues(
    cues,
    *,
    config: TranslationConfig,
    formatting: FormattingConfig,
    client: TranslationClient | None = None,
) -> TranslationResult
```

**DeepL** : `POST /v2/translate`, authentification par
`Authorization: DeepL-Auth-Key …`, endpoint Free
`https://api-free.deepl.com` ou Pro `https://api.deepl.com`. La clé provient
de `TranslationConfig` ou de `DEEPL_API_KEY` et est encapsulée dans
`SecretDeepLKey` (`repr` et `str` masqués). La réponse est demandée avec
`show_billed_characters=true`. Le parser accepte le compteur facturé au
niveau racine ou, pour compatibilité, au niveau de chaque traduction.

**Contexte et qualité** : DeepL traduit indépendamment les éléments de
`text`; la fenêtre de dialogue adjacente est donc transmise par `context`.
`context_window_cues` et `max_context_characters` sont configurables. Les
retours sont normalisés en NFC et les espaces sont nettoyés avant le
reformatage. Un glossaire peut être fourni via `glossary_id`, mais exige
obligatoirement `source_language`, conformément à l'API DeepL.

**Préservation des timings** : une traduction courte conserve exactement le
cue source. Une traduction plus longue peut être découpée en plusieurs cues
à l'intérieur du même intervalle ; la durée est répartie de façon
déterministe selon le poids en caractères, avec au moins 1 ms par segment.
Aucun segment traduit ne traverse une frontière source, un silence ou un
changement de locuteur. Les locuteurs sont conservés. Pour le japonais et le
chinois, le découpage et l'habillage n'imposent pas d'espaces. Lorsque la
durée source ne permet pas de respecter le CPS cible, le résultat reste
synchronisé et `readability_warning_count` le signale.

**Quotas et coût** : DeepL mesure les caractères source en points de code
Unicode. `count_billable_characters()` reproduit ce calcul localement.
`max_total_source_characters` refuse les traitements trop importants avant
réseau. Avec `check_usage_before_translate=True`, `GET /v2/usage` vérifie le
quota restant avant le premier appel payant. La tarification monétaire varie
selon le contrat : aucun prix n'est inventé ni codé en dur.
`cost_per_million_characters_usd` et `maximum_cost_usd` sont des `Decimal`
optionnels et injectables. Le plafond est vérifié avant l'appel ; les
`billed_characters` réels servent ensuite au coût réel. Si le fournisseur
rapporte un dépassement après un premier lot, les lots restants sont arrêtés.
Pour un traitement en un seul lot, `budget_overrun=True` rend le dépassement
visible dans `TranslationResult`.

**Retries** : les HTTP 429 et les erreurs temporaires 5xx/réseau sont
retentés avec backoff exponentiel borné ; un `Retry-After` numérique est
honoré. Les erreurs d'authentification, de quota 456, de requête 4xx et de
réponse structurellement invalide ne sont pas retentées. Le nombre
`request_count` représente les tentatives réseau réelles, retries compris.

**Résultat** : `TranslationResult` expose les cues traduits, les langues,
les caractères source et facturés, coûts estimé/réel éventuels, nombre de
requêtes, avertissements de lisibilité, quota avant appel, usage du glossaire
et éventuel dépassement budgétaire.

**Test réel facultatif** : `@pytest.mark.deepl_integration`, ignoré par
défaut et exécuté uniquement lorsque `DEEPL_API_KEY` est volontairement
fourni. Les tests standards utilisent un client injectable et ne consomment
aucun caractère DeepL.

### Orchestrateur local complet (`src/pipeline.py`) — V14

La fonction publique suivante compose toutes les briques validées sans
réimplémenter leur logique :

```python
run_local_pipeline(
    source_path,
    *,
    output_parent,
    config: PipelineConfig,
    basename=None,
    transcription_client=None,
    translation_clients=None,
    ffmpeg_binary="ffmpeg",
    ffprobe_binary="ffprobe",
) -> PipelineResult
```

Chaîne exécutée :

```text
média local
→ probe_media
→ extracted_audio_tempfile
→ transcribe_audio
→ format_subtitles
→ exports originaux
→ traductions DeepL optionnelles
→ exports traduits
→ pipeline-manifest.json
→ publication atomique du dossier final
```

#### Organisation des sorties

```text
<output_parent>/<basename-nettoyé>/
├── pipeline-manifest.json
├── original/
│   ├── <basename>.srt
│   ├── <basename>.vtt
│   ├── <basename>.txt
│   └── <basename>.json
└── translations/
    └── en-us/
        ├── <basename>-en-us.srt
        ├── <basename>-en-us.vtt
        ├── <basename>-en-us.txt
        └── <basename>-en-us.json
```

`PipelineConfig` regroupe les configurations déjà validées :
`MediaProbeLimits`, `AudioExtractionConfig`, `TranscriptionConfig`,
`FormattingConfig` et zéro ou plusieurs `TranslationConfig`. Les langues
cibles doivent être uniques. Les formats de sortie sont typés avec
`ExportFormat`.

#### Garde-fous et coûts

- Le conflit de dossier de sortie est vérifié avant `ffprobe` et avant tout
  appel payant.
- Le budget de transcription est recalculé dès le résultat du probe, avant
  même l'extraction WAV ; `transcribe_audio` refait ensuite la même barrière.
- DeepL conserve ses propres limites de caractères, quota distant et plafond
  monétaire par langue.
- Le manifeste ne contient ni clé API, ni réponse brute fournisseur, ni texte
  intégral de transcription, ni request ID fournisseur.
- Les montants monétaires sont écrits comme chaînes décimales, jamais comme
  flottants JSON.

#### Sorties atomiques

Tous les fichiers sont créés dans un dossier temporaire voisin. Le dossier
final n'apparaît qu'après réussite de l'analyse, de l'extraction, de la
transcription, de la segmentation et des exports. Avec
`overwrite_output=True`, l'ancien dossier est renommé en sauvegarde avant la
bascule ; il est restauré si la publication échoue. Les liens symboliques de
sortie sont refusés.

Le WAV temporaire est supprimé à la sortie du context manager, y compris si
une étape suivante lève une exception.

#### Succès partiel des traductions

Une erreur DeepL sur une langue ne détruit jamais la transcription déjà
potentiellement facturée. Les fichiers originaux et les autres langues
réussies sont publiés avec :

```text
status = "partial"
```

Le manifeste enregistre le code et le message nettoyé de l'erreur. Il indique
également si une consommation DeepL ne peut pas être exclue. Les erreurs
clairement antérieures à la traduction (budget local, quota, authentification,
rate limit ou requête refusée) sont marquées sans coût de traduction attendu.

#### Exemple local

Les clés restent dans l'environnement :

```bash
export ELEVENLABS_API_KEY="..."
export DEEPL_API_KEY="..."
```

```python
from decimal import Decimal
from pathlib import Path

from src.models import FormattingConfig
from src.pipeline import PipelineConfig, run_local_pipeline
from src.transcription import TranscriptionConfig
from src.translation import TranslationConfig

config = PipelineConfig(
    transcription=TranscriptionConfig(
        maximum_cost_usd=Decimal("1.00"),
    ),
    formatting=FormattingConfig(),
    translation_configs=(
        TranslationConfig(
            target_language="EN-US",
            source_language="FR",
            check_usage_before_translate=True,
        ),
    ),
)

result = run_local_pipeline(
    Path("video.mp4"),
    output_parent=Path("outputs"),
    config=config,
)
print(result.status, result.output_root)
```

Cette V14 reste volontairement locale : aucun téléchargement d'URL, aucune
facturation Apify et aucun stockage distant ne sont encore intégrés.

***

## V15 — Intégration Apify

La V15 ajoute l'adaptateur de plateforme sans modifier les briques métier
validées. L'Actor accepte un fichier téléversé par l'éditeur `fileupload` ou
une URL directe publique, télécharge le média dans un dossier temporaire,
contrôle sa taille et sa durée, réserve la capacité de facturation, lance le
pipeline local, puis stocke les fichiers livrables dans le Key-Value Store et
un résumé dans le Dataset.

### Point d'entrée

```text
python -m src.main
```

Le point d'entrée utilise le SDK Python Apify 4.x et le cycle de vie recommandé
`async with Actor`.

### Secrets obligatoires

À configurer dans les variables d'environnement chiffrées de l'Actor :

```text
ELEVENLABS_API_KEY     obligatoire
DEEPL_API_KEY          obligatoire uniquement si targetLanguages n'est pas vide
DEEPL_API_TIER         free ou pro, défaut free
DEEPL_COST_PER_MILLION_CHARACTERS_USD  facultatif
```

Aucune clé ne doit être placée dans l'Input public, le dépôt, le README, les
logs ou les fichiers de sortie.

### Pay-Per-Event à configurer dans Apify Console

Les noms ci-dessous doivent correspondre exactement à ceux du code :

```text
actor-start
transcribed-minute
translated-minute
```

Prix de lancement proposés :

```text
actor-start         0,05 USD / run validé
transcribed-minute  0,03 USD / minute audio commencée
translated-minute   0,04 USD / minute audio / langue traduite
```

Le média est téléchargé et analysé avant toute facturation. Le code vérifie la
capacité de charge restante avant l'appel ElevenLabs. Une traduction vérifie
également la capacité avant DeepL, mais n'est facturée qu'après une réponse
DeepL réussie ; une erreur de quota ou d'authentification DeepL ne facture donc
pas l'utilisateur.

### Sécurité du téléchargement

- schémas autorisés : HTTP et HTTPS uniquement ;
- refus des identifiants intégrés à l'URL ;
- refus de localhost et des adresses privées, loopback, link-local, réservées,
  multicast ou non spécifiées ;
- revalidation de chaque redirection, maximum cinq ;
- lecture en flux, jamais de média complet en RAM ;
- limite stricte de 500 Mio, contrôlée par `Content-Length` puis pendant le
  flux ;
- suppression des fichiers incomplets ;
- refus des URLs de pages YouTube, TikTok et Instagram.

Cette protection réduit fortement le risque SSRF. Comme tout téléchargement
HTTP fondé sur le DNS, une protection réseau de plateforme reste recommandée
contre les attaques sophistiquées de rebinding DNS.

### Stockage des sorties

- fichiers SRT/VTT/TXT/JSON : Key-Value Store par défaut ;
- une ligne Dataset pour les fichiers originaux et une ligne supplémentaire par traduction, avec liens directs SRT/VTT/TXT/JSON ;
- résumé complet et imbriqué : record `OUTPUT` du Key-Value Store ;
- schéma de sortie : `.actor/output_schema.json`.

Le résumé contient les liens vers les fichiers originaux, les traductions,
les minutes facturables, les événements facturés et les erreurs nettoyées. Il
ne contient ni clé API, ni audio, ni réponse fournisseur brute.

### Docker

Le `Dockerfile` installe FFmpeg/ffprobe dans une image Python 3.12 slim, puis
les dépendances runtime :

```text
apify>=4.0,<5.0
httpx>=0.27,<1.0
```

### Développement local

Pour tester le mécanisme PPE local du SDK Apify :

```bash
ACTOR_TEST_PAY_PER_EVENT=true python -m src.main
```

Les tests unitaires de la V15 utilisent un Actor factice et ne consomment
aucun crédit fournisseur.

#### Private test before monetization

The Actor blocks provider calls when pay-per-event pricing is not enabled. For a private development test only, add the runtime environment variable `ALLOW_UNMONETIZED_RUNS=true`. In this mode, Apify event charges are skipped, but ElevenLabs and DeepL provider costs still apply. Remove this variable before publishing the Actor or enable pay-per-event pricing.

#### Private Apify file uploads

Files uploaded through the Console's `fileupload` input are stored as private
Key-Value Store records. The Actor recognizes only official Apify KVS record
URLs and authenticates them with the scoped `APIFY_TOKEN` injected by the
platform. This token is never forwarded to external hosts or across redirects
to non-Apify domains.

#### V15.5 — Direct translation download rows

The default Dataset now contains one flat row for the original-language files
and one flat row for each requested translation. The Apify Output table shows
SRT, WebVTT, TXT and JSON links directly for every language. The complete
nested run summary remains available in the `OUTPUT` Key-Value Store record for
API integrations.

Recommended initial PPE configuration:

```text
actor-start         0.05 USD per validated run
transcribed-minute  0.03 USD per started audio minute
translated-minute   0.04 USD per started audio minute and target language
```

For the safest launch margin, enable passing Apify platform usage costs to the
user. Remove `ALLOW_UNMONETIZED_RUNS` after PPE is active.

# Actor input Schema

## `mediaFile` (type: `string`):

Upload one audio or video file. The format is detected automatically. Use either this field or Direct audio/video URL, not both. Leave both empty only for the free readiness check.

## `mediaUrl` (type: `string`):

Direct public http(s) URL to an audio or video file. The format is detected automatically. YouTube, TikTok and Instagram page links are not supported. Leave both source fields empty only for the free readiness check.

## `profile` (type: `string`):

Choose the subtitle formatting rules: documentary for long-form content, social for shorter punchier cues, or accessibility for more readable SDH-style subtitles.

## `diarize` (type: `boolean`):

Detect and preserve speaker changes when the transcription service can identify multiple speakers.

## `includeSpeakerLabels` (type: `boolean`):

Prefix exported subtitle text with speaker labels when speaker detection is available.

## `languageCode` (type: `string`):

Optional 2- or 3-letter language code such as fr, en or ja. Leave empty to detect the spoken language automatically.

## `targetLanguages` (type: `array`):

Choose up to five languages for translated subtitles. Leave empty to generate subtitles only in the original detected language. Each selected language is charged per translated media minute.

## `outputFormats` (type: `array`):

Select one or more files to generate for the original transcription and each successful translation.

## `maxCharsPerLine` (type: `integer`):

Override the selected profile’s maximum number of visible characters on each subtitle line.

## `maxLinesPerSubtitle` (type: `integer`):

Override the selected profile’s maximum number of lines displayed in one subtitle cue.

## `maxCps` (type: `number`):

Override the selected profile’s maximum reading speed in characters per second.

## `minSubtitleDurationMs` (type: `integer`):

Override the minimum time, in milliseconds, that a subtitle remains visible.

## `maxSubtitleDurationMs` (type: `integer`):

Override the maximum time, in milliseconds, that a subtitle remains visible.

## `maxMediaMinutes` (type: `integer`):

Reject media whose selected audio track exceeds this duration. The absolute platform safety limit is 180 minutes.

## Actor input object example

```json
{
  "mediaUrl": "https://example.com/interview.mp4",
  "profile": "documentary",
  "diarize": false,
  "includeSpeakerLabels": false,
  "languageCode": "fr",
  "targetLanguages": [],
  "outputFormats": [
    "srt",
    "vtt",
    "txt",
    "json"
  ],
  "maxMediaMinutes": 60
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `files` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("lumaxys/professional-subtitle-formatter").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("lumaxys/professional-subtitle-formatter").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call lumaxys/professional-subtitle-formatter --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=lumaxys/professional-subtitle-formatter",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

```json
{
    "openapi": "3.0.1",
    "info": {
        "title": "Video & Audio to Translated Subtitles",
        "description": "Turn audio and video into accurate, professionally formatted subtitles. Automatically transcribe, translate into multiple languages, and export ready-to-use SRT, VTT, TXT, and JSON files. No API keys required.",
        "version": "0.5",
        "x-build-id": "SgaKx3Tlk1azO09pz"
    },
    "servers": [
        {
            "url": "https://api.apify.com/v2"
        }
    ],
    "paths": {
        "/acts/lumaxys~professional-subtitle-formatter/run-sync-get-dataset-items": {
            "post": {
                "operationId": "run-sync-get-dataset-items-lumaxys-professional-subtitle-formatter",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor, waits for its completion, and returns Actor's dataset items in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK"
                    }
                }
            }
        },
        "/acts/lumaxys~professional-subtitle-formatter/runs": {
            "post": {
                "operationId": "runs-sync-lumaxys-professional-subtitle-formatter",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor and returns information about the initiated run in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/runsResponseSchema"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/acts/lumaxys~professional-subtitle-formatter/run-sync": {
            "post": {
                "operationId": "run-sync-lumaxys-professional-subtitle-formatter",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor, waits for completion, and returns the OUTPUT from Key-value store in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK"
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "inputSchema": {
                "type": "object",
                "properties": {
                    "mediaFile": {
                        "title": "Audio or video file (upload)",
                        "type": "string",
                        "description": "Upload one audio or video file. The format is detected automatically. Use either this field or Direct audio/video URL, not both. Leave both empty only for the free readiness check."
                    },
                    "mediaUrl": {
                        "title": "Direct audio/video URL",
                        "pattern": "^https?:\\/\\/.+",
                        "type": "string",
                        "description": "Direct public http(s) URL to an audio or video file. The format is detected automatically. YouTube, TikTok and Instagram page links are not supported. Leave both source fields empty only for the free readiness check."
                    },
                    "profile": {
                        "title": "Subtitle style",
                        "enum": [
                            "documentary",
                            "social",
                            "accessibility"
                        ],
                        "type": "string",
                        "description": "Choose the subtitle formatting rules: documentary for long-form content, social for shorter punchier cues, or accessibility for more readable SDH-style subtitles.",
                        "default": "documentary"
                    },
                    "diarize": {
                        "title": "Detect speakers",
                        "type": "boolean",
                        "description": "Detect and preserve speaker changes when the transcription service can identify multiple speakers.",
                        "default": false
                    },
                    "includeSpeakerLabels": {
                        "title": "Include speaker labels in text exports",
                        "type": "boolean",
                        "description": "Prefix exported subtitle text with speaker labels when speaker detection is available.",
                        "default": false
                    },
                    "languageCode": {
                        "title": "Input language (optional)",
                        "type": "string",
                        "description": "Optional 2- or 3-letter language code such as fr, en or ja. Leave empty to detect the spoken language automatically."
                    },
                    "targetLanguages": {
                        "title": "Output language(s) (optional)",
                        "maxItems": 5,
                        "uniqueItems": true,
                        "type": "array",
                        "description": "Choose up to five languages for translated subtitles. Leave empty to generate subtitles only in the original detected language. Each selected language is charged per translated media minute.",
                        "items": {
                            "type": "string",
                            "enum": [
                                "EN-US",
                                "EN-GB",
                                "FR",
                                "DE",
                                "ES",
                                "IT",
                                "PT-BR",
                                "PT-PT",
                                "NL",
                                "PL",
                                "JA",
                                "ZH-HANS",
                                "ZH-HANT",
                                "KO"
                            ],
                            "enumTitles": [
                                "English (US)",
                                "English (UK)",
                                "French",
                                "German",
                                "Spanish",
                                "Italian",
                                "Portuguese (Brazil)",
                                "Portuguese (Portugal)",
                                "Dutch",
                                "Polish",
                                "Japanese",
                                "Chinese (Simplified)",
                                "Chinese (Traditional)",
                                "Korean"
                            ]
                        },
                        "default": []
                    },
                    "outputFormats": {
                        "title": "Files to generate",
                        "minItems": 1,
                        "uniqueItems": true,
                        "type": "array",
                        "description": "Select one or more files to generate for the original transcription and each successful translation.",
                        "items": {
                            "type": "string",
                            "enum": [
                                "srt",
                                "vtt",
                                "txt",
                                "json"
                            ],
                            "enumTitles": [
                                "SRT subtitles",
                                "WebVTT subtitles",
                                "Plain text transcript",
                                "Structured JSON"
                            ]
                        },
                        "default": [
                            "srt",
                            "vtt",
                            "txt",
                            "json"
                        ]
                    },
                    "maxCharsPerLine": {
                        "title": "Max characters per line (advanced)",
                        "minimum": 16,
                        "maximum": 60,
                        "type": "integer",
                        "description": "Override the selected profile’s maximum number of visible characters on each subtitle line."
                    },
                    "maxLinesPerSubtitle": {
                        "title": "Max lines per subtitle",
                        "minimum": 1,
                        "maximum": 2,
                        "type": "integer",
                        "description": "Override the selected profile’s maximum number of lines displayed in one subtitle cue."
                    },
                    "maxCps": {
                        "title": "Max characters per second",
                        "minimum": 8,
                        "maximum": 30,
                        "type": "number",
                        "description": "Override the selected profile’s maximum reading speed in characters per second."
                    },
                    "minSubtitleDurationMs": {
                        "title": "Minimum subtitle duration (ms)",
                        "minimum": 300,
                        "maximum": 3000,
                        "type": "integer",
                        "description": "Override the minimum time, in milliseconds, that a subtitle remains visible."
                    },
                    "maxSubtitleDurationMs": {
                        "title": "Maximum subtitle duration (ms)",
                        "minimum": 2000,
                        "maximum": 10000,
                        "type": "integer",
                        "description": "Override the maximum time, in milliseconds, that a subtitle remains visible."
                    },
                    "maxMediaMinutes": {
                        "title": "Maximum media duration (minutes)",
                        "minimum": 1,
                        "maximum": 180,
                        "type": "integer",
                        "description": "Reject media whose selected audio track exceeds this duration. The absolute platform safety limit is 180 minutes.",
                        "default": 60
                    }
                }
            },
            "runsResponseSchema": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "id": {
                                "type": "string"
                            },
                            "actId": {
                                "type": "string"
                            },
                            "userId": {
                                "type": "string"
                            },
                            "startedAt": {
                                "type": "string",
                                "format": "date-time",
                                "example": "2025-01-08T00:00:00.000Z"
                            },
                            "finishedAt": {
                                "type": "string",
                                "format": "date-time",
                                "example": "2025-01-08T00:00:00.000Z"
                            },
                            "status": {
                                "type": "string",
                                "example": "READY"
                            },
                            "meta": {
                                "type": "object",
                                "properties": {
                                    "origin": {
                                        "type": "string",
                                        "example": "API"
                                    },
                                    "userAgent": {
                                        "type": "string"
                                    }
                                }
                            },
                            "stats": {
                                "type": "object",
                                "properties": {
                                    "inputBodyLen": {
                                        "type": "integer",
                                        "example": 2000
                                    },
                                    "rebootCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "restartCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "resurrectCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "computeUnits": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            },
                            "options": {
                                "type": "object",
                                "properties": {
                                    "build": {
                                        "type": "string",
                                        "example": "latest"
                                    },
                                    "timeoutSecs": {
                                        "type": "integer",
                                        "example": 300
                                    },
                                    "memoryMbytes": {
                                        "type": "integer",
                                        "example": 1024
                                    },
                                    "diskMbytes": {
                                        "type": "integer",
                                        "example": 2048
                                    }
                                }
                            },
                            "buildId": {
                                "type": "string"
                            },
                            "defaultKeyValueStoreId": {
                                "type": "string"
                            },
                            "defaultDatasetId": {
                                "type": "string"
                            },
                            "defaultRequestQueueId": {
                                "type": "string"
                            },
                            "buildNumber": {
                                "type": "string",
                                "example": "1.0.0"
                            },
                            "containerUrl": {
                                "type": "string"
                            },
                            "usage": {
                                "type": "object",
                                "properties": {
                                    "ACTOR_COMPUTE_UNITS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_WRITES": {
                                        "type": "integer",
                                        "example": 1
                                    },
                                    "KEY_VALUE_STORE_LISTS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_INTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_EXTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_RESIDENTIAL_TRANSFER_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_SERPS": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            },
                            "usageTotalUsd": {
                                "type": "number",
                                "example": 0.00005
                            },
                            "usageUsd": {
                                "type": "object",
                                "properties": {
                                    "ACTOR_COMPUTE_UNITS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_WRITES": {
                                        "type": "number",
                                        "example": 0.00005
                                    },
                                    "KEY_VALUE_STORE_LISTS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_INTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_EXTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_RESIDENTIAL_TRANSFER_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_SERPS": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
```
