Lección 5 — Assembler et publier : montage, formats, règles

Montage, sous-titres et formats avec ffmpeg

8 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

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 %.

FormatTailleCadrage depuis le 9:16Sous-titresMention IA
9:16720×1280aucunen haut, 210 px sous le bordy = 130 px, sous l'en-tête des applis
1:1720×720recadrage centréen haut, 80 pxy = 24 px
16:91280×720image entière sur fond floutéen bas, 50 pxy = 24 px

La mise en œuvre

files/pubcourte/montage.py (extrait)
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é.

files/pubcourte/montage.py (extrait)
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 run

Ré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.html

Montage 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 s

Contrô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.

Premier rendu : les sous-titres couvrent l'étiquette

Les sous-titres sont passés en haut pour les formats 9:16 et 1:1. Les trois formats livrés, à 5,2 s :

Les trois formats livrés : 9:16, 1:1, 16:9
Publicité 9:16 livrée

Ce qui casse en production

SymptômeCauseParade
Input link parameters do not match au concatclips de tailles ou cadences différentesnormalisation de chaque plan avant concat
Concat qui échoue sur un seul planclip sans piste audiosilence anullsrc ajouté au plan muet
Sous-titres sur le produit ou sous les boutons de l'applizone sûre pensée sans regarder le storyboardposition par format, vérifiée sur une image extraite du rendu
Sous-titres minuscules en 9:16, énormes en 16:9SRT 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éspolice sans les glyphesDejaVu Sans installée (fonts-dejavu-core), nommée explicitement
Fichier refusé par la régiedépassement de taillesecond encodage à débit calculé, erreur si toujours trop gros
Musique qui couvre la voixmixage à volumes fixescompression 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 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 ?

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
Siguiente