← Tous les manuels · Portail
↗ Ouvrir l'app

Shorts Studio (shorts-studio)

Tableau de bord + éditeur pour transformer les vidéos longues YouTube de Matt (@mattparaguay) en shorts verticaux 9:16.

Rôle

  • - Backlog : liste des vidéos de la chaîne (via API YouTube), statut de découpe (à traiter / en cours / terminé / ignoré) et nombre de shorts sortis par vidéo. But : voir d'un coup d'œil ce qu'il reste à découper.
  • - Préparation : télécharge la source (via dl.py, qui gère cookies + fix yt-dlp) et génère un proxy 480p pour la preview.
  • - IA : lit le transcript (auto-subs yt-dlp) et propose 6-8 extraits à fort potentiel via claude -p.
  • - Éditeur : trim début/fin + recadrage 9:16 draggable (+ zoom) + sous-titres brûlés optionnels, export MP4 vertical (ffmpeg).
  • URL / Service / Port

  • - URL publique : https://shorts.panelbay.com
  • - Service systemd : shorts-studio.service (systemctl status/restart shorts-studio)
  • - Port local : 8794
  • - nginx vhost : /etc/nginx/sites-enabled/shorts.panelbay.com.conf
  • Structure

  • - app.py : backend FastAPI. Endpoints :
  • - 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).
  • Données

  • - DB SQLite : /root/aios/data/shorts-studio/shorts.db (tables videos, shorts).
  • - Proxies : /root/aios/data/shorts-studio/proxies/{vid}.mp4.
  • - Sous-titres : /root/aios/data/shorts-studio/subs/.
  • - Shorts exportés : /root/workspace/Shorts/<slug-video>/<slug-short>_<id>.mp4 (visibles dans le workspace).
  • Modèle de recadrage 9:16 (important)

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

    Pièges

  • - Téléchargement bloqué par YouTube : depuis l'IP du VPS, YouTube renvoie souvent "Sign in to confirm you're not a bot" (LOGIN_REQUIRED), y compris sur les vidéos de Matt. 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.
  • - Suggestions IA : 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.
  • - Export : re-encode depuis la source pleine qualité (preset medium, crf 20). Une vidéo source longue = gros téléchargement/proxy (timeout 1h).
  • CHANGELOG

  • - 2026-09-28 (z) : timeline = une paire de barres PAR passage + croix de suppression + annuler/refaire (demande Matt : « les shorts ne veulent rien dire » + éditeur trop rigide). Méthodo IA rééquilibrée (choix Matt : *simple par défaut + resserrage sûr*) : le prompt _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.
  • - 2026-09-27 (x) : croix pour retirer une proposition (demande Matt). Chaque carte de short (panneau de droite) a maintenant un bouton ✕ (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.
  • - 2026-09-27 (w) : nombre d'extraits proportionnel à la durée (Matt : « l'important c'est pas combien mais combien sont pertinentes ; sur 30 min on peut en avoir au moins 15 »). Retiré le plafond « qualité > quantité, vise 4-10 » de (v) qui bridait le nombre. _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.
  • - 2026-09-27 (v) : rééquilibrage : l'IA remontait à 0 montage (Matt : « il me propose aucun short découpé »). Le prompt (u) était trop conservateur (« PAR DEFAUT un passage continu », « le plus souvent 1 seul segment ») -> l'IA ne sortait que des extraits d'un seul tenant. Maintenant que les phrases sont propres (u), le resserrage est sûr : prompt réécrit pour que la MAJORITE des shorts soient resserrés (2-5 segments, on saute les phrases de remplissage au milieu), seule contrainte = l'enchaînement après coupe reste fluide ; compilation lointaine toujours « délicate » ; 1 seul segment seulement si le passage est déjà parfait ; format JSON d'exemple remontré avec 2 segments. Test de cohérence (u) conservé. Restart backend. Recliquer « ✨ Analyser » pour regénérer.
  • - 2026-09-27 (u) : fix montages incohérents ("phrases qui veulent rien dire") (Matt). Double cause : (1) les segments de transcription (Whisper/VTT) tombent en plein milieu des phrases et sont parfois longs (20-30s), donc « couper aux frontières de phrases » coupait en réalité au milieu ; (2) le prompt poussait trop la compilation entre passages éloignés. Corrigés : nouvelle fonction _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).
  • - 2026-09-27 (t) : poignées plus faciles à manipuler + timeline plus lisible pour caler le début (demande Matt). CSS : timeline 96->140px de haut (canvas onde 80->122px, règle 16->18px, texte 10->11px) ; poigné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.
  • - 2026-09-27 (s) : fix barre figée quand un job est interrompu. Si le service est redémarré (ou le thread meurt) pendant une analyse/export, _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.
  • - 2026-09-27 (r) : feedback UI : clic net + barres de chargement propres. CSS boutons : :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.
  • - 2026-09-27 (q) : éditeur de titre avancé (taille + lignes libres) (Matt : la règle 2 lignes n'est pas toujours le must). _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.
  • - 2026-09-27 (p) : position verticale du titre libre (drag dans l'aperçu) (demande Matt). Colonne 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 ».
  • - 2026-09-27 (o) : montage multi-bouts (jump cuts) + suggestions IA qui montent vraiment (demande Matt : que l'IA cible des phrases sur mesure, coupe le remplissage au milieu, et compile des bouts de differents endroits, jusqu'a ~15 shorts, sans deformer le sens). Un short peut desormais contenir plusieurs bouts. Schema : colonne 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).
  • - 2026-09-27 (n) : suppression du cadre manuel (Matt : « l'outil pour mettre le frame moi-même sert plus à rien »). Le suivi de visage pilote le cadrage horizontal (l'export ignore cx quand track=on via sendcmd), donc le cadre orange draggable devenait inutile et trompeur (statique alors que le crop bouge). Retirés : <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 ».
  • - 2026-09-27 (m) : la vignette titre s'affiche dans l'aperçu (Matt : « le titre blanc sur noir apparaît pas dans l'aperçu »). Port JS de _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).
  • - 2026-09-27 (l) : l'aperçu vertical suit désormais le visage (Matt voyait le visage non centré dans l'aperçu et croyait le suivi cassé, alors que le suivi n'était appliqu qu'à l'export). Nouvel endpoint 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é.
  • - 2026-09-27 (k) : aperçu vertical live du short (canvas #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.
  • - 2026-09-27 (j) : suivi de visage activé par défaut (demande Matt : « le visage centré sans y penser »). Schéma 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.
  • - 2026-09-27 (i) : vignette titre : 2 lignes max, taille auto, 5 premières secondes (demande Matt). Nouvelle fonction _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.
  • - 2026-09-27 (h) : vignette titre / contexte en overlay (colonnes 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.
  • - 2026-09-27 (g) : suivi de visage / auto-reframe (option, colonne 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).
  • - 2026-09-27 (f) : timeline : wheel/touchpad → glissement 2 doigts (deltaX ou deltaY) déplace la fenêtre VW gauche/droite ; ctrlKey (pincement) zoome autour du curseur. Listener wheel non passif sur #tl (preventDefault).
  • - 2026-09-27 (e) : éditeur retravaillé (demande Matt). Clic sur un extrait (carte de droite) = 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.
  • - 2026-09-27 (d) : transcription LOCALE pour les suggestions IA (et les sous-titres). Les auto-subs YouTube étant bloquées comme le download, on transcrit le fichier source via Whisper (OpenAI 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).
  • - 2026-09-27 (c) : import direct du fichier source (contourne le blocage YouTube, idéal pour les propres vidéos de Matt). Endpoint 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.
  • - 2026-09-27 (b) : le backlog exclut les Shorts/clips. 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 »).
  • - 2026-09-27 (fix) : apostrophe mal échappée (lance l'IA) dans un template literal cassait tout le JS de index.html (aucun bouton ne répondait). Remplacée par &#39;, script validé node --check.
  • - 2026-09-27 : création. Backlog + sync YouTube (246 vidéos), préparation (dl.py + proxy), suggestions IA (transcript + claude -p), éditeur 9:16 draggable, export vertical, sous-titres optionnels. Exposé sur shorts.panelbay.com. Demandé par Matt (« plateforme pour découper mes vidéos longues en shorts + savoir lesquels il manque »).