Mini éditeur de montage multicam web pour valider/ajuster les montages produits par le skill podcast-montage, faire quelques cuts à la main, et exporter la vidéo soi-même.
Charge un projet podcast (2 rushes + cutlist.json généré par montage.py analyze), affiche une timeline façon logiciel de montage avec preview (via proxys 480p), permet d'éditer les plans (caméra, coupe, trim, suppression, synchro) puis d'exporter le rendu pleine qualité depuis les rushes.
podcast-editor.service (systemctl status/restart podcast-editor)/etc/nginx/sites-enabled/montage.panelbay.com.confapp.py : backend FastAPI. Endpoints :GET /api/project : cutlist + off (synchro) + labels + durées proxys + état proxys/export.POST /api/cutlist : sauvegarde les segments édités dans work/cutlist.json (backup .bak.json), recalcule duration.GET /api/intro : {candidates, selection, gap_max}. Candidats = work/intro_candidates.json (clips proposés pour l'intro, générés à chaque montage). Sélection = work/intro.json (clips retenus, ordonnés, au format teaser.py).POST /api/intro {clips, gap_max} : sauvegarde la sélection d'intro ordonnée dans work/intro.json (backup intro.bak.json), au schéma teaser.py ({title,deblank,gap_max,clips:[{id,cam,start,end,keep_tail,drop,text}]}). Le title est toujours forcé à « DANS CETTE VIDÉO... ».POST /api/export {with_teaser} : lance montage.py render_exact (pleine qualité) ; si with_teaser et une sélection d'intro existe, (re)construit TEASER.mp4 via teaser.py --clips work/intro.json PUIS concat en tête. Si l'intro échoue (ex. clip hors couverture), le montage est quand même livré, sans intro, avec le message d'erreur. Thread + statut.GET /api/export/status, GET /download./media/* : proxys 480p (StaticFiles, range OK). /assets/* : statique.static/index.html : l'éditeur (vanilla JS), 2 onglets partageant le même moteur de timeline (chacun son tableau de plans dans store.{montage,intro}, alias segs = onglet actif). Preview = 2 <video> (cam1 audio, cam2 muet) synchronisés par l'offset off.cam2, on affiche cam1/cam2/split selon le plan ; l'audio vient toujours de cam1 (mix master).work/intro.json.proxies/cam1.mp4 (avec audio) + proxies/cam2.mp4 (muet) : générés par make_proxies.sh (480p, keyframes denses pour un scrub fluide). Régénérer si on change de projet.Défini par l'env PODCAST_WORK (défaut /root/workspace/podcast-2609/work). Pour éditer un autre podcast : changer PODCAST_WORK dans le service, régénérer les proxys depuis les rushes de ce projet, systemctl restart podcast-editor.
off.cam2 = de combien la cam2 a démarré après la cam1. Source vidéo cam2 pour le temps master t = t - off.cam2 (identique à montage.py srct). Ne jamais additionner (bug historique corrigé le 2026-09-22, cf. podcast-montage/references/technique.md).
seg i/N de montage.py render_exact. Avant le 2026-09-23, ce log ne sortait que tous les 40 segments → la barre semblait figée à 2 % pendant plusieurs minutes (mais le rendu tournait). Corrigé : log plan par plan (montage.py) + message Rendu du montage : plan i/N (app.py). Vérifier un export « bloqué » : ls work/segx/*.mp4 | wc -l doit augmenter, et ps aux | grep ffmpeg./api/export/status.off.cam1 reste 0 (master = cam1).downloadNow() clique un <a download> sur /download, 1x via _dlDone) + gros bouton « ⬇ Télécharger la vidéo » (exportDoneUI). Avant : seulement un petit lien texte qu'il fallait cliquer (l'utilisateur croyait l'export raté). Au chargement d'un export déjà fini : bouton sans déclenchement auto.cam_frame:{cam1:{zoom,x,y},cam2:{...}} (x,y ∈ [-1,1], 0=centre) dans cutlist.json (rétro-compat cam_zoom scalaire). UI « Cadrage caméra » : sliders zoom (100-160%) + glisser l'image de l'aperçu pour recadrer la cam affichée (curseur grab quand zoomée, hint si pas zoomée). Aperçu live via transform: translate+scale calculé (camTransform), cohérent avec le crop d'export. montage.py render_exact applique scale(up)+crop décalé par cam (solo + demi split). API /api/project renvoie cam_frame, /api/cutlist le persiste (clamp zoom 1-2, x/y ±1). Remplace le (n).scale(var(--z)) sur la vidéo de la cam affichée, désactivé en onglet intro), persisté dans cutlist.json (cam_zoom:{cam1,cam2}) et appliqué à l'export : montage.py render_exact insère scale(up)+crop centré par cam (solo cam1/cam2 en 1920x1080, et chaque demi en split 960x1080). Endpoints : /api/project renvoie cam_zoom, /api/cutlist le persiste (clampé 1.0-2.0). Autosave inclus. Base d'un futur « appliquer un réglage à tous les plans d'une cam ».busy (spinner + « Rendu… X % » dedans, désactivé, rouge), barre de progression plus haute (14px) et pleine largeur avec transition. exportBtn() gère l'état ; pollExport met le % dans le bouton + la barre. Reprise à l'ouverture si un export tourne déjà (P.export.state==='running'). Frontend seul (pas de restart). Avant : rien ne montrait que le clic avait pris, barre discrète.transport = 'timeline' | 'plan') au lieu de rester bloqués sur le plan sélectionné. Toucher la timeline/ruler/scrub ou déplacer un plan → 'timeline' ; scrub dans l'atelier ou ▶ Lire le plan → 'plan'. Indicateur #tphint (« Espace ▶ montage complet » / « Espace ▶ ce plan »), #detail.focused + #dplay.on en surbrillance. Remplace le (h) où Play restait capté par le plan tant qu'on n'avait pas fait Échap. Résout la plainte : après édition d'un clip, Espace rejoue le montage complet dès qu'on reclique la timeline.#dstrip .dh) refaites en fine ligne nette (2px + petit onglet de saisie en haut, zone de grab invisible de 16px, hover jaune) au lieu du gros taquet opaque qui cachait la forme d'onde. CSS seul./api/cutlist et /api/intro dès qu'il y a un changement. Indicateur #savestat (« enregistrement… » / « ✓ enregistré »). Le bouton Enregistrer + l'export mettent le snapshot à jour (pas de double save). Avant : sauvegarde manuelle seulement.startPlanDrag : mousedown sur le corps d'un plan, glisser = déplacer avec marqueur d'insertion #insmark, sans glisser = simple clic/sélection) ; marche montage + intro (rerender candidats). Clic dans la zone vide de la timeline (#track/#wave/#inner) = deselectPlan() : ferme l'atelier, Play/Pause repart sur la timeline complète. Curseur des plans grab.detailOpen()), le bouton Play et la barre Espace pilotent la lecture de CE plan (via #dplay) au lieu de toute la timeline. Échap désélectionne (ferme l'atelier) et rend Play à la timeline globale. stopPreview remet le libellé du bouton.editCand) : l'ajoute à la timeline d'intro si absent (sinon sélectionne l'existant par id) et ouvre l'atelier, comme un plan normal. Boutons ▶/+ font stopPropagation. Carte cursor:pointer.#wave canvas) sous la timeline principale (les 2 onglets) : le son master (cam1) suit les plans, donc les coupes/silences se voient. Pics servis par GET /api/peaks (fichier work/peaks.json, 50 pics/s normalisés, généré à la volée depuis cam1.wav si absent). L'atelier du plan (bas) a maintenant sa propre forme d'onde de la fenêtre + playhead : clic/glisser sur #dstrip = scrub/prévisualisation du plan, bouton ▶ Lire le plan (#dplay) joue [dpos→fin]. Playhead principal prolongé pour traverser la piste son. Stage réduit à 44vh. Demandé par Matt (« timeline sur le plan » + « timeline son partout »).#stage.bw), titre « DANS CETTE VIDÉO… » qui glisse (#titleOv, même formule que teaser.py, piloté par la position de lecture), et musique suspense (<audio id="music"> servie par GET /api/intro/music, jouée pendant la lecture de l'intro). Pistes visibles sous la timeline (#lanes) : Titre, Musique, Structure (noir + clips + noir). Réglages modifiables (panneau intro) : ouverture/fermeture noire, volume musique, coupe des silences → sauvés dans work/intro.json (intro_black, outro_black, music_volume, gap_max) et appliqués à l'export. teaser.py étendu (rétro-compatible) pour lire ces clés. Titre verrouillé (règle Matt). Demandé par Matt.#pvSel) : joue juste [start,end] avec l'audio master (cam1), sans l'ajouter. Moteur previewClip/stopPreview (état preview, géré dans tick, stoppé par play/pause/scrub). Garde-fou setOut pour sélection vide.#detail/#dstrip, zoom auto = plan + marge) apparaît en dessous : tirer les bords (poignées .dh) pour agrandir/réduire précisément, et outil coupe (#cutTool) pour retirer un morceau au milieu (glisser sur la zone → le plan est coupé en deux, avant + après, keep_head sur le 1er, keep_tail sur le 2e). Fonctionne aussi sur les clips d'intro. JS : openDetail/drawDetail/dragEdge + handler cut sur #dstrip, hideDetail() à la désélection. Demandé par Matt.work/intro_candidates.json (chacun {id,cam,start,end,text,keep_tail?}, temps master) ; dans l'onglet Intro, Matt choisit lesquels garder et les range dans l'ordre voulu (réordon., cam, trim, keep_tail). La sélection est sauvée dans work/intro.json (format teaser.py) et sert à (re)construire le teaser à l'export (teaser.py). Nouveaux endpoints GET/POST /api/intro. Le teaser d'export vient désormais de la sélection éditée (avant : TEASER.mp4 pré-généré figé). Demandé par Matt.$('#off+') (sélecteur CSS invalide → tout le JS plantait, bandeau proxys figé). IDs synchro renommés offdec/offreset/offinc. Ajout capteur d'erreur global + ligne de diagnostic dans le bandeau + anti-cache (Cache-Control: no-store) sur /. Boucle de chargement rendue robuste (retry sur erreur fetch). Preview : rendu d'une image forcé au loadeddata (sinon lecteur noir), player 16:9 centré (align-self:center + hauteur explicite).