devblog
12 min de lecture

AutoLad : un logiciel de montage qui coupe à ma place

Une app desktop 100 % locale qui retire les silences, les « euh » et les prises ratées d'une face cam, et qu'un agent IA peut piloter en direct.

Tauri 2Rust + tokioReact 19ffmpegwhisper.cppMCP

Le montage d'une vidéo face caméra, c'est surtout du travail de nettoyage. On place le curseur, on écoute, on coupe le silence, on recommence. Puis on retire les « euh », puis la phrase qu'on a dû dire trois fois. Rien de créatif, et pourtant c'est ce qui prend le plus de temps.

AutoLad est né de cette frustration : une app desktop qui fait ce nettoyage toute seule, sans rien envoyer dans le cloud. Le rush reste sur la machine, la transcription tourne sur la carte graphique, et le résultat s'ouvre dans une timeline classique où l'on peut tout reprendre à la main. Et comme je voulais aussi pouvoir dire à Claude « enlève les hésitations et exporte en brouillon », l'app est également un serveur MCP qu'un agent peut piloter pendant qu'on le regarde faire.

36

commits

~11,5k

lignes de Rust

161

tests

21

outils MCP

Le principe

Tout le logiciel tourne autour d'un objet simple : l'EDL (Edit Decision List). C'est la liste ordonnée des morceaux de rushes qu'on garde. Couper un silence, retirer un mot, déplacer un plan : tout revient à modifier cette liste. Le rendu final ne fait que la traduire en commande ffmpeg.

Le flux d'auto-montage tient en six étapes :

  1. Import : ffprobe lit le fichier, un hash l'identifie et sert de clé de cache.
  2. Préparation en tâche de fond : proxy 540p pour la lecture, forme d'onde, bande de vignettes.
  3. Analyse : détection des silences par ffmpeg, transcription par Whisper.
  4. Construction de l'EDL à partir des analyses et des réglages.
  5. Édition sur la timeline, avec preview sur les proxys.
  6. Export : l'EDL devient un graphe de filtres ffmpeg, rendu sur les sources originales.

Tout ce qui est calculé est mis en cache par hash : rouvrir un projet ne relance jamais une transcription déjà faite.

L'architecture

Le projet est un workspace Rust découpé en crates, avec une règle stricte : le domaine ne dépend de rien. core contient les types et les algorithmes purs (silences → segments, transcript, sous-titres, historique…) et ne connaît ni Tauri, ni ffmpeg, ni Whisper. Il déclare seulement des traits aux frontières d'entrée/sortie, que media et transcribe implémentent.

FIG_001 — ARCHITECTUREsurvole un bloc
impl traitsimpl traitsfrontReact · TS strictClaudeagent MCPsrc-tauriadaptateurmcpEngine + serveurmediaffmpeg / ffprobetranscribewhisper-rscoredomaine purffmpegsidecar GPLwhisper.cppGPU local

core

Aucune dépendance infra. Types (Project, Asset, Edl, Cut), algos purs (silences → segments, transcript, sous-titres, FCPXML, historique) et traits aux frontières I/O uniquement.

utilisé par : mcp, media, transcribe

Les flèches suivent les dépendances. core ne dépend de rien ; l'UI et les agents passent par le même Engine.

L'autre décision structurante, c'est la crate mcp. Au départ, elle ne devait servir qu'aux agents. Elle est finalement devenue le moteur de toute l'app : l'interface desktop appelle le même Engine que l'agent. Résultat, une seule logique, testée une seule fois, et aucune fonctionnalité qui existe dans l'UI sans exister pour l'agent (ou l'inverse).

Côté front, aucun type n'est écrit à la main : les bindings TypeScript sont générés depuis Rust par tauri-specta, et un test échoue s'ils sont périmés.

Couper les silences

C'est la fonction de base, et elle est plus subtile qu'elle n'en a l'air. ffmpeg (silencedetect) donne des plages où le son reste sous un seuil. Les inverser donne la parole à garder, mais un découpage brut produit un montage haché : des micro-coupes au milieu des phrases, des débuts de mots mangés.

D'où quatre étapes, chacune avec son réglage :

rust
pub fn build_silence_cut_edl(
    asset: &Asset,
    silences: &[TimeRange],
    settings: &SilenceSettings,
) -> Result<Edl, CoreError> {
    settings.validate()?;

    let speech = silences_to_segments(silences, asset.duration);
    let speech = merge_close(&speech, settings.max_gap);
    let speech = apply_margins(&speech, settings.margin, asset.duration);
    let speech = drop_short(&speech, settings.min_segment);

    Ok(Edl { cuts: speech.into_iter().map(|range| Cut { asset: asset.id.clone(), range }).collect() })
}

Le plus parlant, c'est de jouer avec. Le schéma ci-dessous applique exactement ces quatre fonctions, réécrites en TypeScript, sur un faux clip de 20 secondes :

FIG_002 — AUTO-CUT DES SILENCESinteractif
souffletoc0s2s4s6s8s10s12s14s16s18s20s

Montage

20.0 s

Source

14.4 s

Montage

−28 %

Gagné

5

Cuts

Le toc sur le bureau est gardé. Il ne dure que 80 ms, mais les marges passent avant le filtre de durée et le font grossir au-dessus de min_segment.

Les quatre étapes de build_silence_cut_edl, réécrites en TypeScript à l'identique. Le clip est synthétique ; sur un vrai rush, ffmpeg fournit les silences.

Deux choses à remarquer. Le souffle vers 7,5 s est au-dessus du seuil de -30 dB : il n'est pas considéré comme un silence, ce qui est voulu (couper une respiration au milieu d'une idée sonne faux). Et le toc sur le bureau à 14 s survit au montage avec les réglages par défaut. C'est un vrai défaut, repéré en préparant cet article : les marges sont appliquées avant le filtre de durée, donc un bruit de 80 ms devient un segment de 280 ms. Il est dans la roadmap en fin d'article.

