Video & Audio to Translated Subtitles
Pricing
from $30.00 / 1,000 transcribed minutes
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
Maintained by CommunityActor stats
0
Bookmarked
3
Total users
2
Monthly active users
4 days ago
Last modified
Categories
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) -> strwrite_vtt(cues, *, include_speaker_labels=False, include_cue_identifiers=False) -> strwrite_txt(cues, *, include_speaker_labels=False, preserve_cue_breaks=True) -> strwrite_json(cues, *, metadata: ExportMetadata | None = None) -> strexport_all(cues, *, basename, metadata=None, include_speaker_labels=False,formats=None) -> dict[str, str] # rendu en mémoirewrite_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\nunique, 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 + writersmypy 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_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 == 1Erreurs (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)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,ConnectErroret 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
ffprobeet avant tout appel payant. - Le budget de transcription est recalculé dès le résultat du probe, avant
même l'extraction WAV ;
transcribe_audiorefait 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 Decimalfrom pathlib import Pathfrom src.models import FormattingConfigfrom src.pipeline import PipelineConfig, run_local_pipelinefrom src.transcription import TranscriptionConfigfrom src.translation import TranslationConfigconfig = 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 obligatoireDEEPL_API_KEY obligatoire uniquement si targetLanguages n'est pas videDEEPL_API_TIER free ou pro, défaut freeDEEPL_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-starttranscribed-minutetranslated-minute
Prix de lancement proposés :
actor-start 0,05 USD / run validétranscribed-minute 0,03 USD / minute audio commencéetranslated-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-Lengthpuis 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
OUTPUTdu 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.0httpx>=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 runtranscribed-minute 0.03 USD per started audio minutetranslated-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.