Montage, sous-titres et formats avec ffmpeg
Sur téléphone, une bonne part des vidéos défile sans le son : sans sous-titres incrustés, la voix off ne passe pas. Les applications posent leurs boutons et leur légende sur le bas de l'écran vertical. Les régies limitent la taille des fichiers. Et le même message doit sortir en 9:16, 1:1 et 16:9. Tout cela se règle dans un seul outil, ffmpeg, avec des commandes générées par le code plutôt que tapées à la main.
Le mécanisme
flowchart LR C1["clip p1<br/>720×1280"] --> N1["normaliser<br/>taille, 24 i/s, audio 48 kHz"] C2["clip p2"] --> N2["normaliser"] N1 --> K["concat"] N2 --> K K --> X["mixage : ambiance Veo 0,5<br/>+ voix (adelay)<br/>+ musique abaissée sous la voix<br/>→ loudnorm -14 LUFS"] X --> D1["9:16 : tel quel"] X --> D2["1:1 : recadrage centré"] X --> D3["16:9 : image entière<br/>sur fond flouté"] D1 --> E["sous-titres ASS + mention IA<br/>H.264 CRF 26, ≤ 3 Mo"] D2 --> E D3 --> E
Normaliser avant de concaténer. Le filtre concat exige des flux identiques : même taille, même cadence, même format audio. Un clip sans piste son (cela arrive quand l'audio est filtré) casserait la concaténation : on lui ajoute un silence.
Mixer en trois couches. L'ambiance générée par Veo (bruit de rue, clic du bouchon) à mi-volume ; la voix, placée à l'instant exact de chaque plan par adelay ; la musique, coupée à la durée du montage, avec fondus, et comprimée par sidechaincompress dont l'entrée de contrôle est la voix : elle baisse quand la voix parle et remonte ensuite. Le tout est normalisé à -14 LUFS intégrés, l'ordre de grandeur que visent les principales plateformes : un fichier plus fort sera baissé par la plateforme, un fichier plus faible paraîtra terne à côté des autres.
Sous-titres en pixels du format. Le SRT livré à part sert à l'accessibilité et à l'import natif (YouTube, Meta). Pour l'incrustation, le code écrit un fichier ASS par format, avec PlayResX/PlayResY égaux à la taille réelle de la vidéo : la taille du texte et les marges sont alors en pixels, sans conversion propre à chaque ratio.
Recadrer ou flouter. Passer de 9:16 à 1:1 garde 56 % de l'image : on recadre au centre. Passer de 9:16 à 16:9 n'en garderait que 32 % : on pose l'image entière sur une copie agrandie et floutée d'elle-même. Le seuil de décision est 50 %.
| Format | Taille | Cadrage depuis le 9:16 | Sous-titres | Mention IA |
|---|---|---|---|---|
| 9:16 | 720×1280 | aucun | en haut, 210 px sous le bord | y = 130 px, sous l'en-tête des applis |
| 1:1 | 720×720 | recadrage centré | en haut, 80 px | y = 24 px |
| 16:9 | 1280×720 | image entière sur fond flouté | en bas, 50 px | y = 24 px |
La mise en œuvre
def filtre_cadrage(source: tuple[int, int], fmt: str) -> str:
sw, sh = source
c = FORMATS[fmt]
r_src, r_cible = sw / sh, c.largeur / c.hauteur
if min(r_src, r_cible) / max(r_src, r_cible) >= 0.5:
return (f"scale={c.largeur}:{c.hauteur}:force_original_aspect_ratio=increase,"
f"crop={c.largeur}:{c.hauteur},setsar=1")
return (f"split=2[fond][avant];[fond]scale={c.largeur}:{c.hauteur}:force_original_aspect_ratio=increase,"
f"crop={c.largeur}:{c.hauteur},gblur=sigma=30,eq=brightness=-0.08[f];"
f"[avant]scale={c.largeur}:{c.hauteur}:force_original_aspect_ratio=decrease[a];"
f"[f][a]overlay=(W-w)/2:(H-h)/2,setsar=1")La première version comparait simplement les ratios (« cible plus étroite que la source : recadrer »). Elle envoyait le 1:1 sur fond flouté, avec une bouteille réduite de moitié. Le test test_cadrage_recadre_ou_floute l'a attrapé avant tout rendu réel.
La garantie de taille : un encodage à qualité constante (CRF 26), puis, si le fichier dépasse 3 Mo, un second encodage à débit calculé.
encoder(["-crf", "26"])
if sortie.stat().st_size > max_octets: # second passage à débit calculé
kbps = int(max_octets * 8 * 0.92 / duree(master) / 1000) - 96
encoder(["-b:v", f"{kbps}k", "-maxrate", f"{kbps}k", "-bufsize", f"{kbps * 2}k"])
if sortie.stat().st_size > max_octets:
raise ErreurFFmpeg(f"{sortie.name} dépasse encore {max_octets} octets")Pour essayer toute la chaîne sans clé, make run remplace les modèles par des médias synthétiques fabriqués par ffmpeg (fond uni, bruit rose, sinusoïde) et exécute le vrai montage.
make runRésultat obtenu
Démo hors ligne (make run), 2026-10-15 :
9:16 sorties/demo/calade-cold-brew-9x16.mp4 0.09 Mo 8.0 s
1:1 sorties/demo/calade-cold-brew-1x1.mp4 0.09 Mo 8.0 s
16:9 sorties/demo/calade-cold-brew-16x9.mp4 0.09 Mo 8.0 s
devis 0.575 $ · dépense réelle 0.000 $ · 6.0 s · rapport : sorties/demo/rapport.htmlMontage des vrais clips (relance depuis le cache, 15,5 s) :
9:16 sorties/reel/calade-cold-brew-9x16.mp4 1.12 Mo 8.0 s
1:1 sorties/reel/calade-cold-brew-1x1.mp4 0.68 Mo 8.0 s
16:9 sorties/reel/calade-cold-brew-16x9.mp4 0.55 Mo 8.0 sContrôles sur le 9:16 livré : H.264 720×1280 à 24 i/s, 1,05 Mb/s ; AAC 96 kb/s ; sonie intégrée mesurée par ebur128 : -14,7 LUFS. Le SRT livré :
1
00:00:00,200 --> 00:00:02,898
Calade Cold Brew. Seize heures à
froid.
2
00:00:04,200 --> 00:00:05,488
Ouvrez. C'est prêt.Le premier rendu réel plaçait les sous-titres en bas, à 22 % du bord, comme le veulent les recommandations de zones sûres. Sur ce storyboard, le produit occupe le tiers central : les sous-titres couvraient l'étiquette, en 9:16 comme en 1:1.
Les sous-titres sont passés en haut pour les formats 9:16 et 1:1. Les trois formats livrés, à 5,2 s :
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
Input link parameters do not match au concat | clips de tailles ou cadences différentes | normalisation de chaque plan avant concat |
| Concat qui échoue sur un seul plan | clip sans piste audio | silence anullsrc ajouté au plan muet |
| Sous-titres sur le produit ou sous les boutons de l'appli | zone sûre pensée sans regarder le storyboard | position par format, vérifiée sur une image extraite du rendu |
| Sous-titres minuscules en 9:16, énormes en 16:9 | SRT incrusté avec la résolution virtuelle par défaut de libass (384×288) | ASS avec PlayResX/PlayResY = taille réelle |
| Lettres accentuées en carrés | police sans les glyphes | DejaVu Sans installée (fonts-dejavu-core), nommée explicitement |
| Fichier refusé par la régie | dépassement de taille | second encodage à débit calculé, erreur si toujours trop gros |
| Musique qui couvre la voix | mixage à volumes fixes | compression en sidechain sur la voix |
La règle de décision
Tournez (ou générez) dans le format le plus étroit que vous diffuserez, ici 9:16, et dérivez les autres. Recadrez quand vous gardez au moins la moitié de l'image ; au-dessous, image entière sur fond flouté. Vérifiez chaque format sur une image extraite du rendu, pas sur un schéma de zones sûres. Livrez toujours le SRT à côté du MP4 : les sous-titres natifs d'une plateforme sont indexés et lisibles par les lecteurs d'écran, pas ceux qui sont incrustés.
Pratique
Le client demande un 4:5 pour le fil Instagram, à partir du master 9:16. Que fait `filtre_cadrage` si vous ajoutez ce format, et est-ce le bon choix ?
Le rapport des ratios vaut 0,70, au-dessus du seuil de 0,5 : recadrage centré. Avec un produit dans le tiers central, rien d'important n'est coupé. Le fond flouté gaspillerait 30 % de la surface. Le format de génération (9:16) ne limite pas les formats dérivés au montage.
Le 9:16 sort à 3,4 Mo. Le second encodage passe sous 3 Mo, mais le client se plaint d'artefacts sur le plan du bouchon. Quelle est la première chose à revoir ?
À taille fixe, chaque kilobit gagné ailleurs va à l'image : audio de 96 à 64 kb/s pour une voix et une musique de fond, preset slow ou slower. Passer en 1080p à taille égale divise le débit par pixel par plus de deux. Dépasser la limite fait refuser le fichier.
Exercice. Ajoutez une carte de fin d'une seconde (fond uni, logo texte « CALADE », mention « Contenu généré par IA ») au master avant les déclinaisons. Quel filtre ffmpeg utilisez-vous, et quel effet sur la vérification de durée des voix ?
Voir la solution
Générez la carte avec la même normalisation que les plans, puis ajoutez-la à la liste passée à concatener :
carte = travail / "carte.mp4"
ffmpeg(["-f", "lavfi", "-i", "color=c=0x2b1d14:s=720x1280:d=1:r=24",
"-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
"-vf", "drawtext=font='DejaVu Sans':text='CALADE':fontsize=96:fontcolor=white:x=(w-tw)/2:y=(h-th)/2",
"-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(carte)])
master = m.concatener([*clips, carte], travail / "master.mp4")Les voix sont calées sur les débuts des plans (b.debuts()), qui ne changent pas : aucun effet. La durée totale passe à 9 s ; le fondu de sortie de la musique suit, car il est calculé sur la durée du master. Le test test_chaine_complete_hors_ligne, qui attend 8 s, est à mettre à jour.
- Je normalise chaque plan avant de concaténer
- Je mixe ambiance, voix et musique avec abaissement automatique et sonie à -14 LUFS
- J'incruste des sous-titres placés en fonction du storyboard et je livre le SRT à part
- Je dérive 1:1 et 16:9 du 9:16 et je garantis moins de 3 Mo par fichier