Monter par le texte

Les silences ne suffisent pas. Le vrai gain vient de la transcription : une fois qu'on sait quel mot est dit à quel moment, on peut monter en éditant du texte. AutoLad propose trois outils :

  • cut_text : on écrit la phrase à retirer, elle disparaît du montage. La recherche ignore la casse et la ponctuation, et tolère qu'un mot sur cinq manque au transcript (sauf le premier et le dernier, pour ne pas couper au mauvais endroit).
  • remove_fillers : retire les sons d'hésitation. La liste est fermée (« euh », « hum », « bah »…) : jamais de vrais mots comme « ben » ou « donc », qui portent parfois du sens.
  • remove_retakes : détecte les phrases recommencées. Si une phrase commence comme la suivante, ou la répète presque entièrement (plus longue sous-séquence commune ≥ 80 %), c'est une prise ratée : on garde la dernière. Une interjection courte entre les deux (« Non, pardon. ») part avec.
FIG_004 — MONTAGE PAR LE TEXTEinteractif

Bonjour à tous, euh, aujourd'hui on parle de montage. On va voir comment on peut… Non, pardon. On va voir comment couper les silences tout seul. Hum, c'est parti !

Ce que dit le montage

Bonjour à tous, euh, aujourd'hui on parle de montage. On va voir comment on peut… Non, pardon. On va voir comment couper les silences tout seul. Hum, c'est parti !

parole 9.6 s → 9.6 s · clip 13.5 s · 0 mot retiré

Même logique que core::transcript : liste fermée d'hésitations, prise ratée = même début ou quasi-répétition de la phrase suivante. Ici cut_text cherche le texte exact ; le vrai tolère un mot manquant sur cinq.

Toutes ces opérations produisent des retraits de plages sources, appliqués en un seul changement annulable. Un Ctrl+Z annule « remove_retakes » d'un coup, pas mot par mot.

Le piège des timestamps par mot

whisper.cpp peut donner des timestamps par mot, très précis. Mais en mode mot, il perd parfois des mots : j'ai vu « apprendre » disparaître purement et simplement d'une phrase. La solution retenue : deux passes. Le mode phrase donne le texte fiable, affiché dans l'onglet Transcription. Le mode mot sert uniquement à placer les coupes, et n'est calculé qu'à la première demande, puis stocké dans le projet.

Un agent dans la timeline

C'est la partie la plus amusante du projet. Le même autolad.exe, lancé avec --mcp, devient un serveur MCP en stdio. Claude Desktop ou Claude Code peuvent alors importer un rush, transcrire, couper, prévisualiser une image et lancer un rendu.

Mais un agent qui travaille dans un process invisible, c'est frustrant : on ne voit rien. Alors quand l'app est ouverte, le process --mcp se branche dessus au lieu de travailler seul. On voit un curseur « Claude » se déplacer dans l'interface et cliquer là où il agit.

FIG_003 — LE PONT AGENT ⇄ APPpas à pas
01/09
Claudeautolad --mcpApp AutoLadUIspawn (stdio)

spawn (stdio)Claude lance le même exécutable avec --mcp. Pas de fenêtre : sur stdout ne passe que du JSON-RPC, jamais un println!.

Un seul exécutable : lancé normalement c'est l'app, lancé avec --mcp c'est un serveur MCP qui se branche sur l'app ouverte, ou tourne seul.

Le détail qui compte : chaque action de l'agent passe par le même historique que celles de l'utilisateur. Si Claude coupe un passage qu'on voulait garder, Ctrl+Z l'annule. Et inversement, l'agent dispose d'outils undo/redo qui annulent aussi les actions de l'utilisateur.

Les difficultés

Des chemins trop longs pour Windows

whisper.cpp compilé avec le backend Vulkan imbrique des dossiers CMake sur plusieurs niveaux, et le FileTracker de MSBuild ne gère pas les chemins de plus de 260 caractères (FTK1011). La solution est peu élégante mais efficace : le dossier de build Cargo est déplacé à la racine, en C:/t. La marge reste faible, et c'est noté comme tel dans la doc du projet.

Un callback mal casté dans whisper-rs

