Le montage vidéo par le texte ressemble à un traitement de texte, mais en dessous, c'est une liste de plages de temps et une seule commande ffmpeg. La transcription donne à chaque mot un instant de début et de fin, les mots que vous supprimez deviennent des coupes, et tout ce qui se trouve entre les coupes est conservé. L'éditeur vidéo de l'application de bureau osFoundry fait cela en Rust (canvas_transcript.rs). Voici le pipeline écrit en Python, exécuté sur un vrai extrait d'interview de 37,3 secondes, et une comparaison avec le raccourci qui semble équivalent mais ne l'est pas : couper chaque morceau avec -c copy puis les assembler.
Partir des horodatages des mots
Tout transcripteur qui renvoie un horodatage par mot convient. Whisper le fait, tout comme la plupart des API de reconnaissance vocale hébergées si on le demande. L'extrait est une interview de la NASA avec l'astronaute Christina Koch, en 640×360 à 30 ips, et sa transcription compte 119 mots :
{"words": [ {"index": 0, "text": "Well", "start_ms": 0, "end_ms": 500}, {"index": 1, "text": "I", "start_ms": 740, "end_ms": 840}, {"index": 2, "text": "think", "start_ms": 840, "end_ms": 1060}, ... ], "duration_ms": 37300} Les mots sont désignés par leur index et non par leur temps, car l'index reste le même pendant que l'utilisateur sélectionne et désélectionne. Le montage ci-dessous supprime les mots 30 à 81 (une digression de 16,7 secondes) et 111 à 118 (la formule de fin).
L'application n'envoie que l'audio au transcripteur, et cette étape vaut la peine d'être reprise même dans un script. Les fournisseurs facturent à la durée, mais rien ne justifie d'envoyer la piste vidéo :
ffmpeg -i clip.mp4 -vn -ac 1 -ar 16000 -f wav clip.wav Caler chaque coupe sur une image
Un mot qui commence à 9 420 ms commence au milieu d'une image à 30 ips. Si l'instant de coupe n'est pas ramené sur une limite d'image, le nombre que voit l'utilisateur et l'image où ffmpeg coupe réellement diffèrent. L'application arrondit à l'image la plus proche au lieu de tronquer, et son commentaire explique pourquoi : tronquer avance toutes les coupes, et au bout d'une douzaine de montages on entend le début des mots rogné.
def snap(ms, fps): frame = 1000 / fps return round(round(ms / frame) * frame) Transformer les mots supprimés en plages conservées
Les mots supprimés deviennent des plages de coupe. Les plages qui se chevauchent ou se touchent fusionnent, et ce qui se trouve entre les coupes est conservé, y compris les pauses entre les mots conservés. Cette dernière règle évite que le résultat sonne comme un montage automatique par jump cuts : seul disparaît le temps qu'occupe un mot supprimé, pas chaque silence.
def keep_ranges(words, deleted, duration, fps): cuts = sorted((snap(w["start_ms"], fps), snap(w["end_ms"], fps)) for w in words if w["index"] in deleted) merged = [] for s, e in cuts: if merged and s <= merged[-1][1]: merged[-1][1] = max(merged[-1][1], e) else: merged.append([s, e]) keeps, cursor = [], 0 for s, e in merged: if s > cursor: keeps.append((cursor, s)) cursor = max(cursor, e) if cursor < duration: keeps.append((cursor, duration)) return [k for k in keeps if k[1] - k[0] >= 1000 / fps] # a sub-frame keep can't be encoded Pour ce montage, elle renvoie quatre plages, et non les deux qu'on attendrait :
(0, 9433) (16833, 16967) (26133, 34700) (36933, 37300) La deuxième dure 134 ms. Dans la digression supprimée, le locuteur marque une pause de 120 ms entre « road » et « but ». Aucun mot supprimé ne couvre cette pause, donc la règle « conserver tous les intervalles » la garde, et la sortie contient un éclair de quatre images du milieu d'une phrase que vous avez supprimée. La dernière plage est la même chose à la fin : 367 ms d'ambiance sonore après la formule de fin supprimée.
La solution consiste à dire à quoi appartient une pause. Un intervalle entre deux mots supprimés, ou entre un mot supprimé et le début ou la fin du clip, part avec eux. Un intervalle à côté d'un mot conservé reste :
def cut_spans(words, deleted, duration, fps): spans = [] for i, w in enumerate(words): if w["index"] not in deleted: continue s, e = w["start_ms"], w["end_ms"] if i == 0 or words[i - 1]["index"] in deleted: s = 0 if i == 0 else words[i - 1]["end_ms"] if i == len(words) - 1 or words[i + 1]["index"] in deleted: e = duration if i == len(words) - 1 else words[i + 1]["start_ms"] spans.append((snap(s, fps), snap(e, fps))) return spans Avec cut_spans à la place des plages de mots, le même montage donne deux plages, (0, 9433) (26133, 34700), et un résultat de 18,0 secondes.
Couper la vidéo et l'audio dans un seul graphe
Chaque plage devient un trim pour la vidéo et un atrim pour l'audio, et un seul concat les assemble toutes, vidéo et audio ensemble :
def graph(keeps): p = [] for i, (s, e) in enumerate(keeps): p.append(f"[0:v]trim=start={s/1000:.3f}:end={e/1000:.3f},setpts=PTS-STARTPTS[v{i}]") p.append(f"[0:a]atrim=start={s/1000:.3f}:end={e/1000:.3f},asetpts=PTS-STARTPTS[a{i}]") p.append("".join(f"[v{i}][a{i}]" for i in range(len(keeps))) + f"concat=n={len(keeps)}:v=1:a=1[outv][outa]") return ";".join(p) subprocess.run(["ffmpeg", "-y", "-i", "clip.mp4", "-filter_complex", graph(keeps), "-map", "[outv]", "-map", "[outa]", "out.mp4"], check=True) Cela réencode toute la sortie, et c'est le coût : de 4,5 à 7,4 secondes pour cet extrait avec ffmpeg 7.1 sur un Apple M1, selon ce qui tournait en parallèle.
Pourquoi pas -c copy
Le chemin plus rapide, le plus évident, est de couper chaque plage sans réencoder et d'assembler les morceaux avec le démuxeur concat :
ffmpeg -ss 26.133 -to 34.700 -i clip.mp4 -c copy seg2.mp4 ffmpeg -f concat -safe 0 -i list.txt -c copy out-copy.mp4 Les deux versions ont été exécutées sur les mêmes quatre plages et vérifiées par rapport à la source. Les images vidéo ont été rapprochées de la source par hachage (la copie garde les images d'origine) et par la vignette la plus proche, et l'audio par corrélation de courtes fenêtres de la sortie avec l'audio source.
| graphe de filtres | -c copy + concat | |
|---|---|---|
| durée de la vidéo | 18,50 s | 18,87 s |
| la troisième section commence dans la source à | 26,133 s | 26,000 s |
| durée de l'audio décodé | 18,53 s | 20,85 s |
| audio par rapport à l'image sur 7 points de contrôle | 0 à 1 ms | environ 1 s de retard après le deuxième assemblage |
La source n'a une image clé que toutes les 8,33 secondes, donc une coupe copiée ne peut pas commencer là où la transcription le dit. Chaque morceau copié est sorti avec une vidéo et un audio de durées différentes (8,73 s contre 8,58 s pour la troisième plage), l'assemblage a ramené quatre images d'un mot supprimé, et ffmpeg a signalé des horodatages qui reculent au décodage de l'audio assemblé. Les correspondances de l'audio copié étaient aussi faibles (corrélation de 0,2 à 0,8, contre 1,00 pour le graphe de filtres), donc « environ une seconde » est approximatif. Ce qui est clair, c'est que ce n'était pas synchrone, et un spectateur verrait des lèvres qui ne correspondent pas aux mots.
La copie de flux convient pour une seule coupe sur une image clé. Pour des coupes placées d'après une transcription, le réencodage est le prix à payer pour que les coupes tombent là où sont les mots.
L'ensemble
Les morceaux ci-dessus représentent moins de 60 lignes de Python, avec uniquement la bibliothèque standard et un binaire ffmpeg. L'éditeur vidéo canvas d'osFoundry place ce genre de pipeline derrière un panneau de transcription : on fait glisser sur les mots, on appuie sur Suppr, on applique les coupes. L'application est gratuite sur la page de téléchargement d'osFoundry.
