Video & Audio to Translated Subtitles avatar

Video & Audio to Translated Subtitles

Pricing

from $30.00 / 1,000 transcribed minutes

Go to Apify Store
Video & Audio to Translated Subtitles

Video & Audio to Translated Subtitles

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.

Pricing

from $30.00 / 1,000 transcribed minutes

Rating

0.0

(0)

Developer

François Fernandez

François Fernandez

Maintained by Community

Actor stats

0

Bookmarked

3

Total users

2

Monthly active users

4 days ago

Last modified

Share

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)

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)

{
"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

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 :

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_secondsLA 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 : FileNotFoundErrorFFprobeNotFoundError ; PermissionError (binaire présent mais non exécutable) et autres OSErrorFFprobeExecutionError 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 :

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 :

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 :

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

<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 :

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 :

export ELEVENLABS_API_KEY="..."
export DEEPL_API_KEY="..."
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

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 :

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 :

actor-start
transcribed-minute
translated-minute

Prix de lancement proposés :

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 :

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

Développement local

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

$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:

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.