Pour annuler une transcription en cours, whisper.cpp interroge régulièrement un callback d'abandon. Dans whisper-rs 0.16, avec une simple closure, ce callback lisait n'importe quoi et whisper_full échouait avec le code -6. En cause : un cast de pointeur vers le mauvais type dans la lib. Le contournement consiste à lui passer directement le type qu'il caste :

rust
// whisper-rs 0.16.0 casts the callback pointer to the closure's concrete type, but
// stores it as a boxed trait object: with a bare closure the abort callback reads
// garbage and whisper_full fails with -6. Passing the box itself as `F` makes the
// cast match. Revisit when whisper-rs fixes its trampoline.
let abort: Box<dyn FnMut() -> bool> = Box::new(move || cancel.load(Ordering::Relaxed));
params.set_abort_callback_safe::<_, Box<dyn FnMut() -> bool>>(abort);

Toutes les formes de vidéo

Une vidéo de smartphone est souvent stockée en paysage avec une métadonnée de rotation. Certaines caméras ont des pixels non carrés (anamorphiques). D'autres sortent des dimensions impaires, que l'encodeur H.264 refuse. AutoLad raisonne donc toujours en taille affichée : rotation et ratio de pixel appliqués dès l'analyse, mise à l'échelle en pixels carrés et côtés arrondis au pair partout (proxy, preview, rendu). Un test unique fait passer huit cas par toute la chaîne (paysage, portrait, pivoté, anamorphique, minuscule, impair, ultra-large, UHD).

Des fichiers de 8 Go

Le rendu est encodé en qualité constante : l'encodeur dépense ce qu'il faut pour garder une qualité stable. Sur des rushes 4K à 60 i/s avec du grain ou du feuillage, ça montait à ~100 Mb/s, soit 8 Go pour dix minutes. Un plafond fixe ne marchait pas : soit il étouffait la 4K, soit il laissait la 1080p granuleuse prendre trois fois ce dont elle avait besoin. Le plafond suit donc le nombre de pixels par seconde :

FIG_005 — PLAFOND DE DÉBITinteractif

0.2 × 1920 × 1080 × 30 = 12.4 Mb/s

AutoLad (plafond)12 Mb/s · 0.93 Go
Sans plafond (mesuré, 4K60 granuleux)100 Mb/s · 7.5 Go
Qualité constante, mais plafonnée : le débit maximal suit le nombre de pixels par seconde. Le fichier réel est souvent plus léger, c'est une borne haute.

Mesuré sur une 1080p30 granuleuse : passer de 40 à 12 Mb/s divise le fichier par plus de 3 pour moins d'un demi-point de VMAF. J'ai aussi testé les options « qualité » de NVENC (lookahead, AQ spatial et temporel) : fichiers 40 % plus lourds, sans gain mesurable. Écartées.

Des sous-titres et des backslashs

L'incrustation des sous-titres passe par le filtre subtitles de ffmpeg, qui reçoit un chemin de fichier… à l'intérieur d'une chaîne de filtres, avec ses propres règles d'échappement. Avec un chemin Windows (C:\Users\...), c'est un cauchemar. Plutôt que d'échapper, ffmpeg est lancé depuis le dossier temporaire où se trouve le .srt, et le filtre ne reçoit qu'un nom de fichier ASCII. Le problème n'a plus lieu d'être.

Les points d'attention

Quelques règles du projet qui ne se voient pas mais évitent beaucoup de bugs :

  • Sur stdout en mode --mcp, uniquement du JSON-RPC. Un seul println! de debug oublié suffit à corrompre le flux, et l'erreur côté client est rarement explicite.
  • Annuler = dropper le future. Les process ffmpeg sont lancés avec kill_on_drop : annuler un rendu tue réellement le process, au lieu de le laisser tourner en arrière-plan.
  • Écritures atomiques partout. Projet, cache, config de Claude : on écrit dans un .part, puis on renomme. Un crash au milieu d'une écriture ne laisse jamais un fichier à moitié écrit.
  • Jamais de calcul lourd dans la webview. Formes d'onde et vignettes sont calculées en Rust et transmises en binaire, pas en gros JSON.
  • L'historique est en mémoire. 200 étapes d'undo, mais remises à zéro à la fermeture. Le projet, lui, est sauvegardé automatiquement.

Les optimisations à venir

Le moteur couvre aujourd'hui bien son premier cas d'usage, la face cam. Voici ce qui reste sur la liste, avec une estimation honnête de l'effort :

FIG_006 — ROADMAPclique pour déplier

S = quelques heures · M = quelques jours · L = un vrai chantier

Les deux chantiers qui m'intéressent le plus sont les hésitations non transcrites et l'alignement forcé des mots. Les deux visent la même limite : AutoLad est aussi bon que la transcription sur laquelle il s'appuie. Améliorer ce que l'on sait de quel son est produit à quel moment, c'est améliorer toutes les fonctions de montage d'un coup.


Si vous montez des vidéos face caméra, si vous voulez tester AutoLad ou simplement en discuter, écrivez-moi.