Tableau de bord + éditeur pour transformer les vidéos longues YouTube de Matt (@mattparaguay) en shorts verticaux 9:16.
dl.py, qui gère cookies + fix yt-dlp) et génère un proxy 480p pour la preview.claude -p.shorts-studio.service (systemctl status/restart shorts-studio)/etc/nginx/sites-enabled/shorts.panelbay.com.confapp.py : backend FastAPI. Endpoints :GET /api/videos : backlog + compteurs de shorts par vidéo.POST /api/sync : récupère les uploads de la chaîne Matt (id UCjmtRl_IpqjHkqrk67WICvQ) via l'API YouTube (lib.google_workspace.load_credentials, scope youtube.readonly), upsert dans la table videos. Durées via videos.list (ISO8601).POST /api/videos/{vid}/status : change le statut.GET /api/videos/{vid} : détail (vidéo + shorts + jobs en cours).POST /api/videos/{vid}/prepare (+ /status) : thread → dl.py get (source 1080p) puis proxy 480p ffmpeg.POST /api/videos/{vid}/suggest (+ /status) : thread → transcript (yt-dlp auto-subs, _parse_vtt) → claude -p --model sonnet → crée des shorts source='ai'.POST /api/shorts / DELETE /api/shorts/{id} : CRUD short (title, start, end, zoom, cx, cy, captions).POST /api/shorts/{id}/export (+ /status) : thread ffmpeg crop 9:16 + scale 1080x1920 (+ subtitles brûlés si captions), sortie dans /root/workspace/Shorts/<video>/.GET /api/shorts/{id}/download : télécharge le MP4 exporté./media/{vid}.mp4 : proxy 480p (StaticFiles).static/index.html : SPA vanilla JS (backlog grid + vue éditeur avec cadre 9:16 draggable sur l'aperçu). Style propre (outil interne, hors design system des marques)./root/aios/data/shorts-studio/shorts.db (tables videos, shorts)./root/aios/data/shorts-studio/proxies/{vid}.mp4./root/aios/data/shorts-studio/subs/./root/workspace/Shorts/<slug-video>/<slug-short>_<id>.mp4 (visibles dans le workspace).zoom (1-3), cx/cy (centre normalisé 0-1) résolution-indépendants. Backend : crop_h = H/zoom, crop_w = crop_h*9/16, centré sur (cx,cy) clampé, puis scale=1080:1920. Le cadre orange de l'UI utilise les mêmes fractions (aspect 16:9 supposé de la source).
dl.py essaie sans cookies puis avec data/ytdl/cookies.txt ; code de sortie 3 = cookies requis ou expirés. Les cookies YouTube expirent vite. Correctif : déposer un cookies.txt frais sur https://dl.panelbay.com (section Cookies). Voir mémoire [[ytdl-downloader-tool]] et [[yt-dlp-youtube-deno-fix]]. Le backend affiche un message clair dans ce cas.claude -p lancé en sous-processus (subscription CLI, jamais l'API Anthropic), timeout 600s, --model sonnet. Nécessite des auto-subs disponibles sur la vidéo._do_suggest privilégie désormais un extrait CONTINU d'un seul tenant par défaut, autorise le resserrage seulement pour retirer 1-2 phrases de remplissage AU MILIEU d'un même passage (bouts très proches), et INTERDIT explicitement la compilation de passages éloignés (cause des stitches incohérents type « pour en revenir sur… »). Garde-fou déterministe _local_only(clean, max_gap=30, max_total=75) appliqué après _clean_segments : ne garde qu'une suite de bouts adjacents dont le trou sauté reste ≤30s ; dès qu'un écart dépasse ça (compil lointaine), on coupe. Vérifié : le short 57 (447-486 recollé à 608-624, 2 min plus loin) est ramené au 1er passage. Éditeur (front static/index.html) : CUR.segs est TOUJOURS un tableau ≥1 (ensureSegs/syncSpan) ; la timeline rend dynamiquement (drawSegHandles dans #seghost) une paire de barres (début/fin) + un corps déplaçable PAR passage, y compris en multi-bouts (avant : poignées désactivées en montage). Barres restylées ultra simples et semi-transparentes (.sh-h trait 2px + grip, .segbody fill .12) pour voir l'onde dessous ; badge de temps m:ss.d au survol/drag. Glisser une barre sur l'autre collapse le passage (.crossed, rouge) et fait apparaître une croix ✕ cliquable (.segx, data-del) → removeBout(i) supprime ce passage (min 1). ✂ Couper ici (splitHere) scinde le passage sous la tête de lecture en deux (permet de créer un montage manuel puis d'écarter les barres pour retirer le milieu). Annuler / Refaire (doUndo/doRedo, pile UNDO/REDO, snapshot au 1er vrai mouvement de drag / avant chaque split/suppression/nudge/édition de champ ; Ctrl+Z / Ctrl+Maj+Z). saveShort filtre les passages croisés/vides (<0,2s) et envoie segments=null si 1 seul passage (chemin historique backend inchangé). Testé Playwright : chargement short 57 → 2 paires de barres, split→2, undo→1, redo→2, croisement→croix→suppression, 0 erreur JS. Backend redémarré (shorts-studio.service) ; front servi du disque (rechargement suffit). Recliquer « ✨ Analyser » pour régénérer avec la nouvelle méthodo.✕ (classe .xbtn) sous Exporter/⬇ ; clic → delShort(id) (confirm « Retirer cette proposition ? » puis DELETE /api/shorts/{id}). Désactivé pendant un export en cours. Front only (static/index.html), pas de restart : StaticFiles sert depuis le disque, un rafraîchissement suffit._do_suggest calcule target = clamp(round(duree/120), 6, 25) (~1 extrait / 2 min) et le passe au prompt avec la durée ; consigne = proposer TOUS les extraits pertinents (au moins target), passer la vidéo en revue de bout en bout, sans remplissage forcé. Cap code monté de 15 à 25 (via target). Note : la ré-analyse REMPLACE toujours les suggestions IA non exportées (comportement inchangé, Matt n'a pas tranché ce point) ; bascule possible en additif si demandé. Restart backend._sentence_segments(segs) qui reconstruit de VRAIES phrases : découpe le texte de chaque chunk à la ponctuation terminale (re.split(r'(?<=[.?!…])\s+')) avec timestamps interpolés au prorata des caractères, puis fusionne les morceaux jusqu'à une fin de phrase (flush ponctuation ou sécurité 20s). _do_suggest construit désormais sents=_sentence_segments(segs), donne ces phrases complètes à l'IA (lines) ET cale les coupes dessus (_clean_segments(raw_segs, sents) au lieu de segs). Testé htBJoPWcqes : 713 chunks -> 394 phrases, 0 phrase tronquée. Prompt réécrit = beaucoup plus prudent : PAR DEFAUT un passage continu (1 segment), resserrage limité à retirer 1-2 phrases de remplissage SI l'enchaînement reste fluide, compilation lointaine « rare et délicate » (abandonner si ça sonne bizarre), TEST OBLIGATOIRE de relecture mentale des segments collés (doit se lire comme une prise continue, sinon revenir au continu), qualité > quantité (viser 4-10 shorts). Requiert restart shorts-studio.service (backend). Pour appliquer : recliquer « ✨ Analyser & proposer des extraits » (regénère et remplace les anciennes suggestions IA non exportées)..h 12->20px de large avec zone de clic élargie invisible ::before (-10px de chaque côté), curseur col-resize, grip double barre blanche plus visible, halo acc2 au survol et pendant le drag (.grab), coins arrondis, z-index au-dessus de l'onde. Badge de temps précis au-dessus de chaque poignée (#labL/#labR, format m:ss.d dixièmes via fmtDurP), visible au survol de la timeline et pendant le drag, mis à jour dans drawRegion et pendant le déplacement. Offset handle recalé -10 (largeur 20). Boutons « Début : ⟨ −0,5s / +0,5s ⟩ » dans la tlbar -> nudgeStart(dt) (clamp start<end-0.5, saute l'aperçu au nouveau début via pv.currentTime, tlFit si on sort de la fenêtre, loadTrack). Hint réécrit sous la timeline (poignées + boutons + molette/Ctrl+molette). Servi depuis le disque -> actif au rechargement. JS validé node --check._jobs repart vide -> /status renvoie idle, et l'ancien pollSuggest/pollExport ne gérait que running/done/error -> la barre tournait à l'infini. Désormais tout état non-running est terminal (stop + renderDetail), avec une tolérance de 2 ticks sur idle au démarrage de suggest (le temps que le thread passe en running). Rappel opérateur : systemctl restart shorts-studio.service tue tout job en cours (analyse/export) ; relancer après un restart.:active{scale(.95)+brightness} (feedback clic), :focus-visible halo, .busy (spinner+opacité). Barres : .prog refondue (gradient accent + sheen animé + variante .indet indéterminée), helper progBar(pct,msg,indet) (barre + %+ msg) et busy(btn,label) (spinner sur bouton, renvoie une fonction de restauration). Câblé partout : préparation, suggestions IA (correctif : suggest() ne lançait jamais le polling -> barre figée ; maintenant feedback immédiat via #sugPanel + pollSuggest met à jour le % à chaque tick), export (barre immédiate par short via #prog<id> + busy sur le bouton, pollExport réécrit la barre avec le message d'étape), suivi du visage (spinner dans #trkhint), save/nouveau/télécharger YouTube (busy). Boutons async passent this._title_lines_and_size(text,out_w,manual_size) : si le texte contient des \n -> lignes respectées telles quelles (nb de lignes libre) ; taille manuelle si fournie sinon calculée pour tenir la largeur ; sinon 1 ligne (taille manuelle) ou heuristique auto 1-2 lignes. Colonne titlesize REAL (null=auto), save la clampe 20-160, export la passe. Front : #eTitle devient un textarea multi-lignes (Entrée = nouvelle ligne), nouvelle ligne de contrôles « taille auto » (checkbox) + slider 24-120px + valeur ; titleLinesAndSize(text,1080,manualSize) miroir exact. drawTitleCard utilise CUR.titlesize. Testé : rendu multi-lignes/tailles OK, export 3 lignes manuelles + taille 90 + position 0.35 OK.titley REAL (fraction 0..1 du haut du texte ; null = preset titlepos). Export : y=(h*{titley}) sinon 0.09 (haut) / 0.60 (bas). Save accepte titley (clamp 0..0.95). Front : titleY() (libre sinon preset), drawTitleCard memorise le rect _titleBox, setupTitleDrag() (mousedown sur le bandeau -> drag vertical, curseur grab, clamp pour rester a l'ecran, handlers window cables une fois). Le select haut/bas reste et fixe titley au preset. Titre rendu aussi en pause (pas seulement 0-5s) pour pouvoir le placer a tout moment ; togglePlay redessine a la pause. Hint « glisse le titre dans l'aperçu pour l'ajuster ».segments TEXT (JSON [{start,end,text}]) ; vide/1 bout = comportement historique via start/end (= span global). Suggestion (_do_suggest) : transcript fourni avec [debut-fin] par phrase, prompt de MONTEUR (resserrage + compilation, agressivite equilibree, coupe seulement aux frontieres de phrases, interdiction de deformer le propos), rend segments[] par short, jusqu'a 15 ; _clean_segments() cale chaque bout sur des phrases entieres (recouvrement >30%), attache le texte, preserve l'ordre voulu, fusionne les bouts colles. Export (_do_export) : si >1 bout, _build_concat_source() recolle les bouts (ffmpeg filter_complex trim/atrim/concat, reencode veryfast crf18) en un mp4 temp, puis TOUT le pipeline existant (suivi visage, titre, sous-titres) s'applique dessus comme un clip unique (start=0,end=total) ; temp nettoye. Sous-titres recales sur la timeline recollee via _make_segments_srt(). Facetrack preview : _face_track_norm(vid, ranges) accepte plusieurs bouts et renvoie des points en TEMPS ABSOLU (EMA par bout) ; endpoint facetrack?...&segments=<json>. Front : parseSegs/segsTotal/isMulti, panneau "✂️ Montage : N bouts" dans l'editeur (liste des bouts avec texte + ▶ apercu bout + ✕ retirer, removeSeg/playSeg), lecture enchainee des bouts (onTick saute au bout suivant a la fin de chaque bout, resync sur scrub via curSegIdxAt), titre cale sur montageElapsed(), trkCenter en temps absolu, poignees timeline desactivees en multi, waveform teinte chaque bout garde. Save envoie segments (apres retraits). Teste : recollage 3x4s=12s OK ; export complet 3x5s -> 15s 1080x1920 exporte avec suivi OK (~37s).<div id="crop">, ses règles CSS, setupCropDrag() (fonction + appel dans l'init), le hint « Glisse le cadre orange ». drawCrop() réduit à un clamp de cx/cy (fallback quand track=off) + drawVprev(). La vidéo source (gauche) reste la vue contexte, l'aperçu vertical (droite) est la seule référence de cadrage. Hint remplacé par « Cadrage automatique sur le visage »._title_lines_and_size → titleLinesAndSize(text,1080) (même heuristique 1/2 lignes + fontsize 34-72) et drawTitleCard(ctx,c) appelé en fin de drawVprev() : bandeau noir @0.92 (boxborderw 28) + texte blanc, échelle export k=c.width/1080, y = 0.09 (haut) ou 0.60 (bas) de 1920, visible uniquement tRel∈[0,5] (comme enable='between(t,0,5)'). Handlers live ajoutés dans openEdit : #eTitle (oninput), #eCard, #eCardPos (onchange) → drawVprev(). Fidèle à l'export ; le titre n'apparaît que sur les 5 premières secondes de l'extrait (normal).GET /api/shorts/facetrack?vid&start&end → _face_track_norm() : même détecteur YuNet que l'export, mais sur le proxy 480p (rapide), échantillonnage ~0.35s, EMA alpha=0.18 sur cx ET cy, renvoie des points normalisés {"ok",pts:[[t_rel,cx,cy]]} (0..1, indépendants de la résolution), cache mémoire _facetrk_cache par (vid,start,end). Front : loadTrack() (appelé sur setActive, changement start/end, drag timeline, toggle #eTrack) fetch + calcule cyFixed (médiane, comme l'export qui fige y et ne bouge qu'en x) ; trkCenter() interpole cx à l'instant courant ; drawVprev() recadre sur ce centre au lieu de cx/cy fixe. Hint #trkhint sous le canvas (« analyse du visage… » / « suivi actif » / « visage non détecté »). L'aperçu reflète donc fidèlement l'export (x suit, y figé). Vérifié sur le short htBJoPWcqes 210-257s : 135 points, cx 0.55→0.62 (Matt à droite du centre) → visage recentré.#pvv 9:16 à droite de la vidéo source, classe .vprev, wrapper .stagewrap flex). drawVprev() dessine la zone recadrée (crop cropFrac() + cx/cy) de pv sur le canvas via drawImage(sx,sy,sw,sh -> 0,0,w,h) ; boucle rAF vLoop()/startVloop() pendant la lecture, appel ponctuel sur drawCrop (drag du cadre), seeked et loadedmetadata. But : voir en direct le vrai cadrage 9:16 du short (le cadre orange sur la source restait un simple guide, source de confusion répétée). Le suivi de visage et la vignette ne sont pas simulés dans l'aperçu (export-only). Rappel UX : bouton Play = lit toute la source depuis la position courante ; Lire l'extrait = seek au début du short + lecture en boucle sur la plage.track INTEGER DEFAULT 1 ; l'INSERT des suggestions IA force track=1 ; l'endpoint save traite l'absence de clé comme ON (track=0 seulement si explicitement false/0, même logique que titlecard) ; front : nouveau short manuel créé avec track:1 (case cochée d'office, la case reflète CUR.track). Migration one-shot : les 8 shorts existants non exportés passés à track=1. Conséquence : chaque short (IA ou manuel) part avec l'auto-reframe, décocher reste possible par short. Coût : +30-60s d'analyse à l'export._title_lines_and_size(text, out_w=1080) remplace _wrap_title : coupe le titre en 1 ligne (si lisible, police >= 50px) ou 2 lignes équilibrées (split qui minimise la ligne la plus longue), puis calcule fontsize automatiquement selon la longueur (largeur utile 1080-140px, advance glyphe DejaVu Bold ~0.56*fontsize), borné 34-72px. Le drawtext reçoit fontsize={fsize} dynamique + enable='between(t,0,5)' (le titre disparaît après 5s). Testé : "Court titre"→72px 1 ligne, titre moyen→67px 2 lignes, titre long→47px 2 lignes ; frames export à 2s (titre présent) et 10s (titre absent) vérifiées.shorts.titlecard défaut 1, shorts.titlepos défaut 'top'). Le titre du short est écrit en blanc gras (DejaVuSans-Bold, TITLE_FONT) sur bandeau noir (box=1:boxcolor=black@0.92) via drawtext (textfile pour éviter l'échappement, _wrap_title coupe à ~20 car / 3 lignes max). Placé en haut (y=h*0.09, au-dessus du visage, hors zone masquée par l'UI Instagram) ou en bas (h*0.60). Checkbox « vignette titre » (défaut ON) + select haut/bas dans l'éditeur. Testé visuellement : lisible, ne recouvre pas le visage, cohérent avec le suivi de visage.shorts.track). YuNet (assets/yunet.onnx, cv2.FaceDetectorYN) détecte le visage toutes les ~0.2s sur la plage du short (détection sur frame réduite à 640px), lissage EMA (alpha 0.18) du centre x, cy fixé sur la médiane. Export : _track_face génère un script sendcmd qui pilote crop x au fil du temps (sendcmd=f=...,crop=cw:ch:cx:cy,scale=1080:1920), donc x(t) suit le visage sans réencodage Python. Checkbox « 🎯 suivi du visage » dans l'éditeur. Testé sur l'extrait 3:30-4:17 : sortie 1080x1920, visage centré (cx 0.51→0.57). Si détection échoue → repli sur le crop fixe (cx/cy). Dépend d'opencv-python (cv2 5.0) déjà installé + assets/yunet.onnx (232 Ko, OpenCV Zoo).VW gauche/droite ; ctrlKey (pincement) zoome autour du curseur. Listener wheel non passif sur #tl (preventDefault).setActive → charge dans le lecteur gauche, cale sur le début, ouvre les champs. Barre de lecture (scrubber pleine durée, clic/drag). Timeline (#tl) avec règle de temps, forme d'onde (endpoint GET /api/videos/{vid}/peaks, 15 pics/s depuis le proxy, cache data/shorts-studio/peaks/{vid}.json), région début→fin avec poignées L/R draggables + corps déplaçable, playhead. Boutons zoom timeline (Ajuster à l'extrait / + / − / Tout voir). Lecture en boucle sur la plage. Indicateur de durée (vert si 15-60s). Recadrage 9:16 synchro. Champs début/fin ↔ timeline bidirectionnels.whisper-1, response_format=verbose_json, language=fr), par tranches de 10 min (limite 25 Mo/API), avec cache disque data/shorts-studio/subs/{vid}.transcript.json (réutilisé par l'export sous-titres). Fonction _transcribe_local. Gotcha corrigé : le service systemd tourne avec un env propre (pas de .env) → OPENAI_STT_KEY était None → repli YouTube bloqué → « pas de sous-titres ». Ajout de _load_env() au démarrage de app.py (charge /root/aios/.env). Testé bout en bout sur htBJoPWcqes (52 min → transcription → 8 extraits IA en ~4 min).POST /api/videos/{vid}/upload (multipart, streaming vers data/shorts-studio/sources/{vid}.<ext>) → _make_proxy (factorisé, partagé avec prepare). Bouton « Importer un fichier vidéo » dans le panneau Préparation, upload XHR avec barre de progression. nginx : client_max_body_size 8192m + proxy_request_buffering off + timeouts 3600s sur le vhost. Testé OK bout en bout. Raison : l'IP du VPS est flaggée par YouTube, les cookies (même frais) sont rejetés (cookies_rejected), donc le téléchargement auto reste incertain ; l'import est le chemin fiable.SHORT_MAX=180 : la sync n'insère plus les vidéos <= 3 min et GET /api/videos ne renvoie que duration > SHORT_MAX (masque aussi les lives durée 0/inconnue). Filtre non destructif (les rows courtes restent en base). 246 vidéos → 71 longues affichées. Demandé par Matt (« fais en sorte que les shorts apparaissent pas »).lance l'IA) dans un template literal cassait tout le JS de index.html (aucun bouton ne répondait). Remplacée par ', script validé node --check.