Compare commits

...
Author SHA1 Message Date
Claude 4b8fbe6d8e fix(voix SRT): prix et unités reconnus, coupes dans les vraies pauses
- Contrôle de lecture : les nombres et l'unité qui les suit ne sont plus
  comparés lettre à lettre. Whisper écrit « 11 € 99 » ou « 40 cm » là où le
  texte dit « 11,99 € » ou « 40 centimètres » : une lecture correcte passait
  pour des mots sautés (88–91 %) et relançait jusqu'à 3 générations.
- Découpe de la lecture SRT : la coupe entre deux sous-titres se fait dans
  la pause mesurée la plus proche de la borne estimée (fins de phrases
  préférées aux virgules), et non plus à la borne Whisper elle-même, fausse
  quand un prix non reconnu termine la phrase : la coupe tombait alors au
  milieu d'une phrase.
- Whisper reçoit 0,5 s de silence avant la voix : il laissait passer le
  premier mot (« Alerte ») d'une voix qui démarre aussitôt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3ywVwJUjjUXuvM6PwSzSi
2026-09-28 16:44:41 +00:00
Claude 7c38ec459f fix(voix): refuser les lectures Gemini qui sautent des mots
Le contrôle lettre à lettre acceptait une lecture dès 85 %, alors qu'une
lecture complète atteint ~97 % (petites erreurs de Whisper) : une lecture à
86 % (mots sautés) passait sans nouvelle génération. Seuil porté à 92 %.

Les mots non entendus sont nommés dans les journaux et l'avertissement,
pour savoir ce qui manquait. Le modèle Whisper, déjà dans l'image, est
chargé sans interroger Hugging Face (HF_HUB_OFFLINE).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:25:43 +00:00
Claude d4c544e467 fix(voix): plus de fins de phrases coupées ni de bruits parasites
Fins de phrases coupées :
- Le fondu final ne commence plus avant la fin de la voix (il rognait la
  dernière phrase quand la vidéo finissait peu après) ; il se raccourcit au
  besoin. Même règle côté Python, aperçu et rendu Remotion
- SRT : découpe au milieu de la zone la plus calme de chaque pause, mesurée
  sur l'enveloppe sonore (tranches de 10 ms), avec un seuil relatif au
  niveau de la voix qui respecte les fins douces ; 300 ms gardées après le
  dernier son

Bruits parasites :
- Normalisation de la voix en deux passes, en mode linéaire : le mode
  dynamique relevait souffles et bruits entre les phrases (mesuré : -64 dB
  avant, -75 dB après dans les pauses)
- Réduction de bruit douce (afftdn), compresseur sans gain ajouté, fondus
  de 5 ms aux extrémités ; mix final moins « pompant » (LRA 20)
- Voix Gemini vérifiée : les bruits ajoutés avant le premier ou après le
  dernier mot sont retirés

Vérification de lecture comparée lettre à lettre : les petites erreurs de
Whisper ne déclenchent plus de régénérations inutiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:21:18 +00:00
Claude 44899055b2 fix(voix): intonation stable, lecture vérifiée et SRT sans mots coupés
Intonation :
- Gemini reçoit une température basse et une graine fixe (réglables) : la
  lecture ne change plus de ton d'une génération à l'autre
- SRT : tout le texte est lu d'une seule traite puis découpé phrase par
  phrase, au lieu d'une génération (et d'une intonation) par sous-titre ;
  un seul traitement de niveau, plus de sauts de volume entre les phrases

Mots coupés :
- La voix est découpée dans ses vrais silences (silencedetect), plus aux
  bornes des mots estimées par Whisper qui rognaient les syllabes ; fondus
  de 10 ms aux coupes
- Whisper n'est plus amorcé avec le texte attendu : il hallucinait des
  répétitions qui masquaient les fins tronquées

Fiabilité :
- Chaque voix Gemini est réécoutée : mots sautés, fin tronquée ou consigne
  lue à voix haute déclenchent une nouvelle génération (3 au plus), puis
  repli sur Edge avec avertissement
- Consigne de lecture intégrale ajoutée au prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:04:08 +00:00
Claude f3113a0f5a fix(reels): garder le son d'origine de la vidéo quand aucune musique n'est choisie
Sans musique (bouton « Passer »), la bande son de la vidéo était supprimée
dès que la voix était activée. Elle sert désormais de fond sonore : conservée
telle quelle, baissée automatiquement sous la voix, puis normalisée avec le
reste. Une musique choisie la remplace toujours.

Même comportement dans l'aperçu, le rendu Remotion et le rendu FFmpeg de
secours. Le bouton indique « Passer (garder le son de la vidéo) ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 08:57:38 +00:00
Claude 1d47763b29 feat(reels): interrupteur « Afficher le logo sur la vidéo »
Le petit logo affiché pendant la vidéo devient optionnel (activé par
défaut), sur les 4 pages Reel (vidéo et images, bureau et mobile) ainsi que
dans l'aperçu. Le grand logo de l'effet de fin reste piloté par son propre
interrupteur. Pris en charge par le rendu Remotion et par le rendu FFmpeg de
secours (show_watermark).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 06:45:38 +00:00
Claude f5e2461da1 feat(reels): import d'un fichier SRT lu par la voix à son minutage
- Nouveau Reel (bureau et mobile) : bouton « Importer un fichier SRT ».
  Tant qu'un SRT est chargé, le texte libre et l'assistant IA sont
  désactivés ; le choix de la voix (moteur, voix, ton) reste disponible.
- Service Python : chaque sous-titre est lu séparément, ses silences
  retirés, puis posé à son instant de début. Une lecture trop longue est
  accélérée (atempo, sans changer la hauteur) pour tenir dans la durée du
  sous-titre, avec un avertissement au-delà de ×1,35. Après un repli sur
  Edge, tous les sous-titres gardent la même voix.
- Sans voix, les mots de chaque sous-titre s'affichent sur son intervalle.
- Aperçu : sous-titres calés sur le minutage du fichier.
- Tests : lecture SRT (TS), fenêtres, accélération et mixage (Python).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVqEw4Xycgf8BtwmSftE2M
2026-09-27 08:19:44 +00:00
Claude 9b66a6dc5a feat(reels): montage Remotion unifié, aperçu en direct, tests et CI
Montage (étape 3) :
- Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles :
  Impact, Surligné, Épuré), logo, effet de fin et fondu, en React
- Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière
  image figée) et la piste son finale (/prepare-reel) ; Remotion compose
- Reels d'images sur les mêmes composants, avec le vrai minutage de la voix
- Police Montserrat embarquée (plus de dépendance à Google Fonts)
- REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours
- Script de pré-bundle réparé (échouait en silence : require en ESM)

Interface (étape 4) :
- Aperçu en direct avec @remotion/player, identique au rendu final ; la voix
  testée cale les sous-titres, sinon minutage estimé
- Choix du style de sous-titres sur les 4 pages Reel
- Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur
  cause ; fin de la barre simulée et du faux « publié avec succès »

Outillage (étape 5) :
- Tests vitest (minutage identique à Python, validation, sécurité) et
  pytest ; CI GitHub Actions (tsc, tests, build, ruff)
- Captures d'écran, out.mp4 et scripts de test retirés de la racine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 14:37:42 +00:00
Claude 3c6a66bf64 feat(reels): voix Gemini complète, sous-titres mot à mot et rendu de qualité
Service ffmpeg-api réécrit (ffmpeg-service/app/) :
- Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu
- Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON
- Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible
- 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme),
  clé en en-tête, modèle configurable avec repli
- Edge TTS 7 (minutage des mots restauré), secours en voix françaises
- Voix traitée : filtre, compression, niveau constant ; musique bouclée et
  baissée automatiquement sous la voix ; mix final à -14 LUFS
- Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine,
  à la place de ffsubsync), style Montserrat, placés hors des boutons Reels
- Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone
  converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse
- Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et
  modèle Whisper intégrés à l'image ; tests pytest et ruff

Application :
- Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute
- Reel images : minutage réel des mots, interrupteur voix respecté
- sync-info ne génère plus de voix à chaque frappe (estimation locale)
- Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée
- Log « [ReelQueue] Worker démarré » pour vérifier la version déployée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 12:31:27 +00:00
Claude d24ab0d4b7 feat(reels): file d'attente persistante des rendus (reel_jobs)
L'ancienne file comptait les posts « processing » : un redémarrage pendant
un rendu la bloquait définitivement. Les rendus passent désormais par une
table reel_jobs, réservée avec FOR UPDATE SKIP LOCKED, avec heartbeat et
reprise des jobs interrompus au démarrage (2 tentatives maximum).

- Pipeline vidéo et pipeline images (Remotion) extraits des routes vers
  server/services/reels/, les routes ne font que valider (zod) et mettre en file
- Rendus d'images suivis en base au lieu d'une Map en mémoire
- storeName conservé pour les jobs mis en attente
- Publication Facebook en binaire : l'URL relative /uploads/... transmise
  auparavant n'était pas téléchargeable par Facebook
- Job en échec si toutes les pages échouent ou si la voix demandée manque
  (le service Python renseigne enfin tts_error)
- La voix choisie sur la page images est réellement utilisée
- sync-info mesure la voix Gemini avec sa clé
- Timeouts sur les appels au service FFmpeg, vignettes via execFile,
  /debug-ffmpeg protégé par la clé API, plus de voix anglaise en secours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 09:31:31 +00:00
Claude fcbc6b706f fix(docker): construire les images au lieu de tenter de les télécharger
Portainer échouait en « Pull and redeploy » : `pull access denied for
sociaflow-app, repository does not exist`. Les services `app` et
`ffmpeg-api` n'ayant que `build:`, Compose leur dérivait un nom d'image
depuis le nom de la stack et Docker allait la chercher sur Docker Hub,
où elle n'existe évidemment pas.

- `pull_policy: build` : l'image est reconstruite, jamais téléchargée
- `image:` explicite, pour que le nom ne dépende plus du nom donné à la
  stack dans Portainer
- DOCKER.md : procédure Portainer et diagnostic de cette erreur

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
2026-09-11 18:08:48 +00:00
Claude 3af7f9418b fix(oauth): déduire l'URI de redirection du domaine public réel
Sans APP_URL, Facebook et TikTok recevaient
http://localhost:5555/api/{facebook,tiktok}/callback : une URI que les
fournisseurs refusent, donc aucune page connectable derrière un reverse
proxy. Le repli localhost n'est plus utilisé que hors requête HTTP ; en
service, la base publique est reconstruite depuis X-Forwarded-Proto /
X-Forwarded-Host (trust proxy est déjà activé).

- `resolvePublicBaseUrl(req)` centralise la résolution (APP_URL prioritaire)
- l'URI de redirection est déduite de la requête au démarrage du flux, à
  l'échange du code et dans l'affichage des paramètres : les trois restent
  identiques, comme l'exige OAuth
- docker-compose ne force plus APP_URL à localhost, qui rendait l'auto
  détection inopérante
- cookie de session en `secure: 'auto'` à défaut d'APP_URL, pour ne pas
  émettre un cookie non sécurisé derrière un proxy HTTPS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
2026-09-11 18:01:28 +00:00
Claude 94e961d0f1 perf(api): alléger les requêtes du calendrier, des stats et de la médiathèque
Trois défauts se cumulaient : des réponses bien plus larges que nécessaire,
aucun index en base, et des boucles de requêtes là où une seule suffisait.

Réponses
- /api/scheduled-posts renvoyait la ligne `social_pages` entière, jetons
  d'accès Facebook et TikTok compris, jusque dans le navigateur. Seules les
  colonnes réellement affichées sont désormais sélectionnées.
- Le filtre de dates est poussé en SQL. Il était appliqué en JavaScript après
  avoir chargé tout l'historique — que le client ne bornait jamais. Le
  calendrier demande maintenant les six semaines affichées, le sélecteur de
  date les créneaux à venir, le bloc « publications récentes » ses 90 jours.
- /api/media accepte `limit` et `offset` (sans eux, réponse inchangée : des
  écrans doivent retrouver un média par son identifiant). La médiathèque
  pagine côté serveur au lieu de tout charger pour afficher cinq vignettes, et
  l'écran de création ne demande que les douze médias qu'il propose. Le total
  voyage dans l'en-tête X-Total-Count pour garder le compteur juste.

Index
- server/migrate.ts est le seul script exécuté au démarrage et n'en créait
  aucun ; ceux de migration-docker.sql ne servaient à rien, ce fichier n'étant
  monté nulle part. Postgres n'indexant pas les clés étrangères, le calendrier,
  l'historique et le scheduler parcouraient les tables entières. S'y ajoutent
  deux index partiels pour les boucles de fond : publications en attente et
  suivi asynchrone TikTok.

Requêtes
- /api/stats lisait quatre tables entières pour en compter les lignes : c'est
  désormais quatre COUNT filtrés.
- Les vues d'administration listaient les utilisateurs puis interrogeaient les
  pages de chacun ; une seule requête les remplace.
- getPostWithMedia et l'API externe chargeaient les médias un par un.
- /api/reels/ongoing chargeait tous les posts de l'utilisateur pour n'en garder
  que ceux en cours de génération, toutes les trois secondes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:39:16 +00:00
Claude 25bf0981bd perf(client): rafraîchir les données sans recharger la page
`staleTime: Infinity` combiné à `refetchOnWindowFocus: false` empêchait toute
revalidation : une donnée chargée à la première visite restait affichée pour
toute la session. Or le serveur agit de son côté — le scheduler publie chaque
minute, les jetons se renouvellent en tâche de fond — sans que le navigateur en
soit averti. D'où la nécessité de recharger la page pour voir quoi que ce soit.

Les compteurs du tableau de bord étaient le cas le plus visible : /api/stats et
/api/posts n'étaient invalidés par aucune mutation de l'application, et ne
pouvaient donc plus bouger du tout.

- Péremption courte (30 s) et rafraîchissement au retour sur l'onglet ou sur
  reconnexion, en remplacement du cache éternel.
- /api/stats et /api/posts, qui dérivent de tout le reste, sont invalidés depuis
  le MutationCache après chaque mutation réussie : aucune mutation ne peut
  raisonnablement se souvenir de le faire, et aucune ne le faisait. Les
  mutations d'authentification en sont exclues, la session étant déjà détruite
  quand la déconnexion aboutit.
- Les trois sondages à 3 s (historique desktop et mobile, reels en cours)
  devenaient inutiles hors traitement en cours. Ils ne s'emballent plus que
  lorsqu'il y a effectivement quelque chose à suivre — une publication dont
  l'heure est passée, une génération en route — et retombent à 30 s sinon. La
  page Historique rechargeait tout l'historique 20 fois par minute.
- La liste des modèles OpenRouter, servie par un appel sortant, garde une
  péremption d'une heure pour ne pas être redemandée à chaque retour d'onglet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:22:21 +00:00
Claude d7f002a64f feat(facebook): renouvellement automatique des jetons de page
Un jeton de page collé à la main meurt au bout de 60 jours — ou du jour au
lendemain si le mot de passe change, si le compte perd son rôle d'admin ou si
Meta révoque l'autorisation — et on ne l'apprenait qu'à la première publication
en échec : le contrôle quotidien existant se contentait de marquer la page
« expired ».

On reprend la chaîne recommandée par Meta, comme dans PleinR : autorisation
OAuth, échange du code contre un jeton utilisateur longue durée (~60 j), puis
dérivation des jetons de page — qui n'expirent pas. Le jeton utilisateur est
conservé chiffré sur chaque page : c'est lui qui permet au TokenManager de
régénérer un jeton révoqué sans intervention humaine, et de se prolonger
lui-même avant échéance.

- facebook_config : App ID / App Secret de l'application, saisis dans les
  paramètres (chiffrés en base) ou par variables d'environnement.
- /api/facebook : configuration, connexion OAuth, callback qui crée ou met à
  jour les pages, contrôle d'une page à la demande, contrôle global.
- TokenManager : /debug_token pour connaître l'état réel du jeton, puis
  renouvellement automatique 15 jours avant l'échéance ou dès révocation ;
  repli sur le contrôle historique tant que l'application n'est pas configurée
  ou si Facebook est injoignable, pour ne pas passer l'UI au rouge sur une
  panne réseau. Le cron passe à deux fois par jour.
- Les comptes Instagram professionnels rattachés à une page reçoivent le même
  traitement, puisqu'ils publient avec le jeton de cette page.
- L'UI montre l'état constaté (jeton valide, bientôt expiré, révoqué), l'erreur
  à corriger et un bouton de contrôle immédiat ; un bandeau signale les pages à
  reconnecter.
- /api/pages ne renvoie plus aucun jeton au navigateur, seulement un indicateur
  de renouvellement automatique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:11:43 +00:00
LogiFlow 55e502aa8d Merge pull request #110 from R0m1k3/claude/tiktok-store-account-management-m558za
Claude/tiktok store account management m558za
2026-08-12 10:02:11 +02:00
Claude ac8b494f24 fix: vignettes vidéo persistantes et remise à zéro des erreurs de types
Deux corrections demandées ensemble.

1. Vignettes vidéo

Le planificateur supprime la vidéo locale une fois publiée, alors que la
ligne `media` survit pour l'historique. L'interface tentait donc de lire
un fichier disparu pour en extraire une image : d'où les avertissements
« Video thumbnail generation failed » et les tuiles vides.

Une vignette JPEG est désormais extraite à la création du reel, dans les
deux flux de génération, et stockée à part : elle reste disponible quand
la vidéo ne l'est plus. La purge quotidienne la nettoie en même temps que
la ligne qu'elle illustre. L'extraction est tolérante à l'échec — une
vignette manquante dégrade l'affichage mais ne fait jamais échouer une
publication — et le composant retombe sur son icône de remplacement pour
les médias créés avant ce changement. La fonction d'extraction, jusque-là
privée à la route Remotion, devient un service partagé.

2. Erreurs de compilation préexistantes (27 → 0)

- remotion-video.tsx (bureau et mobile) : les composants Select étaient
  utilisés sans être importés, ce qui faisait planter le sélecteur de voix
  à l'exécution.
- external.ts : fileSize et aiGenerated étaient transmis dans un type que
  le schéma ne déclare pas, et ne fonctionnaient que par conversion
  implicite de PostgreSQL.
- reels.ts : l'interface MusicTrack, référencée mais jamais déclarée dans
  ce module.
- remotion.ts : chromiumOptions.args retiré. Remotion ne lit jamais cette
  clé, les drapeaux Chrome qu'elle contenait n'atteignaient donc pas le
  navigateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 08:00:48 +00:00
Claude c69fe4e462 fix(ia): afficher la cause réelle d'un échec de génération de texte
La route /api/ai/generate connaissait la cause exacte de l'échec
(configuration absente, clé invalide, crédits épuisés, modèle inconnu)
mais la remplaçait par un « Failed to generate text » en 500, et
l'interface affichait de son côté un message figé. Sans accès aux logs
du serveur, l'utilisateur n'avait donc aucun moyen de savoir quoi
corriger.

Le motif renvoyé par OpenRouter est désormais extrait, tronqué et
propagé jusqu'au bandeau d'erreur, avec un code HTTP qui distingue un
problème de configuration (400) d'un refus du service tiers (502).
La clé API n'apparaît à aucun moment dans ces messages.

Même traitement pour /api/reels/generate-text, qui souffrait du même
masquage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 07:49:23 +00:00
Claude 0570088722 feat(tiktok): servir le fichier de vérification du domaine
TikTok exige de prouver la propriété du domaine ("URL properties") avant
d'autoriser la Content Posting API, en servant un fichier à la racine du
site.

Le dossier public-root/ est exposé publiquement à la racine du domaine et
monté avant le catch-all du frontend. Y déposer un fichier suffit : pas de
modification de code ni de reconstruction du client, ce qui couvre aussi
les prochaines vérifications (autre domaine, Google, Meta).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:27:26 +00:00
Claude 3e00a3ca67 feat(legal): préremplir les mentions légales de la société exploitante
Renseigne l'identité de FROUARD DISTRIBUTION (enseigne LA FOIR'FOUILLE)
comme valeur par défaut des pages /terms et /privacy : forme juridique,
capital, siège et numéro RCS. Les relecteurs TikTok ouvrent ces pages
pendant l'audit et refusent les mentions incomplètes.

Chaque champ reste surchargeable par variable d'environnement si l'outil
venait à être exploité par une autre société du groupe.

L'adresse de contact reste à fournir via LEGAL_CONTACT_EMAIL : elle ne
figure pas au registre, et le RGPD impose un point de contact pour
l'exercice des droits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:19:16 +00:00
Claude d0f63c68e4 feat(legal): servir les pages /terms et /privacy exigées par TikTok
L'audit de l'application développeur TikTok impose une URL publique pour
les conditions d'utilisation et pour la politique de confidentialité, et
les relecteurs les ouvrent réellement.

Les pages sont rendues en HTML côté serveur, sans authentification, pour
rester lisibles par un robot qui n'exécute pas le JavaScript du client.
La politique décrit précisément ce que l'intégration fait : données reçues
de TikTok (open_id, nom d'affichage, avatar, jetons), finalité de
publication, chiffrement des jetons au repos, suppression à la
déconnexion et marche à suivre pour retirer l'autorisation.

Les mentions de l'exploitant se configurent via LEGAL_COMPANY_NAME,
LEGAL_COMPANY_ADDRESS et LEGAL_CONTACT_EMAIL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:14:13 +00:00
Claude b95cd23ebc feat(tiktok): publier les reels sur les comptes TikTok des magasins
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo
sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule
publication.

Un compte TikTok vit dans social_pages comme une page Facebook : il hérite
donc des permissions par utilisateur, de la planification et du calendrier.

Trois particularités de l'API TikTok structurent l'implémentation :
- pas de jeton saisi à la main : chaque compte passe par OAuth, et
  l'access token (24h) est renouvelé via le refresh token (1 an) avant
  chaque publication ;
- la publication est asynchrone : l'upload rend un publish_id, et un
  poller récupère l'identifiant définitif du post ;
- creator_info doit être interrogé avant chaque envoi pour ne demander
  qu'un niveau de confidentialité réellement autorisé sur le compte.

Serveur :
- service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks,
  suivi de statut) et routes de connexion/configuration
- schéma : platform 'tiktok', refresh token et scopes sur social_pages,
  publish_id/publish_status sur scheduled_posts, table tiktok_config
- routage par plateforme dans les deux flux de reels et le planificateur
- cron de suivi des publications, token manager et analytics adaptés

Client :
- connexion et reconnexion des comptes TikTok depuis « Pages gérées »
- sélection combinée pages Facebook / comptes TikTok à la publication
- configuration de l'application TikTok dans les paramètres (admin)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 14:29:38 +00:00
144 changed files with 10854 additions and 4030 deletions

No files matched your search

+98 -1
View File
@@ -11,6 +11,11 @@ DATABASE_URL=postgresql://socialflow:changeme@postgres:4523/socialflow
# Configuration de l'application # Configuration de l'application
PORT=5555 PORT=5555
NODE_ENV=production NODE_ENV=production
# URL publique de l'application, telle que vue par le navigateur.
# En production, mettez le vrai domaine (ex. https://socialflow.exemple.fr) :
# les URI de redirection OAuth (Facebook/TikTok) en découlent.
# Laissée vide derrière un reverse proxy, elle est déduite des en-têtes
# X-Forwarded-Proto / X-Forwarded-Host.
APP_URL=http://localhost:5555 APP_URL=http://localhost:5555
# Clé secrète pour les sessions (CHANGEZ CETTE VALEUR) # Clé secrète pour les sessions (CHANGEZ CETTE VALEUR)
@@ -30,17 +35,109 @@ OPENROUTER_API_KEY=your-openrouter-api-key-here
MINIO_ENDPOINT=http://minio:9000 MINIO_ENDPOINT=http://minio:9000
MINIO_PUBLIC_URL=https://media.example.com MINIO_PUBLIC_URL=https://media.example.com
# Optionnel: Configuration Facebook API # ==========================================
# FACEBOOK (connexion des pages et renouvellement des jetons)
# ==========================================
# Identifiants de votre application sur https://developers.facebook.com
# Ils peuvent aussi être saisis dans l'application (Paramètres → Facebook),
# ce qui est prioritaire sur ces variables.
# Sans eux, les jetons de page doivent être collés et remplacés à la main.
# FACEBOOK_APP_ID= # FACEBOOK_APP_ID=
# FACEBOOK_APP_SECRET= # FACEBOOK_APP_SECRET=
#
# URI de redirection OAuth à déclarer chez Facebook.
# Par défaut : ${APP_URL}/api/facebook/callback, ou le domaine vu par le
# reverse proxy quand APP_URL n'est pas défini.
# FACEBOOK_REDIRECT_URI=
#
# Permissions demandées à l'autorisation. Défaut : pages_show_list,
# pages_manage_posts, pages_read_engagement, pages_manage_engagement,
# read_insights, business_management, instagram_basic, instagram_content_publish
# FACEBOOK_SCOPES=
#
# Version de la Graph API utilisée pour l'OAuth (défaut : v19.0).
# FACEBOOK_GRAPH_VERSION=
# Optionnel: Configuration Instagram API # Optionnel: Configuration Instagram API
# INSTAGRAM_APP_ID= # INSTAGRAM_APP_ID=
# INSTAGRAM_APP_SECRET= # INSTAGRAM_APP_SECRET=
# ==========================================
# TIKTOK (publication des reels)
# ==========================================
# Identifiants de votre application sur https://developers.tiktok.com
# Ils peuvent aussi être saisis dans l'application (Paramètres → TikTok),
# ce qui est prioritaire sur ces variables.
# TIKTOK_CLIENT_KEY=
# TIKTOK_CLIENT_SECRET=
#
# URL de redirection OAuth à déclarer chez TikTok.
# Par défaut : ${APP_URL}/api/tiktok/callback, ou le domaine vu par le
# reverse proxy quand APP_URL n'est pas défini.
# TIKTOK_REDIRECT_URI=
#
# Scopes demandés à l'autorisation (défaut : user.info.basic,video.publish).
# "video.publish" nécessite que l'application ait passé l'audit TikTok ;
# sans audit, les vidéos publiées restent privées.
# TIKTOK_SCOPES=
# ==========================================
# MENTIONS LÉGALES
# ==========================================
# Affichées sur les pages publiques /terms et /privacy, dont TikTok exige
# les URLs pour l'audit de l'application développeur.
#
# L'identité de la société exploitante est déjà la valeur par défaut : seule
# l'adresse de contact doit obligatoirement être renseignée ici, le RGPD
# imposant un point de contact pour l'exercice des droits.
LEGAL_CONTACT_EMAIL=
# Surcharges facultatives (utiles si l'outil est exploité par une autre société)
# LEGAL_COMPANY_NAME=
# LEGAL_TRADE_NAME=
# LEGAL_COMPANY_LEGAL_FORM=
# LEGAL_COMPANY_RCS=
# LEGAL_COMPANY_ADDRESS=
# ========================================== # ==========================================
# API EXTERNE # API EXTERNE
# ========================================== # ==========================================
# Clé d'accès pour l'API externe /api/v1/* # Clé d'accès pour l'API externe /api/v1/*
# Générer avec: openssl rand -hex 32 # Générer avec: openssl rand -hex 32
EXTERNAL_API_KEY=your-secure-random-key-here EXTERNAL_API_KEY=your-secure-random-key-here
# ==========================================
# REELS : VOIX ET RENDU VIDÉO (service ffmpeg-api)
# ==========================================
# Clé partagée entre l'application et le service FFmpeg (CHANGEZ CETTE VALEUR).
FFMPEG_API_KEY=change-me-ffmpeg-key
#
# Clé Google AI Studio pour les voix Gemini (sinon : voix Edge, gratuite).
# Peut aussi être saisie dans l'application, ce qui est prioritaire.
GEMINI_API_KEY=
#
# Modèle Gemini TTS (défaut : gemini-2.5-flash-preview-tts ; repli automatique
# sur celui-ci si le modèle choisi n'existe pas).
# GEMINI_TTS_MODEL=
#
# Stabilité de la voix Gemini : température basse et graine fixe donnent la
# même intonation d'une génération à l'autre ; chaque lecture est réécoutée et
# régénérée (jusqu'à GEMINI_TTS_ATTEMPTS fois) si des mots manquent.
# GEMINI_TTS_TEMPERATURE=0.3
# GEMINI_TTS_SEED=20260928
# GEMINI_TTS_ATTEMPTS=3
#
# Modèle Whisper utilisé pour caler les sous-titres mot à mot sur la voix
# (tiny, base, small). Choisi à la construction de l'image : changer la valeur
# demande de reconstruire ffmpeg-api.
# WHISPER_MODEL=base
#
# Moteur de rendu des Reels vidéo :
# - remotion (défaut) : sous-titres animés, logo et effet de fin identiques à
# l'aperçu affiché dans l'application ; ~30 à 60 s pour un Reel de 30 s ;
# - ffmpeg : plus rapide (~10 s), sous-titres karaoké classiques.
# REEL_RENDERER=remotion
#
# Onglets Chromium utilisés en parallèle par Remotion (défaut : moitié des cœurs).
# Réduisez-le si le serveur manque de mémoire.
# RENDER_CONCURRENCY=
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
app:
name: Application (TypeScript)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# Les dépendances natives (canvas, sharp) et le navigateur Remotion ne
# servent pas aux vérifications : on saute leurs scripts d'installation.
- run: npm ci --ignore-scripts
- run: npm run check
- run: npm test
- run: npm run build
ffmpeg-service:
name: Service vidéo (Python)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ffmpeg-service
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: ffmpeg-service/requirements*.txt
- run: pip install -r requirements-dev.txt
- run: ruff check .
- run: ruff format --check .
- run: pytest -q
+56
View File
@@ -136,6 +136,62 @@ docker-compose down
docker-compose down -v docker-compose down -v
``` ```
## 🔗 URL publique et connexions OAuth (Facebook / TikTok)
Les boutons « Connecter une page » construisent une URI de redirection à partir
de l'URL publique de l'application. Renseignez donc `APP_URL` avec le domaine
réel :
```bash
APP_URL=https://socialflow.exemple.fr
```
Puis déclarez l'URI correspondante chez le fournisseur :
- Facebook : *Connexion Facebook → Paramètres → URI de redirection OAuth valides*
→ `https://socialflow.exemple.fr/api/facebook/callback`
- TikTok : *Login Kit → Redirect URI* → `https://socialflow.exemple.fr/api/tiktok/callback`
Si `APP_URL` est absent, l'URL est déduite des en-têtes `X-Forwarded-Proto` et
`X-Forwarded-Host` envoyés par Nginx — vérifiez que le proxy les transmet
(voir la configuration Nginx ci-dessus). Un `APP_URL` réglé sur
`http://localhost:5555` en production produit une URI que Facebook refuse.
## 🧩 Déploiement via Portainer (stack Git)
La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
`build:`, leurs images n'existent sur aucun registre.
- **« Pull and redeploy » échoue** avec `pull access denied for <stack>-app,
repository does not exist` : Docker cherche sur Docker Hub une image qui est
construite localement. Le `pull_policy: build` du `docker-compose.yml` évite
cette tentative ; si votre version de Portainer déclenche quand même le pull,
décochez **« Re-pull image »** dans la mise à jour de la stack.
- Pour reconstruire après une mise à jour du dépôt : *Stack → Editor →
Update the stack*, en laissant Docker rebâtir les images (ou
`docker compose up -d --build` en ligne de commande).
- Le nom des images est figé (`socialflow-app:latest`,
`socialflow-ffmpeg-api:latest`) : il ne dépend donc plus du nom donné à la
stack dans Portainer.
## 🎙️ Service ffmpeg-api (voix et rendu des Reels)
- **Premier build plus long** : l'image embarque FFmpeg 7.0.2 (version
épinglée), les polices des sous-titres et le modèle Whisper qui cale les
sous-titres mot à mot sur la voix (~150 Mo avec `WHISPER_MODEL=base`).
- **Voix Gemini** : renseignez `GEMINI_API_KEY` (ou la clé dans l'application).
Sans clé, la voix Edge gratuite est utilisée et un avertissement apparaît
dans les logs.
- **Moteur de rendu** : par défaut, les Reels vidéo sont montés par Remotion
(sous-titres animés, identiques à l'aperçu de l'application). En cas de
serveur trop lent, `REEL_RENDERER=ffmpeg` revient au rendu FFmpeg, plus
rapide. `RENDER_CONCURRENCY` limite le nombre d'onglets Chromium.
- **Vérifier la version déployée** : au démarrage, les logs de `socialflow-app`
affichent `[ReelQueue] Worker démarré`, et `socialflow-ffmpeg` répond
`{"status":"ok","version":2}` sur `/health`.
- **Tests du service** : `pip install -r requirements-dev.txt`, puis `pytest`
et `ruff check .` dans `ffmpeg-service/`.
## 🔒 Sécurité en production ## 🔒 Sécurité en production
1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env` 1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env`
+1 -1
View File
@@ -70,7 +70,7 @@ RUN rm -rf client/node_modules \
/tmp/remotion-* /tmp/remotion-*
# Pré-bundler la composition Remotion # Pré-bundler la composition Remotion
RUN node scripts/prebundle-remotion.js || true RUN node scripts/prebundle-remotion.mjs || true
# Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian) # Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian)
RUN npx remotion browser ensure && echo "✓ Remotion browser prêt" RUN npx remotion browser ensure && echo "✓ Remotion browser prêt"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

BIN
View File
Binary file not shown.
+3
View File
@@ -38,6 +38,9 @@ export default function AiChat() {
// Fetch available models from OpenRouter // Fetch available models from OpenRouter
const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({ const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
const availableModels = modelsData?.data || []; const availableModels = modelsData?.data || [];
+2 -2
View File
@@ -1,7 +1,7 @@
import { format, isSameDay, parseISO } from "date-fns"; import { format, isSameDay, parseISO } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react"; import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useState } from "react"; import { useState } from "react";
@@ -159,7 +159,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
+25 -4
View File
@@ -6,13 +6,31 @@ import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { ScheduledPost } from "@shared/schema"; import type { ScheduledPost } from "@shared/schema";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import EditScheduledPostDialog from "./edit-scheduled-post-dialog"; import EditScheduledPostDialog from "./edit-scheduled-post-dialog";
import CalendarListView from "./calendar-list-view"; import CalendarListView from "./calendar-list-view";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import type { Media } from "@shared/schema"; import type { Media } from "@shared/schema";
/**
* Bornes de la grille affichée : 42 cases à partir du lundi qui précède le 1er
* du mois. Demander cette fenêtre plutôt que tout l'historique évite de charger
* des années de publications pour en afficher six semaines.
*/
function visibleRange(currentDate: Date): { startDate: string; endDate: string } {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const startingDayOfWeek = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
const start = new Date(year, month, 1 - startingDayOfWeek);
const end = new Date(year, month, 1 - startingDayOfWeek + 42);
end.setMilliseconds(-1); // fin de la 42e journée
return { startDate: start.toISOString(), endDate: end.toISOString() };
}
export default function CalendarView() { export default function CalendarView() {
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -22,8 +40,11 @@ export default function CalendarView() {
const { toast } = useToast(); const { toast } = useToast();
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
// La fenêtre fait partie de la clé : changer de mois redéclenche la requête.
const range = visibleRange(currentDate);
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({ const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ["/api/scheduled-posts"], queryKey: ["/api/scheduled-posts", range],
refetchInterval: 30000, // Auto-refresh every 30 seconds refetchInterval: 30000, // Auto-refresh every 30 seconds
}); });
@@ -261,7 +282,7 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
@@ -340,7 +361,7 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
@@ -169,6 +169,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail <MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl} src={media.instagramFeedUrl || media.originalUrl}
alt="Selected" alt="Selected"
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
className="w-full h-24 object-cover rounded-lg border-2 border-primary" className="w-full h-24 object-cover rounded-lg border-2 border-primary"
/> />
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail <MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl} src={media.instagramFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${ className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
isSelected ? 'border-primary' : 'border-border hover:border-primary/50' isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
@@ -0,0 +1,203 @@
/**
* Suivi et renouvellement des jetons d'accès.
*
* Un jeton de page Facebook n'affiche pas toujours de date d'expiration : celui
* qui dérive d'une connexion OAuth est permanent, et c'est sa révocation — pas
* son échéance — qui casse la publication. L'UI montre donc l'état réel remonté
* par le contrôle serveur, pas seulement un décompte de jours.
*/
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, Calendar, CheckCircle2, Facebook, RefreshCw, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
type TokenStatus = 'valid' | 'expiring' | 'expired' | 'error';
const STATUS_STYLES: Record<TokenStatus, { label: string; color: string; bgColor: string }> = {
valid: { label: 'Jeton valide', color: 'text-green-600', bgColor: 'bg-green-100' },
expiring: { label: 'Jeton bientôt expiré', color: 'text-orange-600', bgColor: 'bg-orange-100' },
expired: { label: 'Jeton expiré', color: 'text-red-600', bgColor: 'bg-red-100' },
error: { label: 'Contrôle en échec', color: 'text-red-600', bgColor: 'bg-red-100' },
};
function statusOf(page: ClientSocialPage): TokenStatus {
return (page.tokenStatus as TokenStatus | null) ?? 'valid';
}
/**
* Lance l'autorisation Facebook. La connexion se fait par navigation complète
* du navigateur (et non en fetch) puisqu'elle passe par le site de Facebook.
*/
export function ConnectFacebookButton({ className }: { className?: string }) {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/facebook/config'],
});
const handleClick = () => {
if (config && !config.configured) {
toast({
title: "Facebook n'est pas configuré",
description:
"Renseignez l'App ID et l'App Secret de votre application Facebook dans les paramètres.",
variant: 'destructive',
});
return;
}
window.location.href = '/api/facebook/connect';
};
return (
<Button
variant="outline"
className={className}
onClick={handleClick}
data-testid="button-connect-facebook"
>
<Facebook className="w-4 h-4 mr-2" />
Connecter des pages Facebook
</Button>
);
}
/** Relance le contrôle serveur, qui renouvelle le jeton s'il le peut. */
export function RefreshTokenButton({ page }: { page: ClientSocialPage }) {
const { toast } = useToast();
const refreshMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest('POST', `/api/facebook/pages/${page.id}/refresh`);
return (await response.json()) as { status: TokenStatus; renewed: boolean; error?: string };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: result.renewed
? 'Jeton renouvelé'
: result.status === 'valid'
? 'Jeton valide'
: STATUS_STYLES[result.status].label,
description:
result.error ||
(result.renewed
? `Un nouveau jeton a été généré pour ${page.pageName}.`
: `Aucun renouvellement nécessaire pour ${page.pageName}.`),
variant: result.status === 'valid' ? undefined : 'destructive',
});
},
onError: (error: unknown) => {
toast({
title: 'Contrôle impossible',
description: getErrorMessage(error, 'Facebook est injoignable, réessayez plus tard.'),
variant: 'destructive',
});
},
});
return (
<Button
variant="ghost"
size="sm"
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
data-testid={`button-refresh-token-${page.id}`}
>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
{refreshMutation.isPending ? 'Contrôle…' : 'Vérifier le jeton'}
</Button>
);
}
/**
* Bloc d'état du jeton : ce que le dernier contrôle a constaté, l'échéance
* quand il y en a une, et l'erreur à corriger le cas échéant.
*/
export function TokenHealthPanel({ page }: { page: ClientSocialPage }) {
const status = statusOf(page);
const style = STATUS_STYLES[status];
const isTiktok = page.platform === 'tiktok';
// Pour TikTok, c'est l'autorisation (refresh token) qui porte l'échéance.
const expiresAt = isTiktok ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
return (
<div className={`text-xs px-3 py-2 rounded-lg space-y-1 ${style.bgColor}`}>
<div className="flex items-center gap-2">
{status === 'valid' ? (
<CheckCircle2 className="w-4 h-4 text-green-600 shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0" />
)}
<span className={`font-semibold ${style.color}`}>{style.label}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-3.5 h-3.5 shrink-0" />
{expiresAt ? (
<span>
{isTiktok ? 'Connexion valable jusqu\'au' : 'Expire le'}{' '}
{format(new Date(expiresAt), 'd MMMM yyyy', { locale: fr })}
</span>
) : page.autoRenew ? (
<span>Sans expiration</span>
) : (
<span>Échéance inconnue</span>
)}
</div>
{page.autoRenew && (
<div className="flex items-center gap-2 text-muted-foreground">
<ShieldCheck className="w-3.5 h-3.5 shrink-0" />
<span>Renouvellement automatique actif</span>
</div>
)}
{page.tokenError && (
<p className={`${style.color} leading-relaxed`} data-testid={`text-token-error-${page.id}`}>
{page.tokenError}
</p>
)}
{page.lastTokenCheck && (
<p className="text-muted-foreground">
Dernier contrôle : {format(new Date(page.lastTokenCheck), 'd MMM yyyy à HH:mm', { locale: fr })}
</p>
)}
</div>
);
}
/** Bandeau global : les pages qui réclament une reconnexion manuelle. */
export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) {
const failing = pages.filter((page) => {
const status = statusOf(page);
return status === 'expired' || status === 'error' || status === 'expiring';
});
if (failing.length === 0) return null;
return (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div className="text-sm space-y-1">
<p className="font-semibold text-red-600 dark:text-red-400">
{failing.length === 1
? 'Une page nécessite votre attention'
: `${failing.length} pages nécessitent votre attention`}
</p>
{failing.map((page) => (
<p key={page.id} className="text-muted-foreground">
<strong>{page.pageName}</strong> — {STATUS_STYLES[statusOf(page)].label.toLowerCase()}
{page.tokenError ? ` : ${page.tokenError}` : '.'}
</p>
))}
</div>
</div>
);
}
+5 -3
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Users } from "lucide-react"; import { Users } from "lucide-react";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
export default function ManagedPages() { export default function ManagedPages() {
const { data: pages, isLoading } = useQuery({ const { data: pages, isLoading } = useQuery({
@@ -48,10 +48,12 @@ export default function ManagedPages() {
<div className="flex items-start justify-between mb-5"> <div className="flex items-start justify-between mb-5">
<div className={` <div className={`
w-14 h-14 rounded-xl flex items-center justify-center shadow-md w-14 h-14 rounded-xl flex items-center justify-center shadow-md
${page.platform === "facebook" ? "bg-[#1877F2]/10" : "bg-[#E4405F]/10"} ${page.platform === "facebook" ? "bg-[#1877F2]/10" : page.platform === "tiktok" ? "bg-foreground/10" : "bg-[#E4405F]/10"}
`}> `}>
{page.platform === "facebook" ? ( {page.platform === "facebook" ? (
<SiFacebook className="text-[#1877F2] text-2xl" /> <SiFacebook className="text-[#1877F2] text-2xl" />
) : page.platform === "tiktok" ? (
<SiTiktok className="text-foreground text-2xl" />
) : ( ) : (
<SiInstagram className="text-[#E4405F] text-2xl" /> <SiInstagram className="text-[#E4405F] text-2xl" />
)} )}
@@ -65,7 +67,7 @@ export default function ManagedPages() {
</div> </div>
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4> <h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
<p className="text-sm text-muted-foreground mb-4 font-medium"> <p className="text-sm text-muted-foreground mb-4 font-medium">
{page.platform === "facebook" ? "Facebook Page" : "Instagram Business"} {page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}
</p> </p>
<div className="flex items-center justify-between pt-4 border-t border-border/50"> <div className="flex items-center justify-between pt-4 border-t border-border/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+4 -1
View File
@@ -61,7 +61,10 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
}; };
const handleError = () => { const handleError = () => {
console.warn('Video thumbnail generation failed for:', src); // Cas courant et attendu : la vidéo a été supprimée du disque après
// publication. Les médias créés depuis embarquent une vignette persistée,
// qui évite d'en arriver là.
console.warn('Aperçu vidéo indisponible (fichier absent ou illisible):', src);
setError(true); setError(true);
video.remove(); video.remove();
}; };
+43 -10
View File
@@ -2,25 +2,57 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram } from "react-icons/si";
/** Nombre de vignettes chargées par page. */
const MEDIA_PAGE_SIZE = 15;
export default function MediaUpload() { export default function MediaUpload() {
const { toast } = useToast(); const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<any>(null); const [selectedFile, setSelectedFile] = useState<any>(null);
const [visibleCount, setVisibleCount] = useState(5);
const [zoomImage, setZoomImage] = useState<string | null>(null); const [zoomImage, setZoomImage] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const { data: mediaList } = useQuery({ // Pagination côté serveur : la médiathèque entière transitait pour n'afficher
queryKey: ["/api/media"], // que les premières vignettes. La clé reste préfixée par « /api/media » afin
// que les invalidations existantes continuent de la rafraîchir.
const {
data: mediaPages,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["/api/media", "pages"],
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/media?limit=${MEDIA_PAGE_SIZE}&offset=${pageParam}`, {
credentials: "include",
});
if (!response.ok) {
// Même traitement que les autres requêtes : une session expirée renvoie
// vers l'écran de connexion plutôt que sur une médiathèque vide.
if (response.status === 401) handleUnauthorized("/api/media");
throw new Error("Impossible de charger la médiathèque");
}
const items = (await response.json()) as any[];
const total = Number(response.headers.get("X-Total-Count"));
return { items, total: Number.isFinite(total) ? total : items.length };
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((count, page) => count + page.items.length, 0);
return loaded < lastPage.total ? loaded : undefined;
},
}); });
const mediaList = mediaPages?.pages.flatMap(page => page.items) ?? [];
const totalMedia = mediaPages?.pages[0]?.total ?? 0;
const [uploadingCount, setUploadingCount] = useState(0); const [uploadingCount, setUploadingCount] = useState(0);
const uploadMutation = useMutation({ const uploadMutation = useMutation({
@@ -163,8 +195,8 @@ export default function MediaUpload() {
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && mediaList && visibleCount < (mediaList as any[]).length) { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
setVisibleCount(prev => Math.min(prev + 5, (mediaList as any[]).length)); fetchNextPage();
} }
}, },
{ threshold: 0.1 } { threshold: 0.1 }
@@ -179,7 +211,7 @@ export default function MediaUpload() {
observer.unobserve(loadMoreRef.current); observer.unobserve(loadMoreRef.current);
} }
}; };
}, [mediaList, visibleCount]); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return ( return (
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg"> <div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
@@ -268,13 +300,13 @@ export default function MediaUpload() {
<h4 className="text-sm font-semibold text-foreground flex items-center justify-between"> <h4 className="text-sm font-semibold text-foreground flex items-center justify-between">
<span>Fichiers téléchargés</span> <span>Fichiers téléchargés</span>
<span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full"> <span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full">
{(mediaList as any[])?.length || 0} média(s) {totalMedia} média(s)
</span> </span>
</h4> </h4>
<div className="max-h-[500px] overflow-y-auto"> <div className="max-h-[500px] overflow-y-auto">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{(mediaList as any[])?.slice(0, visibleCount).map((media: any) => ( {mediaList.map((media: any) => (
<div <div
key={media.id} key={media.id}
onClick={() => setSelectedFile(media)} onClick={() => setSelectedFile(media)}
@@ -284,6 +316,7 @@ export default function MediaUpload() {
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
/> />
<div className="absolute top-2 right-2 flex gap-2"> <div className="absolute top-2 right-2 flex gap-2">
@@ -315,7 +348,7 @@ export default function MediaUpload() {
</div> </div>
{/* Élément sentinelle pour le scroll infini */} {/* Élément sentinelle pour le scroll infini */}
{(mediaList && visibleCount < (mediaList as any[]).length) ? ( {hasNextPage ? (
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3"> <div ref={loadMoreRef} className="flex justify-center py-4 mt-3">
<Loader2 className="w-5 h-5 text-primary animate-spin" /> <Loader2 className="w-5 h-5 text-primary animate-spin" />
</div> </div>
+29 -17
View File
@@ -4,16 +4,20 @@ import { Progress } from "@/components/ui/progress";
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react"; import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
import type { Post } from "@shared/schema"; import type { Post } from "@shared/schema";
/** Labels for each progress stage */ type OngoingReel = Post & { generationStep?: string | null };
function getStageLabel(progress: number, status?: string): string {
if (status === 'pending') return "En attente de traitement..."; /** Libellé de l'étape en cours, telle que rapportée par la file de rendu. */
if (progress <= 5) return "Démarrage du traitement…"; const STEP_LABELS: Record<string, string> = {
if (progress <= 15) return "Traitement vidéo (FFmpeg)…"; prepare: "Préparation…",
if (progress <= 65) return "Encodage en cours…"; voice: "Voix et préparation de la vidéo…",
if (progress <= 85) return "Upload vers le cloud…"; render: "Montage : sous-titres, logo, effets…",
if (progress <= 90) return "Liaison du média…"; store: "Enregistrement dans la médiathèque…",
if (progress <= 95) return "Publication en cours…"; publish: "Publication…",
return "Finalisation…"; };
function getStageLabel(post: OngoingReel): string {
if (post.generationStatus === "pending") return "En attente : un autre Reel est en cours…";
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
} }
interface OngoingReelsProps { interface OngoingReelsProps {
@@ -22,9 +26,13 @@ interface OngoingReelsProps {
} }
export default function OngoingReels({ compact = false }: OngoingReelsProps) { export default function OngoingReels({ compact = false }: OngoingReelsProps) {
const { data: ongoingPosts = [] } = useQuery<Post[]>({ const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
queryKey: ["/api/reels/ongoing"], queryKey: ["/api/reels/ongoing"],
refetchInterval: 3000, // Poll every 3 seconds // Cette route ne renvoie que les générations en cours : tant qu'elle est
// vide il n'y a pas de barre de progression à animer. On garde une veille
// lente, qui suffit à détecter une génération lancée depuis un autre écran.
refetchInterval: (query) =>
(query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000,
}); });
// Nothing to show — render nothing (not even a card) // Nothing to show — render nothing (not even a card)
@@ -40,8 +48,12 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
> >
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}> <CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}> <CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
<Loader2 className="w-5 h-5 animate-spin text-primary" /> {ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
Génération en cours <Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
<XCircle className="w-5 h-5 text-destructive" />
)}
Reels en cours
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}> <CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
@@ -70,8 +82,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
{isFailed ? ( {isFailed ? (
<div className="flex items-center gap-1.5 mt-2 text-destructive"> <div className="flex items-center gap-1.5 mt-2 text-destructive">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span className="text-xs"> <span className="text-xs break-words">
{post.generationError || "Erreur lors du traitement"} Échec : {post.generationError || "erreur lors du traitement"}
</span> </span>
</div> </div>
) : ( ) : (
@@ -84,7 +96,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
</div> </div>
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{getStageLabel(progress)} {getStageLabel(post)}
</span> </span>
<span className="text-xs font-medium text-primary"> <span className="text-xs font-medium text-primary">
{progress}% {progress}%
+22 -2
View File
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react"; import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema"; import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
@@ -28,6 +29,16 @@ import { Trash2 } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
/** Profondeur d'historique du bloc « Publications récentes ». */
const RECENT_WINDOW_DAYS = 90;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfDay(date: Date): Date {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
return copy;
}
export default function RecentPublications() { export default function RecentPublications() {
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] }); const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] });
@@ -36,8 +47,13 @@ export default function RecentPublications() {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Ce bloc n'affiche que les dix dernières publications : inutile de faire
// remonter tout l'historique pour cela.
const since = new Date();
since.setDate(since.getDate() - RECENT_WINDOW_DAYS);
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({ const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts', { startDate: startOfDay(since).toISOString() }],
}); });
// Filter and sort attempted posts (scheduled in the past) // Filter and sort attempted posts (scheduled in the past)
@@ -162,10 +178,14 @@ export default function RecentPublications() {
{/* Platform icon */} {/* Platform icon */}
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook' <div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
? 'bg-blue-500' ? 'bg-blue-500'
: 'bg-gradient-to-br from-purple-500 to-pink-500' : scheduledPost.page?.platform === 'tiktok'
? 'bg-black'
: 'bg-gradient-to-br from-purple-500 to-pink-500'
}`}> }`}>
{scheduledPost.page?.platform === 'facebook' ? ( {scheduledPost.page?.platform === 'facebook' ? (
<Facebook className="w-5 h-5 text-white" /> <Facebook className="w-5 h-5 text-white" />
) : scheduledPost.page?.platform === 'tiktok' ? (
<SiTiktok className="w-5 h-5 text-white" />
) : ( ) : (
<Instagram className="w-5 h-5 text-white" /> <Instagram className="w-5 h-5 text-white" />
)} )}
@@ -0,0 +1,63 @@
import { CAPTION_STYLE_OPTIONS, type CaptionStyle } from "@shared/captions";
import { cn } from "@/lib/utils";
/** Aperçu miniature de chaque style de sous-titres. */
const SAMPLE: Record<CaptionStyle, React.ReactNode> = {
bold: (
<span className="font-black uppercase tracking-tight" style={{ WebkitTextStroke: "1px #000", paintOrder: "stroke fill" }}>
<span className="text-white">Nos </span>
<span className="text-yellow-300">promos</span>
</span>
),
neon: (
<span className="font-extrabold">
<span className="rounded px-1 bg-black/50 text-white">Nos</span>{" "}
<span className="rounded px-1 bg-violet-600 text-white">promos</span>
</span>
),
minimal: (
<span className="font-bold">
<span className="text-white">Nos </span>
<span className="text-white/45">promos</span>
</span>
),
};
export function CaptionStylePicker({
value,
onChange,
compact = false,
}: {
value: CaptionStyle;
onChange: (style: CaptionStyle) => void;
compact?: boolean;
}) {
return (
<div className="grid grid-cols-3 gap-2" role="radiogroup" aria-label="Style des sous-titres">
{CAPTION_STYLE_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={value === option.id}
title={option.description}
onClick={() => onChange(option.id)}
className={cn(
"rounded-lg border p-2 text-left transition-colors",
value === option.id ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-primary/50",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md bg-gradient-to-br from-slate-700 to-slate-900",
compact ? "h-9 text-[11px]" : "h-12 text-sm",
)}
>
{SAMPLE[option.id]}
</div>
<p className={cn("mt-1 font-medium", compact ? "text-[11px]" : "text-xs")}>{option.label}</p>
</button>
))}
</div>
);
}
@@ -0,0 +1,198 @@
import { useEffect, useMemo, useState } from "react";
import { Player } from "@remotion/player";
import { Loader2, Info } from "lucide-react";
import {
REEL_FPS,
REEL_HEIGHT,
REEL_WIDTH,
VOICE_DELAY,
cleanCaptionText,
computeImagesTiming,
computeReelTiming,
offsetWords,
spreadWords,
type CaptionStyle,
type TimedWord,
} from "@shared/captions";
import { srtEnd, srtWords, type SrtCue } from "@shared/srt";
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition";
import type { VoicePreviewResult } from "./voice-picker";
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
const WORDS_PER_SECOND = 2.6;
interface CommonProps {
text: string;
showCaptions: boolean;
captionStyle: CaptionStyle;
ttsEnabled: boolean;
/** Voix générée pour ce texte et ces réglages, sinon null (minutage estimé). */
voice: VoicePreviewResult | null;
musicUrl?: string;
musicVolume: number;
logoUrl?: string | null;
/** Petit logo pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */
showWatermark: boolean;
storeName?: string;
endingEffect: boolean;
/** Sous-titres SRT : remplacent `text`, minutés par le fichier. */
srtCues?: SrtCue[] | null;
}
type ReelPreviewProps =
| (CommonProps & { kind: "video"; videoUrl: string })
| (CommonProps & { kind: "images"; images: string[] });
/** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */
function useVideoDuration(url: string | undefined): number | null {
const [duration, setDuration] = useState<number | null>(null);
useEffect(() => {
setDuration(null);
if (!url) return;
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => setDuration(Number.isFinite(video.duration) ? video.duration : 15);
video.onerror = () => setDuration(15);
video.src = url;
return () => {
video.onloadedmetadata = null;
video.onerror = null;
video.src = "";
};
}, [url]);
return duration;
}
function estimatedVoiceDuration(text: string): number {
const words = cleanCaptionText(text).split(" ").filter(Boolean).length;
return words ? words / WORDS_PER_SECOND + 0.4 : 0;
}
/**
* Aperçu du Reel dans le navigateur, avec les mêmes compositions Remotion que
* le rendu final : sous-titres, logo, effet de fin et durée identiques.
* Sans voix générée, le minutage des mots est estimé.
*/
export function ReelPreview(props: ReelPreviewProps) {
const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined);
const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } =
props;
const logoUrl = props.logoUrl ?? undefined;
const storeName = endingEffect ? props.storeName || undefined : undefined;
const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null;
const cleanText = cleanCaptionText(text);
const voiceDuration = srtCues
? ttsEnabled ? srtEnd(srtCues) : 0
: ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0;
const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice;
const composition = useMemo(() => {
if (props.kind === "images") {
if (props.images.length === 0) return null;
const { total, endingSeconds } = computeImagesTiming({
imageCount: props.images.length,
voiceDuration,
hasEnding: Boolean(logoUrl || storeName),
});
const contentEnd = total - endingSeconds;
let words: TimedWord[] = [];
if (showCaptions && cleanText) {
words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8));
}
const inputProps: ImageCompositionProps = {
images: props.images,
totalDuration: total,
words,
captionStyle,
audioUrl: voice?.audioUrl,
musicUrl,
musicVolume,
logoUrl,
showWatermark,
storeName,
endingSeconds,
};
return { component: ImageComposition, inputProps, total };
}
if (videoDuration == null) return null;
const timing = computeReelTiming({
videoDuration,
voiceDuration: voiceDuration || undefined,
hasOutro: Boolean(logoUrl),
endingEffect,
voiceDelay: srtCues ? 0 : VOICE_DELAY,
});
let words: TimedWord[] = [];
if (srtCues) {
// La voix de l'aperçu n'est pas générée sous-titre par sous-titre : minutage du fichier
if (showCaptions) words = srtWords(srtCues);
} else if (showCaptions && cleanText) {
if (voice) words = offsetWords(voice.words, VOICE_DELAY);
else if (ttsEnabled) words = spreadWords(text, VOICE_DELAY, VOICE_DELAY + voiceDuration);
else words = spreadWords(text, 0.5, (timing.logoStart ?? timing.total) - 0.5);
}
const inputProps: ReelVideoProps = {
videoUrl: props.videoUrl,
videoDuration,
totalDuration: timing.total,
words,
captionStyle,
logoUrl,
showWatermark,
storeName,
logoStart: timing.logoStart,
fadeStart: timing.fadeStart,
fadeDuration: timing.fadeDuration,
voiceUrl: srtCues ? undefined : voice?.audioUrl,
voiceDelay: VOICE_DELAY,
musicUrl,
musicVolume,
};
return { component: ReelVideo, inputProps, total: timing.total };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
props.kind,
props.kind === "video" ? props.videoUrl : props.images.join("|"),
videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect,
]);
if (!composition) {
return (
<div className="aspect-[9/16] w-full rounded-lg bg-black flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-white/70" />
</div>
);
}
return (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg bg-black">
<Player
// Nouvelle instance quand la durée change : le lecteur repart du début
key={`${props.kind}-${composition.total}`}
component={composition.component as React.ComponentType<Record<string, unknown>>}
inputProps={composition.inputProps as unknown as Record<string, unknown>}
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
fps={REEL_FPS}
compositionWidth={REEL_WIDTH}
compositionHeight={REEL_HEIGHT}
style={{ width: "100%", aspectRatio: "9 / 16" }}
controls
loop
acknowledgeRemotionLicense
/>
</div>
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0" />
{srtCues
? `Minutage du fichier SRT (la voix sera posée sur chaque sous-titre au rendu) · ${composition.total.toFixed(1)} s`
: estimated
? "Minutage estimé : cliquez sur « Tester la voix » pour caler les sous-titres sur la vraie voix."
: `Aperçu fidèle au rendu final · ${composition.total.toFixed(1)} s`}
</p>
</div>
);
}
@@ -0,0 +1,89 @@
import { useRef } from "react";
import { FileText, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { formatSrtTime, parseSrt, srtEnd, type SrtCue } from "@shared/srt";
const MAX_SRT_BYTES = 512 * 1024;
export interface SrtFile {
name: string;
cues: SrtCue[];
}
interface SrtUploadProps {
value: SrtFile | null;
onChange: (value: SrtFile | null) => void;
}
/**
* Import d'un fichier de sous-titres SRT. Tant qu'un fichier est chargé, il
* remplace le texte libre : la voix lit chaque sous-titre à son instant.
*/
export function SrtUpload({ value, onChange }: SrtUploadProps) {
const { toast } = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = async (file: File | undefined) => {
if (!file) return;
try {
if (file.size > MAX_SRT_BYTES) throw new Error("Fichier SRT trop volumineux (512 Ko au plus)");
const cues = parseSrt(await file.text());
onChange({ name: file.name, cues });
toast({ title: "Sous-titres importés", description: `${cues.length} sous-titre(s) · ${formatSrtTime(srtEnd(cues))}` });
} catch (error) {
toast({
title: "Fichier SRT invalide",
description: error instanceof Error ? error.message : "Lecture impossible",
variant: "destructive",
});
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
accept=".srt,application/x-subrip,text/plain"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
{value ? (
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 text-sm font-medium">
<FileText className="w-4 h-4 shrink-0 text-primary" />
<span className="truncate">{value.name}</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => onChange(null)}>
<X className="w-4 h-4 mr-1" />
Retirer
</Button>
</div>
<p className="text-xs text-muted-foreground">
{value.cues.length} sous-titre(s) · jusqu'à {formatSrtTime(srtEnd(value.cues))}. La voix lira chaque
sous-titre à son instant et accélérera si besoin pour tenir dans sa durée.
</p>
<ul className="max-h-40 overflow-y-auto space-y-1 text-xs">
{value.cues.map((cue, i) => (
<li key={i} className="flex gap-2">
<span className="shrink-0 font-mono text-muted-foreground">
{formatSrtTime(cue.start)}–{formatSrtTime(cue.end)}
</span>
<span>{cue.text}</span>
</li>
))}
</ul>
</div>
) : (
<Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()}>
<Upload className="w-4 h-4 mr-2" />
Importer un fichier SRT
</Button>
)}
</div>
);
}
@@ -0,0 +1,217 @@
import { useEffect, useRef, useState } from "react";
import { Loader2, Play, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage } from "@/lib/queryClient";
import {
DEFAULT_VOICE,
TTS_STYLE_OPTIONS,
voicesFor,
type TtsEngine,
type TtsStyle,
} from "@shared/voices";
import type { TimedWord } from "@shared/captions";
export interface VoiceSettings {
engine: TtsEngine;
voice: string;
style: TtsStyle;
}
/** Voix générée par « Tester la voix », réutilisée par l'aperçu vidéo. */
export interface VoicePreviewResult {
audioUrl: string;
duration: number;
words: TimedWord[];
/** Texte et réglages ayant servi : l'aperçu n'est valable que s'ils n'ont pas changé. */
text: string;
settings: VoiceSettings;
}
export function isVoicePreviewCurrent(
preview: VoicePreviewResult | null,
text: string,
settings: VoiceSettings,
): preview is VoicePreviewResult {
return (
!!preview &&
preview.text === text.trim() &&
preview.settings.engine === settings.engine &&
preview.settings.voice === settings.voice &&
preview.settings.style === settings.style
);
}
interface VoicePickerProps {
value: VoiceSettings;
onChange: (value: VoiceSettings) => void;
/** Texte lu par le bouton « Tester la voix ». */
sampleText?: string;
compact?: boolean;
/** Appelé avec la voix générée (audio et minutage des mots). */
onPreview?: (result: VoicePreviewResult) => void;
}
const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !";
/**
* Choix du moteur, de la voix et du ton, avec écoute de l'aperçu.
* Partagé par les pages Reel (vidéo et images, bureau et mobile).
*/
export function VoicePicker({ value, onChange, sampleText, compact = false, onPreview }: VoicePickerProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => () => audioRef.current?.pause(), []);
const voices = voicesFor(value.engine);
const groups = [
{ label: "Voix féminines", items: voices.filter((v) => v.gender === "female") },
{ label: "Voix masculines", items: voices.filter((v) => v.gender === "male") },
];
const setEngine = (engine: TtsEngine) => {
if (engine !== value.engine) onChange({ ...value, engine, voice: DEFAULT_VOICE[engine] });
};
const stop = () => {
audioRef.current?.pause();
setPlaying(false);
};
const preview = async () => {
if (playing) return stop();
setLoading(true);
try {
const text = sampleText?.trim() || FALLBACK_SAMPLE;
const response = await apiRequest("POST", "/api/reels/tts-preview", {
text,
ttsEngine: value.engine,
ttsVoice: value.voice,
ttsStyle: value.style,
});
const data = await response.json();
for (const warning of data.warnings ?? []) {
toast({ title: "Voix de secours utilisée", description: warning });
}
const audioUrl = `data:audio/mpeg;base64,${data.audioBase64}`;
if (sampleText?.trim()) {
onPreview?.({ audioUrl, duration: data.duration, words: data.words ?? [], text, settings: value });
}
const audio = new Audio(audioUrl);
audioRef.current?.pause();
audioRef.current = audio;
audio.onended = () => setPlaying(false);
await audio.play();
setPlaying(true);
} catch (error) {
toast({
title: "Impossible de tester la voix",
description: getErrorMessage(error, "La voix n'a pas pu être générée."),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const labelClass = compact ? "text-xs font-medium" : "text-sm font-medium";
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Moteur</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "gemini" ? "default" : "outline"}
onClick={() => setEngine("gemini")}
>
Gemini (naturelle)
</Button>
<Button
type="button"
size="sm"
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "edge" ? "default" : "outline"}
onClick={() => setEngine("edge")}
>
Edge (gratuite)
</Button>
</div>
</div>
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Voix</Label>
<Select value={value.voice} onValueChange={(voice) => onChange({ ...value, voice })}>
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
<SelectValue placeholder="Choisir une voix" />
</SelectTrigger>
<SelectContent className="max-h-72">
{groups.map((group) => (
<SelectGroup key={group.label}>
<SelectLabel>{group.label}</SelectLabel>
{group.items.map((voice) => (
<SelectItem key={voice.id} value={voice.id}>
{voice.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Ton</Label>
<Select value={value.style} onValueChange={(style) => onChange({ ...value, style: style as TtsStyle })}>
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TTS_STYLE_OPTIONS.map((style) => (
<SelectItem key={style.id} value={style.id}>
{style.label} <span className="text-muted-foreground">— {style.description}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
variant="secondary"
className={compact ? "w-full h-8 text-xs" : "w-full"}
disabled={loading}
onClick={(e) => {
e.stopPropagation();
preview();
}}
>
{loading ? (
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
) : playing ? (
<Square className="w-3 h-3 mr-2" />
) : (
<Play className="w-3 h-3 mr-2" />
)}
{loading ? "Génération de la voix…" : playing ? "Arrêter" : "Tester la voix"}
</Button>
</div>
);
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react"; import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -52,6 +52,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
}; };
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationKey: [AUTH_MUTATION, "logout"],
mutationFn: async () => { mutationFn: async () => {
const res = await apiRequest("POST", "/api/auth/logout", {}); const res = await apiRequest("POST", "/api/auth/logout", {});
return await res.json(); return await res.json();
+84 -5
View File
@@ -1,4 +1,4 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query"; import { MutationCache, QueryClient, QueryFunction } from "@tanstack/react-query";
// Les routes d'authentification elles-mêmes ne doivent jamais déclencher // Les routes d'authentification elles-mêmes ne doivent jamais déclencher
// de redirection automatique (ex: mauvais mot de passe sur /login). // de redirection automatique (ex: mauvais mot de passe sur /login).
@@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) {
window.location.href = "/login"; window.location.href = "/login";
} }
/**
* Extrait un message lisible d'une erreur d'API.
* apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce
* décodage, l'interface ne peut afficher qu'un message générique alors que le
* serveur a renvoyé la cause exacte.
*/
export function getErrorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error) || !error.message) return fallback;
const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim();
if (!withoutStatus) return fallback;
try {
const parsed = JSON.parse(withoutStatus);
const message = parsed?.error || parsed?.message;
if (typeof message === 'string' && message.trim()) return message.trim();
} catch {
// Corps non-JSON : on affiche le texte tel quel
}
return withoutStatus.startsWith('<') ? fallback : withoutStatus;
}
async function throwIfResNotOk(res: Response, url: string) { async function throwIfResNotOk(res: Response, url: string) {
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
@@ -46,13 +69,40 @@ export async function apiRequest(
return res; return res;
} }
/**
* Construit l'URL d'une requête à partir de sa clé.
*
* Les segments texte forment le chemin (`['/api/posts', id]`). Un objet en
* dernière position porte les paramètres de requête : c'est ce qui permet de
* demander une fenêtre de dates plutôt que tout l'historique
* (`['/api/scheduled-posts', { startDate, endDate }]`).
*/
export function urlFromQueryKey(queryKey: readonly unknown[]): string {
const segments = [...queryKey];
const last = segments[segments.length - 1];
if (last !== null && typeof last === "object") {
segments.pop();
const params = new URLSearchParams();
for (const [key, value] of Object.entries(last as Record<string, unknown>)) {
if (value !== undefined && value !== null && value !== "") {
params.set(key, String(value));
}
}
const query = params.toString();
return query ? `${segments.join("/")}?${query}` : segments.join("/");
}
return segments.join("/");
}
type UnauthorizedBehavior = "returnNull" | "throw"; type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: { export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior; on401: UnauthorizedBehavior;
}) => QueryFunction<T> = }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { async ({ queryKey }) => {
const url = queryKey.join("/") as string; const url = urlFromQueryKey(queryKey);
const res = await fetch(url, { const res = await fetch(url, {
credentials: "include", credentials: "include",
}); });
@@ -65,13 +115,42 @@ export const getQueryFn: <T>(options: {
return await res.json(); return await res.json();
}; };
/**
* Marque les mutations d'authentification, exclues de l'invalidation globale
* ci-dessous. À passer en `mutationKey` : `[AUTH_MUTATION, 'logout']`.
*/
export const AUTH_MUTATION = "auth";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
// Les compteurs du tableau de bord et la liste des posts dérivent de tout le
// reste : publier, supprimer un média, générer un texte… Aucune mutation ne
// peut raisonnablement se souvenir de les invalider, et aucune ne le faisait :
// ils restaient figés jusqu'au rechargement de la page. On les invalide donc
// ici, une bonne fois, après chaque mutation réussie.
mutationCache: new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
// Sauf pour l'authentification : au moment où la déconnexion réussit, la
// session est déjà détruite. Relancer des requêtes ici ne produirait que
// des 401 et une redirection brutale, avant même que le cache soit vidé.
if (mutation.options.mutationKey?.[0] === AUTH_MUTATION) return;
queryClient.invalidateQueries({ queryKey: ["/api/stats"] });
queryClient.invalidateQueries({ queryKey: ["/api/posts"] });
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
queryFn: getQueryFn({ on401: "throw" }), queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false, // Le serveur agit de son côté — le scheduler publie toutes les minutes, le
refetchOnWindowFocus: false, // renouvellement des jetons tourne en tâche de fond — sans que le
staleTime: Infinity, // navigateur en soit averti. Avec `staleTime: Infinity`, un écran restait
// sur le cache de sa première visite pour toute la session : d'où la
// nécessité de recharger la page pour voir quoi que ce soit. Une
// péremption courte, plus un rafraîchissement au retour sur l'onglet,
// suffit à garder l'affichage juste sans marteler l'API.
staleTime: 30_000,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: false, retry: false,
}, },
mutations: { mutations: {
+14 -1
View File
@@ -17,6 +17,15 @@ type ScheduledPostWithRelations = ScheduledPost & {
page?: SocialPage; page?: SocialPage;
}; };
/** Publication dont l'heure est passée mais que le scheduler n'a pas encore traitée. */
function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boolean {
return (
!!scheduledPost.scheduledAt &&
new Date(scheduledPost.scheduledAt) <= new Date() &&
!scheduledPost.publishedAt
);
}
export default function History() { export default function History() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
@@ -25,7 +34,11 @@ export default function History() {
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({ const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts'],
refetchInterval: 3000, // Le scheduler publie en tâche de fond : on ne suit à la seconde que ce qui
// est réellement en attente. Le reste du temps, une veille lente suffit —
// sans quoi la page entière était rechargée 20 fois par minute pour rien.
refetchInterval: (query) =>
(query.state.data ?? []).some(isAwaitingPublication) ? 3000 : 30000,
}); });
// Filter attempted posts (scheduled in the past) // Filter attempted posts (scheduled in the past)
+13 -1
View File
@@ -19,13 +19,25 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
/** Post dont le statut peut changer d'un instant à l'autre. */
function isChanging(post: any): boolean {
if (post?.generationStatus === 'pending' || post?.generationStatus === 'processing') {
return true;
}
return post?.status === 'scheduled' && !!post?.scheduledFor && new Date(post.scheduledFor) <= new Date();
}
export default function MobileHistory() { export default function MobileHistory() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
// Fetch posts history with polling to see status updates // Fetch posts history with polling to see status updates
const { data: posts } = useQuery<any[]>({ const { data: posts } = useQuery<any[]>({
queryKey: ['/api/posts'], queryKey: ['/api/posts'],
refetchInterval: 3000, // Poll every 3 seconds // Un post planifié pour la semaine prochaine ne bougera pas d'ici une
// seconde : on ne suit de près que ce dont le statut peut basculer
// maintenant (génération en cours, ou heure de publication dépassée).
refetchInterval: (query) =>
(query.state.data ?? []).some(isChanging) ? 3000 : 30000,
}); });
const getStatusIcon = (status: string) => { const getStatusIcon = (status: string) => {
+26 -7
View File
@@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -81,6 +81,7 @@ function SortableMediaItem({
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
/> />
@@ -103,6 +104,16 @@ function SortableMediaItem({
); );
} }
/** Nombre de médias récents proposés à la sélection. */
const RECENT_MEDIA_COUNT = 12;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfToday(): Date {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
export default function NewPostMobile() { export default function NewPostMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation(); const [, navigate] = useLocation();
@@ -119,12 +130,17 @@ export default function NewPostMobile() {
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ // Ce composeur gère les publications feed/story : TikTok n'accepte que des
// vidéos et se pilote depuis la création de reel.
const { data: allPages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
const pages = allPages.filter(p => p.platform !== 'tiktok');
// Seuls les douze derniers médias sont proposés ici : autant ne demander
// que ceux-là plutôt que toute la médiathèque.
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
}); });
const mediaList = useMemo(() => { const mediaList = useMemo(() => {
@@ -138,11 +154,13 @@ export default function NewPostMobile() {
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA; return dateB - dateA;
}) })
.slice(0, 12); .slice(0, RECENT_MEDIA_COUNT);
}, [allMedia, recentUploads]); }, [allMedia, recentUploads]);
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
// passé n'a aucune influence sur une planification à venir.
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({ const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
}); });
const uploadMutation = useMutation({ const uploadMutation = useMutation({
@@ -227,10 +245,10 @@ export default function NewPostMobile() {
}); });
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
}, },
onError: () => { onError: (error: unknown) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de générer le texte", description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -532,6 +550,7 @@ export default function NewPostMobile() {
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
/> />
+144 -121
View File
@@ -16,8 +16,16 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
import { ReelPreview } from "@/components/reels/reel-preview";
import { SrtUpload, type SrtFile } from "@/components/reels/srt-upload";
import { srtText } from "@shared/srt";
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -50,6 +58,8 @@ export default function MobileNewReel() {
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null); const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null); const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
const [overlayText, setOverlayText] = useState(''); const [overlayText, setOverlayText] = useState('');
// Fichier SRT : remplace le texte libre, la voix suit son minutage
const [srtFile, setSrtFile] = useState<SrtFile | null>(null);
const [productInfo, setProductInfo] = useState(''); const [productInfo, setProductInfo] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]); const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]); const [selectedPages, setSelectedPages] = useState<string[]>([]);
@@ -58,28 +68,23 @@ export default function MobileNewReel() {
// État TTS // État TTS
const [ttsEnabled, setTtsEnabled] = useState(false); const [ttsEnabled, setTtsEnabled] = useState(false);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge'); const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural'); engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
// Gemini native TTS voices (Charon = homme, Kore = femme) style: DEFAULT_TTS_STYLE,
const geminiVoices = [ });
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' }, const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' }, const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
]; const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
// French Edge TTS voices const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
// État audio preview // État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null); const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [stabilize, setStabilize] = useState(true); // Activé par défaut pour les Reels // Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
// TTS Sync state // TTS Sync state
const [syncInfo, setSyncInfo] = useState<{ const [syncInfo, setSyncInfo] = useState<{
@@ -92,7 +97,7 @@ export default function MobileNewReel() {
// Auto-calculate TTS sync // Auto-calculate TTS sync
useEffect(() => { useEffect(() => {
if (!ttsEnabled || !overlayText.trim()) { if (!ttsEnabled || !overlayText.trim() || srtFile) {
setSyncInfo(null); setSyncInfo(null);
return; return;
} }
@@ -107,13 +112,31 @@ export default function MobileNewReel() {
.catch(() => setSyncInfo(null)); .catch(() => setSyncInfo(null));
}, 800); }, 800);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]); }, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]);
const { data: pages = [] } = useQuery<SocialPage[]>({ const { data: pages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
// Destinations acceptant une vidéo verticale
const facebookPages = pages.filter(p => p.platform === 'facebook');
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
<Checkbox
id={page.id}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) setSelectedPages([...selectedPages, page.id]);
else setSelectedPages(selectedPages.filter(id => id !== page.id));
}}
/>
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
</div>
);
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media'],
}); });
@@ -192,8 +215,10 @@ export default function MobileNewReel() {
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({ toast({
title: data.success ? "Reel créé !" : "Attention", title: "Création du Reel lancée",
description: data.success ? "Publication réussie" : "Certaines erreurs", description: data.queued
? "Un autre Reel est en cours : le vôtre démarrera juste après."
: "Suivez l'avancement sur l'accueil.",
}); });
navigate('/'); navigate('/');
}, },
@@ -242,8 +267,9 @@ export default function MobileNewReel() {
createReelMutation.mutate({ createReelMutation.mutate({
videoMediaId: selectedVideoId, videoMediaId: selectedVideoId,
musicTrackId: selectedTrack?.id, musicTrackId: selectedTrack?.id,
overlayText: overlayText, overlayText: srtFile ? undefined : overlayText,
description: overlayText, srtCues: srtFile?.cues,
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(), scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100, musicVolume: musicVolume[0] / 100,
@@ -252,7 +278,10 @@ export default function MobileNewReel() {
ttsEnabled, ttsEnabled,
ttsEngine, ttsEngine,
ttsVoice, ttsVoice,
ttsStyle,
captionStyle,
enableEndingEffect, enableEndingEffect,
showLogo,
}); });
}; };
@@ -407,6 +436,7 @@ export default function MobileNewReel() {
{/* TEXT STEP */} {/* TEXT STEP */}
{currentStep === 'text' && ( {currentStep === 'text' && (
<div className="space-y-6"> <div className="space-y-6">
{!srtFile && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base flex items-center"><Sparkles className="w-4 h-4 mr-2 text-primary" /> Assistant IA</CardTitle> <CardTitle className="text-base flex items-center"><Sparkles className="w-4 h-4 mr-2 text-primary" /> Assistant IA</CardTitle>
@@ -424,8 +454,9 @@ export default function MobileNewReel() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
)}
{generatedVariants.length > 0 && ( {!srtFile && generatedVariants.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
{generatedVariants.map((v, i) => ( {generatedVariants.map((v, i) => (
<div key={i} className="bg-card p-3 rounded-lg border text-sm" onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}> <div key={i} className="bg-card p-3 rounded-lg border text-sm" onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}>
@@ -437,13 +468,34 @@ export default function MobileNewReel() {
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Texte Overlay</label> <label className="text-sm font-medium">Texte Overlay</label>
<Textarea <SrtUpload value={srtFile} onChange={setSrtFile} />
value={overlayText} {srtFile ? (
onChange={(e) => setOverlayText(e.target.value)} <p className="text-xs text-muted-foreground">
placeholder="Texte sur la vidéo..." Texte libre désactivé : le fichier SRT est utilisé. Retirez-le pour écrire un texte.
className="text-lg" </p>
rows={3} ) : (
/> <Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Texte sur la vidéo..."
className="text-lg"
rows={3}
/>
)}
<div className="space-y-1.5 mt-3">
<Label className="text-xs font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
</div>
<div className="flex items-center space-x-2 mt-4">
<Switch
id="show-logo"
checked={showLogo}
onCheckedChange={setShowLogo}
/>
<Label htmlFor="show-logo" className="font-medium cursor-pointer">
Afficher le logo sur la vidéo
</Label>
</div>
<div className="flex items-center space-x-2 mt-4"> <div className="flex items-center space-x-2 mt-4">
<Switch <Switch
id="enable-ending-effect" id="enable-ending-effect"
@@ -473,85 +525,17 @@ export default function MobileNewReel() {
<span>TTS — voix activée</span> <span>TTS — voix activée</span>
</div> </div>
{/* TTS Engine Selector */}
<div className="flex items-center gap-2 mt-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button
size="sm"
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => {
setTtsEngine('edge');
setTtsVoice('fr-FR-VivienneMultilingualNeural');
}}
>
Edge
</Button>
<Button
size="sm"
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => {
setTtsEngine('gemini');
setTtsVoice('fr-FR-Standard-B');
}}
>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2 mt-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
) : (
geminiVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="mt-2"> <div className="mt-2">
<Button <VoicePicker
size="sm" value={voiceSettings}
variant="secondary" onChange={setVoiceSettings}
className="w-full h-8 text-xs" sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
onClick={async (e) => { onPreview={setVoicePreview}
e.stopPropagation(); compact
const textToTest = overlayText || "Ceci est un test de voix."; />
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
ttsEngine,
ttsVoice,
});
const data = await response.json();
if (data.success && data.audioBase64) {
const audio = new Audio(`data:audio/mp3;base64,${data.audioBase64}`);
audio.play();
}
} catch (err) {
toast({ title: "Erreur", description: "Impossible de lire la voix", variant: "destructive" });
}
}}
>
<Play className="w-3 h-3 mr-1" /> Tester la voix
</Button>
</div> </div>
{syncInfo && ( {syncInfo && !srtFile && (
<div className={`p-2 rounded-lg border mt-2 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}> <div className={`p-2 rounded-lg border mt-2 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="font-medium">Sync</span> <span className="font-medium">Sync</span>
@@ -581,24 +565,63 @@ export default function MobileNewReel() {
{/* PUBLISH STEP */} {/* PUBLISH STEP */}
{currentStep === 'publish' && ( {currentStep === 'publish' && (
<div className="space-y-6"> <div className="space-y-6">
{selectedVideo && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[260px] mx-auto">
<ReelPreview
kind="video"
videoUrl={selectedVideo.originalUrl}
text={srtFile ? srtText(srtFile.cues) : overlayText}
srtCues={srtFile?.cues}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={selectedTrack?.previewUrl}
musicVolume={musicVolume[0] / 100}
logoUrl={reelConfig?.logoUrl}
showWatermark={showLogo}
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
endingEffect={enableEndingEffect}
/>
</div>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Diffusion</CardTitle> <CardTitle className="text-base">Diffusion</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-4">
{pages.filter(p => p.platform === 'facebook').map((page) => ( {facebookPages.length === 0 && tiktokAccounts.length === 0 && (
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg"> <p className="text-sm text-muted-foreground">
<Checkbox Aucune page Facebook ni compte TikTok connecté.
id={page.id} </p>
checked={selectedPages.includes(page.id)} )}
onCheckedChange={(checked) => {
if (checked) setSelectedPages([...selectedPages, page.id]); {facebookPages.length > 0 && (
else setSelectedPages(selectedPages.filter(id => id !== page.id)); <div className="space-y-3">
}} <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
/> <SiFacebook className="w-4 h-4 text-[#1877F2]" />
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label> Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div> </div>
))} )}
{tiktokAccounts.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
+103 -90
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,68 +12,77 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage } from "@shared/schema"; import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns"; import {
import { fr } from "date-fns/locale"; ConnectFacebookButton,
RefreshTokenButton,
TokenAlertBanner,
TokenHealthPanel,
} from "@/components/facebook-token-status";
function getTokenExpirationStatus(expiresAt: string | null | undefined) { /**
if (!expiresAt) { * Lance l'autorisation TikTok. La connexion se fait par navigation complète du
return { * navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
status: 'unknown' as const, */
color: 'text-gray-500', function ConnectTiktokButton() {
bgColor: 'bg-gray-100', const { toast } = useToast();
daysLeft: null,
message: 'Date inconnue'
};
}
const now = new Date(); const { data: config } = useQuery<{ configured: boolean }>({
const expiration = new Date(expiresAt); queryKey: ['/api/tiktok/config'],
const diffTime = expiration.getTime() - now.getTime(); });
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (daysLeft < 0) { const handleClick = () => {
return { if (config && !config.configured) {
status: 'expired' as const, toast({
color: 'text-red-600', title: "TikTok n'est pas configuré",
bgColor: 'bg-red-100', description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
daysLeft, variant: "destructive",
message: 'Expiré' });
}; return;
} else if (daysLeft <= 7) { }
return { window.location.href = '/api/tiktok/connect';
status: 'urgent' as const, };
color: 'text-red-600',
bgColor: 'bg-red-100', return (
daysLeft, <Button
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}` variant="outline"
}; className="w-full min-h-[48px]"
} else if (daysLeft <= 15) { onClick={handleClick}
return { data-testid="button-connect-tiktok"
status: 'warning' as const, >
color: 'text-orange-600', <SiTiktok className="w-5 h-5 mr-2" />
bgColor: 'bg-orange-100', Connecter un compte TikTok
daysLeft, </Button>
message: `${daysLeft} jours restants` );
};
} else {
return {
status: 'good' as const,
color: 'text-green-600',
bgColor: 'bg-green-100',
daysLeft,
message: `${daysLeft} jours restants`
};
}
} }
export default function PagesManagementMobile() { export default function PagesManagementMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null); const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({ // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
// redirigent ici avec le résultat en paramètre)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
if (!network) return;
const result = params.get(network);
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
toast({
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
@@ -119,10 +129,14 @@ export default function PagesManagementMobile() {
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1> <h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Connectez et gérez vos pages Facebook et Instagram Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
</p> </p>
</div> </div>
<TokenAlertBanner pages={pages} />
<ConnectFacebookButton className="w-full min-h-[48px]" />
<ConnectTiktokButton />
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} /> <EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
@@ -181,6 +195,10 @@ export default function PagesManagementMobile() {
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Facebook className="w-6 h-6 text-white" /> <Facebook className="w-6 h-6 text-white" />
</div> </div>
) : page.platform === 'tiktok' ? (
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center flex-shrink-0">
<SiTiktok className="w-6 h-6 text-white" />
</div>
) : ( ) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Instagram className="w-6 h-6 text-white" /> <Instagram className="w-6 h-6 text-white" />
@@ -200,42 +218,37 @@ export default function PagesManagementMobile() {
ID: {page.pageId} ID: {page.pageId}
</div> </div>
{/* Token expiration status */} {/* État du jeton, tel que constaté par le dernier contrôle serveur */}
{(() => { <TokenHealthPanel page={page} />
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null {page.platform !== 'tiktok' && (
); <div className="flex">
return ( <RefreshTokenButton page={page} />
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}> </div>
<Calendar className="w-4 h-4 flex-shrink-0" /> )}
<div className="flex-1 min-w-0">
<div className="font-medium">Expiration du token</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
<div className="text-muted-foreground mt-0.5">
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
</div>
)}
</div>
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
<AlertTriangle className="w-4 h-4 text-red-600 flex-shrink-0" />
)}
</div>
);
})()}
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button {page.platform === 'tiktok' ? (
variant="outline" <Button
className="flex-1 min-h-[44px]" variant="outline"
onClick={() => setEditingPage(page)} className="flex-1 min-h-[44px]"
data-testid={`button-edit-page-${page.id}`} onClick={() => { window.location.href = '/api/tiktok/connect'; }}
> data-testid={`button-reconnect-page-${page.id}`}
<Edit className="w-4 h-4 mr-2" /> >
Modifier <RefreshCw className="w-4 h-4 mr-2" />
</Button> Reconnecter
</Button>
) : (
<Button
variant="outline"
className="flex-1 min-h-[44px]"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4 mr-2" />
Modifier
</Button>
)}
<Button <Button
variant="destructive" variant="destructive"
className="flex-1 min-h-[44px]" className="flex-1 min-h-[44px]"
@@ -417,7 +430,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
); );
} }
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) { function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const { toast } = useToast(); const { toast } = useToast();
+117 -87
View File
@@ -1,5 +1,10 @@
import { useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
import { ReelPreview } from "@/components/reels/reel-preview";
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -11,10 +16,12 @@ import { MediaThumbnail } from "@/components/media-thumbnail";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { Media, SocialPage } from "@shared/schema"; import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; } interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
@@ -31,23 +38,11 @@ export default function MobileRemotionVideoPage() {
const [productInfo, setProductInfo] = useState(""); const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]); const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge'); const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural'); engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
// Gemini native TTS voices (Charon = homme, Kore = femme) style: DEFAULT_TTS_STYLE,
const geminiVoices = [ });
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
const [musicFile, setMusicFile] = useState<File | null>(null); const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null); const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3); const [musicVolume, setMusicVolume] = useState(0.3);
@@ -58,6 +53,12 @@ export default function MobileRemotionVideoPage() {
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined); const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [publishDescription, setPublishDescription] = useState(""); const [publishDescription, setPublishDescription] = useState("");
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [showLogo, setShowLogo] = useState(true);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const [renderProgress, setRenderProgress] = useState(0);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null); const galleryInputRef = useRef<HTMLInputElement>(null);
@@ -67,13 +68,37 @@ export default function MobileRemotionVideoPage() {
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] }); const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] }); const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
const facebookPages = socialPages.filter(p => p.platform === 'facebook'); const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
);
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const generateTextMutation = useMutation({ const generateTextMutation = useMutation({
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(), mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); }, onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
onError: () => toast({ title: "Erreur IA", variant: "destructive" }), onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
}); });
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -124,16 +149,6 @@ export default function MobileRemotionVideoPage() {
const totalSelected = images.length + selectedLibraryImages.length; const totalSelected = images.length + selectedLibraryImages.length;
const handleTtsPreview = async () => {
const ttsText = stripForTTS(overlayText);
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
};
const togglePlayPreview = (track: AudioTrack) => { const togglePlayPreview = (track: AudioTrack) => {
if (!audioRef.current) return; if (!audioRef.current) return;
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); } if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
@@ -143,12 +158,19 @@ export default function MobileRemotionVideoPage() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; } if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
setIsRendering(true); setIsRendering(true);
setRenderProgress(0);
setVideoUrl(null); setVideoUrl(null);
try { try {
const formData = new FormData(); const formData = new FormData();
images.forEach(img => formData.append("images", img)); images.forEach(img => formData.append("images", img));
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl)); selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
if (overlayText) formData.append("overlayText", overlayText); if (overlayText) formData.append("overlayText", overlayText);
formData.append("ttsEnabled", String(ttsEnabled));
formData.append("ttsEngine", voiceSettings.engine);
formData.append("ttsVoice", voiceSettings.voice);
formData.append("ttsStyle", voiceSettings.style);
formData.append("captionStyle", captionStyle);
formData.append("showLogo", String(showLogo));
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
@@ -177,6 +199,7 @@ export default function MobileRemotionVideoPage() {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" }); toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false); setIsRendering(false);
} else { } else {
setRenderProgress(job.progress ?? 0);
setTimeout(checkStatus, 3000); setTimeout(checkStatus, 3000);
} }
} catch (e: any) { } catch (e: any) {
@@ -325,6 +348,14 @@ export default function MobileRemotionVideoPage() {
)} )}
<Textarea placeholder="Texte overlay…" value={overlayText} <Textarea placeholder="Texte overlay…" value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" /> onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
<div className="space-y-1.5">
<Label className="text-xs font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
</div>
<div className="flex items-center gap-3">
<Switch id="show-logo-m" checked={showLogo} onCheckedChange={setShowLogo} />
<Label htmlFor="show-logo-m" className="text-sm">Afficher le logo sur la vidéo</Label>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} /> <Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label> <Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
@@ -333,41 +364,7 @@ export default function MobileRemotionVideoPage() {
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground">TTS — voix activée</p> <p className="text-xs text-muted-foreground">TTS — voix activée</p>
{/* TTS Engine Selector */} <VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs px-2"
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
Edge
</Button>
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs px-2"
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-B'); }}>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
) : (
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
)}
</SelectContent>
</Select>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -430,39 +427,72 @@ export default function MobileRemotionVideoPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 4. Page Facebook */} {/* 4. Destinations */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle> <CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{facebookPages.length === 0 ? ( {facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p> <p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
) : ( ) : (
facebookPages.map(page => ( <>
<div key={page.id} className="flex items-center gap-3"> {facebookPages.length > 0 && (
<Checkbox <div className="space-y-2">
id={`page-${page.id}`} <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
checked={selectedPageIds.includes(page.id)} <SiFacebook className="w-4 h-4 text-[#1877F2]" />
onCheckedChange={checked => Pages Facebook
setSelectedPageIds(prev => </div>
checked ? [...prev, page.id] : prev.filter(id => id !== page.id) {facebookPages.map(renderTargetCheckbox)}
) </div>
} )}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer"> {tiktokAccounts.length > 0 && (
{page.pageName} <div className="space-y-2 pt-2">
</label> <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
</div> <SiTiktok className="w-4 h-4" />
)) Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Aperçu en direct */}
{previewImages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[260px] mx-auto">
<ReelPreview
kind="images"
images={previewImages}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={musicFileUrl ?? selectedTrack?.url}
musicVolume={musicVolume}
logoUrl={reelConfig?.logoUrl}
showWatermark={showLogo}
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
endingEffect
/>
</div>
</CardContent>
</Card>
)}
{/* Générer */} {/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg"> <Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
{isRendering {isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération (1-2 min)…</> ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>} : <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
</Button> </Button>
@@ -484,7 +514,7 @@ export default function MobileRemotionVideoPage() {
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p> <p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
) : ( ) : (
<p className="text-xs text-primary font-medium"> <p className="text-xs text-primary font-medium">
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')} Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p> </p>
)} )}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription} <Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
+3
View File
@@ -47,6 +47,9 @@ export default function SettingsMobile() {
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({ const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
useEffect(() => { useEffect(() => {
+26 -7
View File
@@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
@@ -80,6 +80,7 @@ function SortableMediaItem({
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
/> />
</button> </button>
@@ -101,6 +102,16 @@ function SortableMediaItem({
); );
} }
/** Nombre de médias récents proposés à la sélection. */
const RECENT_MEDIA_COUNT = 12;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfToday(): Date {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
export default function NewPost() { export default function NewPost() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation(); const [, navigate] = useLocation();
@@ -116,12 +127,17 @@ export default function NewPost() {
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ // Ce composeur gère les publications feed/story : TikTok n'accepte que des
// vidéos et se pilote depuis la création de reel.
const { data: allPages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
const pages = allPages.filter(p => p.platform !== 'tiktok');
// Seuls les douze derniers médias sont proposés ici : autant ne demander
// que ceux-là plutôt que toute la médiathèque.
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
}); });
// Afficher seulement les 12 derniers médias triés par date décroissante // Afficher seulement les 12 derniers médias triés par date décroissante
@@ -134,11 +150,13 @@ export default function NewPost() {
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA; // Tri décroissant (plus récent en premier) return dateB - dateA; // Tri décroissant (plus récent en premier)
}) })
.slice(0, 12); .slice(0, RECENT_MEDIA_COUNT);
}, [allMedia]); }, [allMedia]);
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
// passé n'a aucune influence sur une planification à venir.
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({ const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
}); });
const uploadMutation = useMutation({ const uploadMutation = useMutation({
@@ -223,10 +241,10 @@ export default function NewPost() {
}); });
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
}, },
onError: () => { onError: (error: unknown) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de générer le texte", description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -540,6 +558,7 @@ export default function NewPost() {
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
/> />
{isSelected && ( {isSelected && (
+163 -285
View File
@@ -25,8 +25,16 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
import { ReelPreview } from "@/components/reels/reel-preview";
import { SrtUpload, type SrtFile } from "@/components/reels/srt-upload";
import { srtText } from "@shared/srt";
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -59,6 +67,8 @@ export default function NewReel() {
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null); const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null); const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
const [overlayText, setOverlayText] = useState(''); const [overlayText, setOverlayText] = useState('');
// Fichier SRT : remplace le texte libre, la voix suit son minutage
const [srtFile, setSrtFile] = useState<SrtFile | null>(null);
const [productInfo, setProductInfo] = useState(''); const [productInfo, setProductInfo] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]); const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]); const [selectedPages, setSelectedPages] = useState<string[]>([]);
@@ -66,8 +76,10 @@ export default function NewReel() {
const [musicVolume, setMusicVolume] = useState([25]); const [musicVolume, setMusicVolume] = useState([25]);
const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsEnabled, setTtsEnabled] = useState(true);
const [drawText, setDrawText] = useState(true); const [drawText, setDrawText] = useState(true);
const [stabilize, setStabilize] = useState(true); // default to true // Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
// TTS Sync state // TTS Sync state
const [syncInfo, setSyncInfo] = useState<{ const [syncInfo, setSyncInfo] = useState<{
@@ -78,24 +90,18 @@ export default function NewReel() {
warnings: string[]; warnings: string[];
} | null>(null); } | null>(null);
// TTS Engine & Voice // Voix : moteur, voix et ton de lecture
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge'); const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural'); engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
// Gemini native TTS voices (Charon = homme, Kore = femme) style: DEFAULT_TTS_STYLE,
const geminiVoices = [ });
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' }, const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' }, const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
]; // Dernière voix générée : l'aperçu l'utilise tant que texte et réglages n'ont pas changé
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
// French Edge TTS voices const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const edgeVoices = [ const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
// Enable TTS by default on mobile // Enable TTS by default on mobile
useEffect(() => { useEffect(() => {
@@ -107,7 +113,7 @@ export default function NewReel() {
// Auto-calculate TTS sync when text changes // Auto-calculate TTS sync when text changes
useEffect(() => { useEffect(() => {
if (!ttsEnabled || !overlayText.trim()) { if (!ttsEnabled || !overlayText.trim() || srtFile) {
setSyncInfo(null); setSyncInfo(null);
return; return;
} }
@@ -122,7 +128,7 @@ export default function NewReel() {
.catch(() => setSyncInfo(null)); .catch(() => setSyncInfo(null));
}, 800); }, 800);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]); }, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]);
// État audio preview // État audio preview
@@ -133,6 +139,32 @@ export default function NewReel() {
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
// Destinations acceptant une vidéo verticale
const facebookPages = pages.filter(p => p.platform === 'facebook');
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center space-x-2">
<Checkbox
id={`page-${page.id}`}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-none flex-1"
>
{page.pageName}
</label>
</div>
);
// Récupérer les vidéos disponibles // Récupérer les vidéos disponibles
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media'],
@@ -208,10 +240,10 @@ export default function NewReel() {
description: `${variants.length} variations créées`, description: `${variants.length} variations créées`,
}); });
}, },
onError: () => { onError: (error: unknown) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de générer le texte", description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -226,10 +258,10 @@ export default function NewReel() {
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({ toast({
title: data.success ? "Reel créé !" : "Reel créé avec avertissements", title: "Création du Reel lancée",
description: data.success description: data.queued
? "Votre Reel a été publié avec succès" ? "Un autre Reel est en cours : le vôtre démarrera juste après. Suivez l'avancement sur le tableau de bord."
: "Certaines pages ont échoué", : "Suivez l'avancement sur le tableau de bord.",
}); });
navigate('/'); navigate('/');
}, },
@@ -378,17 +410,21 @@ export default function NewReel() {
createReelMutation.mutate({ createReelMutation.mutate({
videoMediaId: selectedVideoId, videoMediaId: selectedVideoId,
musicTrackId: selectedTrack?.id, musicTrackId: selectedTrack?.id,
overlayText: overlayText, overlayText: srtFile ? undefined : overlayText,
description: overlayText, srtCues: srtFile?.cues,
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(), scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100, musicVolume: musicVolume[0] / 100,
ttsEnabled, ttsEnabled,
ttsEngine, ttsEngine,
ttsVoice, ttsVoice,
ttsStyle,
captionStyle,
drawText, drawText,
stabilize: stabilize, stabilize: stabilize,
enableEndingEffect, enableEndingEffect,
showLogo,
}); });
}; };
@@ -541,6 +577,7 @@ export default function NewReel() {
<MediaThumbnail <MediaThumbnail
src={media.originalUrl} src={media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type="video" type="video"
/> />
{selectedVideoId === media.id && ( {selectedVideoId === media.id && (
@@ -657,7 +694,7 @@ export default function NewReel() {
setSelectedTrack(null); setSelectedTrack(null);
setCurrentStep('text'); setCurrentStep('text');
}}> }}>
Passer Passer (garder le son de la vidéo)
</Button> </Button>
<Button onClick={() => setCurrentStep('text')}> <Button onClick={() => setCurrentStep('text')}>
Continuer Continuer
@@ -672,6 +709,7 @@ export default function NewReel() {
{/* ÉTAPE 3: Texte */} {/* ÉTAPE 3: Texte */}
{currentStep === 'text' && ( {currentStep === 'text' && (
<> <>
{!srtFile && (
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -699,8 +737,9 @@ export default function NewReel() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
)}
{generatedVariants.length > 0 && ( {!srtFile && generatedVariants.length > 0 && (
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader> <CardHeader>
<CardTitle>Variations générées</CardTitle> <CardTitle>Variations générées</CardTitle>
@@ -740,16 +779,26 @@ export default function NewReel() {
Texte Overlay Texte Overlay
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Ce texte s'affichera au centre de votre Reel (style TikTok) Ce texte s'affichera au centre de votre Reel (style TikTok).
Vous pouvez aussi importer un fichier SRT : il remplace le texte.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Textarea <div className="mb-4">
value={overlayText} <SrtUpload value={srtFile} onChange={setSrtFile} />
onChange={(e) => setOverlayText(e.target.value)} </div>
placeholder="Écrivez le texte qui apparaîtra sur votre Reel..." {srtFile ? (
rows={4} <p className="text-xs text-muted-foreground">
/> Texte libre désactivé : le fichier SRT est utilisé. Retirez-le pour écrire un texte.
</p>
) : (
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Écrivez le texte qui apparaîtra sur votre Reel..."
rows={4}
/>
)}
<div className="flex items-center space-x-2 mt-4"> <div className="flex items-center space-x-2 mt-4">
<Switch <Switch
@@ -762,6 +811,26 @@ export default function NewReel() {
</Label> </Label>
</div> </div>
{drawText && (
<div className="mt-3 ml-12">
<Label className="text-sm font-medium">Style des sous-titres</Label>
<div className="mt-2">
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
</div>
</div>
)}
<div className="flex items-center space-x-2 mt-4">
<Switch
id="show-logo"
checked={showLogo}
onCheckedChange={setShowLogo}
/>
<Label htmlFor="show-logo" className="font-medium cursor-pointer">
Afficher le logo sur la vidéo
</Label>
</div>
<div className="flex items-center space-x-2 mt-4"> <div className="flex items-center space-x-2 mt-4">
<Switch <Switch
id="enable-ending-effect" id="enable-ending-effect"
@@ -791,91 +860,16 @@ export default function NewReel() {
<span>TTS — voix activée</span> <span>TTS — voix activée</span>
</div> </div>
{/* TTS Engine Selector */} <div className="mt-3">
<div className="flex items-center gap-4 mt-3"> <VoicePicker
<Label className="text-sm font-medium">Moteur:</Label> value={voiceSettings}
<div className="flex gap-2"> onChange={setVoiceSettings}
<Button sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
size="sm" onPreview={setVoicePreview}
variant={ttsEngine === 'edge' ? 'default' : 'outline'} />
onClick={() => {
setTtsEngine('edge');
setTtsVoice('fr-FR-VivienneMultilingualNeural');
}}
>
Edge TTS
</Button>
<Button
size="sm"
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
onClick={() => {
setTtsEngine('gemini');
setTtsVoice('fr-FR-Standard-B');
}}
>
Gemini TTS
</Button>
</div>
</div> </div>
{/* Voice Selector */} {syncInfo && !srtFile && (
<div className="flex items-center gap-3 mt-3">
<Label className="text-sm font-medium shrink-0">Voix:</Label>
<Select
value={ttsVoice}
onValueChange={setTtsVoice}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
) : (
geminiVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="mt-3 flex gap-2">
<Button
size="sm"
variant="secondary"
className="w-full"
onClick={async (e) => {
e.stopPropagation();
const textToTest = overlayText || "Ceci est un test de voix pour votre vidéo.";
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
ttsEngine,
ttsVoice,
});
const data = await response.json();
if (data.success && data.audioBase64) {
const audio = new Audio(`data:audio/mp3;base64,${data.audioBase64}`);
audio.play();
}
} catch (err) {
toast({
title: "Erreur",
description: "Impossible de tester la voix",
variant: "destructive"
});
}
}}
>
<Play className="w-3 h-3 mr-2" />
Tester la voix
</Button>
</div>
{syncInfo && (
<div className={`p-3 rounded-lg border mt-3 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}> <div className={`p-3 rounded-lg border mt-3 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium">Sync texte/voix</span> <span className="font-medium">Sync texte/voix</span>
@@ -893,8 +887,10 @@ export default function NewReel() {
)} )}
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Le texte sera automatiquement synchronisé avec la voix. {srtFile
Les #hashtags et émojis ne seront pas lus. ? "Chaque sous-titre sera lu à son instant et accéléré si besoin pour respecter la durée du fichier SRT."
: "Le texte sera automatiquement synchronisé avec la voix."}
{' '}Les #hashtags et émojis ne seront pas lus.
</p> </p>
</div> </div>
)} )}
@@ -918,42 +914,42 @@ export default function NewReel() {
<> <>
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader> <CardHeader>
<CardTitle>Pages cibles</CardTitle> <CardTitle>Destinations</CardTitle>
<CardDescription>Sélectionnez les pages où publier</CardDescription> <CardDescription>
La même vidéo peut partir sur plusieurs pages Facebook et plusieurs comptes TikTok
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{pages.filter(p => p.platform === 'facebook').length === 0 ? ( {facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-muted-foreground mb-2"> <p className="text-muted-foreground mb-2">
Aucune page Facebook connectée Aucune page Facebook ni compte TikTok connecté
</p> </p>
<Button variant="link" onClick={() => navigate('/pages')}> <Button variant="link" onClick={() => navigate('/pages')}>
Ajouter des pages Ajouter des pages
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-5">
{pages.filter(p => p.platform === 'facebook').map((page) => ( {facebookPages.length > 0 && (
<div key={page.id} className="flex items-center space-x-2"> <div className="space-y-3">
<Checkbox <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
id={`page-${page.id}`} <SiFacebook className="w-4 h-4 text-[#1877F2]" />
checked={selectedPages.includes(page.id)} Pages Facebook
onCheckedChange={(checked) => { </div>
if (checked) { {facebookPages.map(renderTargetCheckbox)}
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-none flex-1"
>
{page.pageName}
</label>
</div> </div>
))} )}
{tiktokAccounts.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -1017,31 +1013,22 @@ export default function NewReel() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{selectedVideo ? ( {selectedVideo ? (
<div className="relative aspect-[9/16] bg-black rounded-lg overflow-hidden"> <ReelPreview
<video kind="video"
src={selectedVideo.originalUrl} videoUrl={selectedVideo.originalUrl}
className="w-full h-full object-cover" text={srtFile ? srtText(srtFile.cues) : overlayText}
muted srtCues={srtFile?.cues}
playsInline showCaptions={drawText}
loop captionStyle={captionStyle}
autoPlay ttsEnabled={ttsEnabled}
/> voice={currentVoice}
{overlayText && ( musicUrl={selectedTrack?.previewUrl}
<div className="absolute inset-0 flex items-center justify-center p-4"> musicVolume={musicVolume[0] / 100}
<p className="text-white text-center text-lg font-bold drop-shadow-lg"> logoUrl={reelConfig?.logoUrl}
{overlayText} showWatermark={showLogo}
</p> storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
</div> endingEffect={enableEndingEffect}
)} />
{selectedTrack && (
<div className="absolute bottom-4 left-4 right-4 bg-black/50 rounded-lg p-2 flex items-center gap-2">
<Music className="w-4 h-4 text-white" />
<span className="text-white text-sm truncate">
{selectedTrack.title} - {selectedTrack.artist}
</span>
</div>
)}
</div>
) : ( ) : (
<div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center"> <div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
@@ -1065,11 +1052,11 @@ export default function NewReel() {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Musique</span> <span className="text-muted-foreground">Musique</span>
<span>{selectedTrack ? selectedTrack.title : 'Aucune'}</span> <span>{selectedTrack ? selectedTrack.title : 'Son de la vidéo'}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Texte</span> <span className="text-muted-foreground">Texte</span>
<span>{overlayText ? '✓' : '—'}</span> <span>{srtFile ? `SRT (${srtFile.cues.length})` : overlayText ? '✓' : '—'}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Pages</span> <span className="text-muted-foreground">Pages</span>
@@ -1081,115 +1068,6 @@ export default function NewReel() {
</div > </div >
</div > </div >
</main > </main >
{/* Overlay de progression */}
< ProcessingOverlay
isVisible={createReelMutation.isPending}
stabilize={stabilize}
ttsEnabled={ttsEnabled}
/>
</div > </div >
); );
} }
function ProcessingOverlay({ isVisible, stabilize, ttsEnabled }: { isVisible: boolean; stabilize: boolean; ttsEnabled: boolean }) {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("Initialisation...");
useEffect(() => {
if (!isVisible) {
setProgress(0);
return;
}
setProgress(0);
setStatus("Préparation des fichiers...");
const timeouts: any[] = [];
let interval: any;
// Sequence de simulation
// 1. 2s: Téléchargement
timeouts.push(setTimeout(() => {
setStatus("Téléchargement des médias...");
setProgress(10);
}, 1500));
// 2. 4s: Audio/TTS
timeouts.push(setTimeout(() => {
setStatus(ttsEnabled ? "Génération de la voix IA..." : "Mixage audio...");
setProgress(25);
}, 3500));
// 3. 7s: Stabilisation ou Encodage
timeouts.push(setTimeout(() => {
if (stabilize) {
setStatus("Stabilisation vidéo (Traitement long)...");
setProgress(35);
// Progression lente : +1% toutes les 800ms
// Ça permet de couvrir ~45 secondes avant d'arriver à 90%
let p = 35;
interval = setInterval(() => {
if (p < 90) {
p++;
setProgress(p);
}
}, 800);
} else {
setStatus("Encodage optimisé...");
setProgress(40);
// Progression pour encodage standard (~10-15s)
let p = 40;
interval = setInterval(() => {
if (p < 90) {
p += 2;
setProgress(p);
}
}, 500);
}
}, 6500));
// Nettoyage
return () => {
timeouts.forEach(clearTimeout);
if (interval) clearInterval(interval);
};
}, [isVisible, stabilize, ttsEnabled]);
if (!isVisible) return null;
return (
<div className="fixed inset-0 z-[60] bg-black/90 flex flex-col items-center justify-center p-4 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-8 text-center">
<div className="relative w-24 h-24 mx-auto mb-8">
<Loader2 className="w-full h-full text-primary animate-spin" />
<div className="absolute inset-0 flex items-center justify-center font-bold text-xl">
{progress}%
</div>
</div>
<div className="space-y-4">
<h3 className="text-2xl font-bold text-white tracking-tight">Création de votre Reel</h3>
<p className="text-lg text-muted-foreground animate-pulse">
{status}
</p>
</div>
<div className="w-full h-2 bg-secondary/30 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{stabilize && (
<div className="flex items-center justify-center gap-2 text-sm text-yellow-500/80 bg-yellow-500/10 py-2 px-4 rounded-full mx-auto w-fit">
<Sparkles className="w-4 h-4" />
<span>Stabilisation activée : traitement prolongé</span>
</div>
)}
</div>
</div>
);
}
+107 -99
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,68 +12,41 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage } from "@shared/schema"; import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns"; import {
import { fr } from "date-fns/locale"; ConnectFacebookButton,
RefreshTokenButton,
function getTokenExpirationStatus(expiresAt: string | null | undefined) { TokenAlertBanner,
if (!expiresAt) { TokenHealthPanel,
return { } from "@/components/facebook-token-status";
status: 'unknown' as const,
color: 'text-gray-500',
bgColor: 'bg-gray-100',
daysLeft: null,
message: 'Date inconnue'
};
}
const now = new Date();
const expiration = new Date(expiresAt);
const diffTime = expiration.getTime() - now.getTime();
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (daysLeft < 0) {
return {
status: 'expired' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: 'Expiré'
};
} else if (daysLeft <= 7) {
return {
status: 'urgent' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
};
} else if (daysLeft <= 15) {
return {
status: 'warning' as const,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
daysLeft,
message: `${daysLeft} jours restants`
};
} else {
return {
status: 'good' as const,
color: 'text-green-600',
bgColor: 'bg-green-100',
daysLeft,
message: `${daysLeft} jours restants`
};
}
}
export default function PagesManagement() { export default function PagesManagement() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null); const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({ // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
// redirigent ici avec le résultat en paramètre)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
if (!network) return;
const result = params.get(network);
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
toast({
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
@@ -119,25 +93,33 @@ export default function PagesManagement() {
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1> <h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Connectez et gérez vos pages Facebook et Instagram Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
</p> </p>
</div> </div>
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <div className="flex items-center gap-3">
<ConnectFacebookButton />
<ConnectTiktokButton />
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} /> <EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
</div> </div>
<TokenAlertBanner pages={pages} />
{/* Permissions required notice */} {/* Permissions required notice */}
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3"> <div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" /> <AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm"> <div className="text-sm">
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Permissions Facebook requises pour la publication vidéo</p> <p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Connectez vos pages plutôt que de coller un jeton</p>
<p className="text-muted-foreground mb-2"> <p className="text-muted-foreground mb-2">
Le token de page doit inclure : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "} « Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "} ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la
<code className="bg-muted px-1 rounded text-xs">publish_video</code> main expire au bout de 60 jours et devra être remplacé manuellement.
</p> </p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Dans Graph API Explorer → Sélectionnez votre app → Ajoutez ces 3 permissions → Générez un User Token → Échangez via <code className="bg-muted px-1 rounded">/me/accounts</code> pour obtenir le Page Token. Le jeton doit couvrir : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
</p> </p>
</div> </div>
</div> </div>
@@ -194,6 +176,10 @@ export default function PagesManagement() {
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
<Facebook className="w-6 h-6 text-white" /> <Facebook className="w-6 h-6 text-white" />
</div> </div>
) : page.platform === 'tiktok' ? (
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
<SiTiktok className="w-6 h-6 text-white" />
</div>
) : ( ) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
<Instagram className="w-6 h-6 text-white" /> <Instagram className="w-6 h-6 text-white" />
@@ -213,41 +199,32 @@ export default function PagesManagement() {
ID: {page.pageId} ID: {page.pageId}
</div> </div>
{/* Token expiration status */} {/* État du jeton, tel que constaté par le dernier contrôle serveur */}
{(() => { <TokenHealthPanel page={page} />
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
);
return (
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
<Calendar className="w-4 h-4" />
<div className="flex-1">
<div className="font-medium">Expiration du token</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
<div className="text-muted-foreground mt-0.5">
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
</div>
)}
</div>
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
<AlertTriangle className="w-4 h-4 text-red-600" />
)}
</div>
);
})()}
<div className="flex items-center justify-end gap-2 pt-2"> <div className="flex items-center justify-end gap-2 pt-2">
<Button {page.platform !== 'tiktok' && <RefreshTokenButton page={page} />}
variant="ghost" {page.platform === 'tiktok' ? (
size="icon" // Un token TikTok ne se saisit pas : on repasse par l'autorisation
onClick={() => setEditingPage(page)} <Button
data-testid={`button-edit-page-${page.id}`} variant="ghost"
> size="sm"
<Edit className="w-4 h-4" /> onClick={() => { window.location.href = '/api/tiktok/connect'; }}
</Button> data-testid={`button-reconnect-page-${page.id}`}
>
<RefreshCw className="w-4 h-4 mr-2" />
Reconnecter
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -269,6 +246,37 @@ export default function PagesManagement() {
); );
} }
/**
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
*/
function ConnectTiktokButton() {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/tiktok/config'],
});
const handleClick = () => {
if (config && !config.configured) {
toast({
title: "TikTok n'est pas configuré",
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
variant: "destructive",
});
return;
}
window.location.href = '/api/tiktok/connect';
};
return (
<Button variant="outline" onClick={handleClick} data-testid="button-connect-tiktok">
<SiTiktok className="w-4 h-4 mr-2" />
Connecter un compte TikTok
</Button>
);
}
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook'); const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
const [pageName, setPageName] = useState(''); const [pageName, setPageName] = useState('');
@@ -417,7 +425,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
); );
} }
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) { function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const { toast } = useToast(); const { toast } = useToast();
+116 -85
View File
@@ -1,19 +1,26 @@
import { useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
import { ReelPreview } from "@/components/reels/reel-preview";
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react"; import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import type { Media, SocialPage } from "@shared/schema"; import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
interface AudioTrack { interface AudioTrack {
@@ -32,23 +39,11 @@ export default function RemotionVideoPage() {
const [productInfo, setProductInfo] = useState(""); const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]); const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge'); const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural'); engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
// Gemini native TTS voices (Charon = homme, Kore = femme) style: DEFAULT_TTS_STYLE,
const geminiVoices = [ });
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
const [musicFile, setMusicFile] = useState<File | null>(null); const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null); const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3); const [musicVolume, setMusicVolume] = useState(0.3);
@@ -59,6 +54,12 @@ export default function RemotionVideoPage() {
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined); const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [publishDescription, setPublishDescription] = useState(""); const [publishDescription, setPublishDescription] = useState("");
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [showLogo, setShowLogo] = useState(true);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const [renderProgress, setRenderProgress] = useState(0);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const { toast } = useToast(); const { toast } = useToast();
@@ -66,7 +67,30 @@ export default function RemotionVideoPage() {
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] }); const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] }); const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
const facebookPages = socialPages.filter(p => p.platform === 'facebook'); const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`pre-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
);
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const totalSelected = images.length + selectedLibraryImages.length; const totalSelected = images.length + selectedLibraryImages.length;
@@ -76,7 +100,7 @@ export default function RemotionVideoPage() {
setGeneratedVariants(data.variants || []); setGeneratedVariants(data.variants || []);
toast({ title: "Textes générés", description: "Cliquez pour appliquer." }); toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
}, },
onError: () => toast({ title: "Erreur IA", variant: "destructive" }), onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
}); });
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -91,16 +115,6 @@ export default function RemotionVideoPage() {
}); });
}; };
const handleTtsPreview = async () => {
const ttsText = overlayText.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
};
const togglePlayPreview = (track: AudioTrack) => { const togglePlayPreview = (track: AudioTrack) => {
if (!audioRef.current) return; if (!audioRef.current) return;
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); } if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
@@ -110,12 +124,19 @@ export default function RemotionVideoPage() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; } if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
setIsRendering(true); setIsRendering(true);
setRenderProgress(0);
setVideoUrl(null); setVideoUrl(null);
try { try {
const formData = new FormData(); const formData = new FormData();
images.forEach(img => formData.append("images", img)); images.forEach(img => formData.append("images", img));
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl)); selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
if (overlayText) formData.append("overlayText", overlayText); if (overlayText) formData.append("overlayText", overlayText);
formData.append("ttsEnabled", String(ttsEnabled));
formData.append("ttsEngine", voiceSettings.engine);
formData.append("ttsVoice", voiceSettings.voice);
formData.append("ttsStyle", voiceSettings.style);
formData.append("captionStyle", captionStyle);
formData.append("showLogo", String(showLogo));
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
@@ -144,6 +165,7 @@ export default function RemotionVideoPage() {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" }); toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false); setIsRendering(false);
} else { } else {
setRenderProgress(job.progress ?? 0);
setTimeout(checkStatus, 3000); setTimeout(checkStatus, 3000);
} }
} catch (e: any) { } catch (e: any) {
@@ -266,6 +288,14 @@ export default function RemotionVideoPage() {
)} )}
<Textarea placeholder="Votre texte overlay..." value={overlayText} <Textarea placeholder="Votre texte overlay..." value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={3} /> onChange={e => setOverlayText(e.target.value)} rows={3} />
<div className="space-y-1.5">
<Label className="text-sm font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
</div>
<div className="flex items-center gap-3">
<Switch id="show-logo" checked={showLogo} onCheckedChange={setShowLogo} />
<Label htmlFor="show-logo">Afficher le logo sur la vidéo</Label>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} /> <Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts">Activer la voix TTS</Label> <Label htmlFor="tts">Activer la voix TTS</Label>
@@ -274,41 +304,7 @@ export default function RemotionVideoPage() {
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border"> <div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
<p className="text-sm text-muted-foreground">TTS — voix activée</p> <p className="text-sm text-muted-foreground">TTS — voix activée</p>
{/* TTS Engine Selector */} <VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs"
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
Edge
</Button>
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs"
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-B'); }}>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
) : (
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
)}
</SelectContent>
</Select>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -376,38 +372,73 @@ export default function RemotionVideoPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 4. Page Facebook */} {/* 4. Destinations */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle> <CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
<CardDescription>Sélectionnez la page cible (son nom apparaîtra en fin de vidéo).</CardDescription> <CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{facebookPages.length === 0 ? ( {facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune page Facebook connectée.</p> <p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
) : ( ) : (
facebookPages.map(page => ( <>
<div key={page.id} className="flex items-center gap-3"> {facebookPages.length > 0 && (
<Checkbox <div className="space-y-2">
id={`pre-${page.id}`} <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
checked={selectedPageIds.includes(page.id)} <SiFacebook className="w-4 h-4 text-[#1877F2]" />
onCheckedChange={checked => Pages Facebook
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id)) </div>
} {facebookPages.map(renderTargetCheckbox)}
/> </div>
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer"> )}
{page.pageName}
</label> {tiktokAccounts.length > 0 && (
</div> <div className="space-y-2 pt-2">
)) <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Aperçu en direct */}
{previewImages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[300px] mx-auto">
<ReelPreview
kind="images"
images={previewImages}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={musicFileUrl ?? selectedTrack?.url}
musicVolume={musicVolume}
logoUrl={reelConfig?.logoUrl}
showWatermark={showLogo}
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
endingEffect
/>
</div>
</CardContent>
</Card>
)}
{/* Générer */} {/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg"> <Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
{isRendering {isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération en cours (1-2 min)...</> ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>} : <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
</Button> </Button>
@@ -432,7 +463,7 @@ export default function RemotionVideoPage() {
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p> <p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
) : ( ) : (
<p className="text-sm text-primary font-medium"> <p className="text-sm text-primary font-medium">
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')} Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p> </p>
)} )}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription} <Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
+230 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react"; import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { ModelCombobox } from "@/components/model-combobox"; import { ModelCombobox } from "@/components/model-combobox";
import { SiTiktok } from "react-icons/si";
export default function Settings() { export default function Settings() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -31,6 +32,10 @@ export default function Settings() {
const [ffmpegApiKey, setFfmpegApiKey] = useState(""); const [ffmpegApiKey, setFfmpegApiKey] = useState("");
const [externalApiKey, setExternalApiKey] = useState(""); const [externalApiKey, setExternalApiKey] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState(""); const [geminiApiKey, setGeminiApiKey] = useState("");
const [facebookAppId, setFacebookAppId] = useState("");
const [facebookAppSecret, setFacebookAppSecret] = useState("");
const [tiktokClientKey, setTiktokClientKey] = useState("");
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
const { toast } = useToast(); const { toast } = useToast();
const { data: session } = useQuery<{ id: string; username: string; role: string }>({ const { data: session } = useQuery<{ id: string; username: string; role: string }>({
@@ -50,12 +55,34 @@ export default function Settings() {
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({ const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
const { data: ffmpegConfig } = useQuery({ const { data: ffmpegConfig } = useQuery({
queryKey: ['/api/ffmpeg/config'], queryKey: ['/api/ffmpeg/config'],
}); });
const { data: tiktokConfig } = useQuery<{
configured: boolean;
clientKey: string;
hasClientSecret: boolean;
redirectUri: string;
}>({
queryKey: ['/api/tiktok/config'],
});
const { data: facebookConfig } = useQuery<{
configured: boolean;
appId: string;
hasAppSecret: boolean;
redirectUri: string;
scopes: string[];
}>({
queryKey: ['/api/facebook/config'],
});
const { data: externalApiConfig } = useQuery({ const { data: externalApiConfig } = useQuery({
queryKey: ['/api/settings/external-api'], queryKey: ['/api/settings/external-api'],
enabled: isAdmin, enabled: isAdmin,
@@ -92,6 +119,20 @@ export default function Settings() {
} }
}, [ffmpegConfig]); }, [ffmpegConfig]);
useEffect(() => {
if (tiktokConfig) {
// Le client secret n'est jamais renvoyé par l'API : il reste vide
setTiktokClientKey(tiktokConfig.clientKey || "");
}
}, [tiktokConfig]);
useEffect(() => {
if (facebookConfig) {
// L'App Secret n'est jamais renvoyé par l'API : il reste vide
setFacebookAppId(facebookConfig.appId || "");
}
}, [facebookConfig]);
useEffect(() => { useEffect(() => {
if (geminiConfig) { if (geminiConfig) {
// Don't pre-fill - user must enter key manually // Don't pre-fill - user must enter key manually
@@ -264,6 +305,55 @@ export default function Settings() {
}, },
}); });
const saveFacebookMutation = useMutation({
mutationFn: () => {
const payload: any = { appId: facebookAppId.trim() };
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
if (facebookAppSecret.trim() !== "") {
payload.appSecret = facebookAppSecret.trim();
}
return apiRequest('PUT', '/api/facebook/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/facebook/config'] });
setFacebookAppSecret("");
toast({
title: "Configuration Facebook enregistrée",
description: "Les jetons de page se renouvellent désormais automatiquement",
});
},
onError: async (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible d'enregistrer la configuration Facebook",
variant: "destructive",
});
},
});
const saveTiktokMutation = useMutation({
mutationFn: () => {
const payload: any = { clientKey: tiktokClientKey.trim() };
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
if (tiktokClientSecret.trim() !== "") {
payload.clientSecret = tiktokClientSecret.trim();
}
return apiRequest('PUT', '/api/tiktok/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tiktok/config'] });
setTiktokClientSecret("");
toast({ title: "Configuration TikTok enregistrée", description: "Vous pouvez maintenant connecter les comptes TikTok de vos magasins" });
},
onError: async (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible d'enregistrer la configuration TikTok",
variant: "destructive",
});
},
});
const deleteExternalApiMutation = useMutation({ const deleteExternalApiMutation = useMutation({
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}), mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
onSuccess: () => { onSuccess: () => {
@@ -760,6 +850,145 @@ export default function Settings() {
</Card> </Card>
)} )}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Facebook className="w-5 h-5" />
Facebook
</CardTitle>
<CardDescription>
Identifiants de votre application Facebook (developers.facebook.com). Ils
permettent de connecter vos pages en un clic et de renouveler leurs jetons
automatiquement, sans jamais recoller un token à la main.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{facebookConfig?.configured && (
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Key className="w-4 h-4" />
<span>Application Facebook configurée</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="facebookAppId">App ID</Label>
<Input
id="facebookAppId"
value={facebookAppId}
onChange={(e) => setFacebookAppId(e.target.value)}
placeholder="1234567890123456"
/>
</div>
<div className="space-y-2">
<Label htmlFor="facebookAppSecret">
{facebookConfig?.hasAppSecret ? "Nouvel App Secret (laisser vide pour conserver l'actuel)" : "App Secret"}
</Label>
<Input
id="facebookAppSecret"
type="password"
value={facebookAppSecret}
onChange={(e) => setFacebookAppSecret(e.target.value)}
placeholder="••••••••••••••••"
/>
</div>
<div className="space-y-2">
<Label>URI de redirection à déclarer chez Facebook</Label>
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
{facebookConfig?.redirectUri || `${window.location.origin}/api/facebook/callback`}
</code>
<p className="text-xs text-muted-foreground">
À ajouter dans « Connexion Facebook › Paramètres › URI de redirection OAuth valides ».
</p>
</div>
{facebookConfig?.scopes?.length ? (
<div className="space-y-2">
<Label>Permissions demandées</Label>
<p className="text-xs text-muted-foreground break-all">
{facebookConfig.scopes.join(', ')}
</p>
</div>
) : null}
<Button
onClick={() => saveFacebookMutation.mutate()}
disabled={saveFacebookMutation.isPending || !facebookAppId.trim()}
className="w-full"
>
{saveFacebookMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</CardContent>
</Card>
)}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<SiTiktok className="w-5 h-5" />
TikTok
</CardTitle>
<CardDescription>
Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
connecter les comptes TikTok de chaque magasin depuis la page « Pages gérées ».
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{tiktokConfig?.configured && (
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Key className="w-4 h-4" />
<span>Application TikTok configurée</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="tiktokClientKey">Client key</Label>
<Input
id="tiktokClientKey"
value={tiktokClientKey}
onChange={(e) => setTiktokClientKey(e.target.value)}
placeholder="awxxxxxxxxxxxxxx"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tiktokClientSecret">
{tiktokConfig?.hasClientSecret ? "Nouveau client secret (laisser vide pour conserver l'actuel)" : "Client secret"}
</Label>
<Input
id="tiktokClientSecret"
type="password"
value={tiktokClientSecret}
onChange={(e) => setTiktokClientSecret(e.target.value)}
placeholder="••••••••••••••••"
/>
</div>
<div className="space-y-2">
<Label>URL de redirection à déclarer chez TikTok</Label>
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
{tiktokConfig?.redirectUri || `${window.location.origin}/api/tiktok/callback`}
</code>
<p className="text-xs text-muted-foreground">
Tant que l'application n'a pas passé l'audit TikTok, les vidéos publiées
restent privées (visibles du seul propriétaire du compte).
</p>
</div>
<Button
onClick={() => saveTiktokMutation.mutate()}
disabled={saveTiktokMutation.isPending || !tiktokClientKey.trim()}
className="w-full"
>
{saveTiktokMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</CardContent>
</Card>
)}
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6"> <CardHeader className="p-6">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
+40 -209
View File
@@ -1,21 +1,24 @@
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion"; import React from "react";
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
export type WordTiming = { import type { CaptionStyle, TimedWord } from "@shared/captions";
word: string; import { Captions } from "./components/Captions";
startFrame: number; import { Outro, Watermark } from "./components/Branding";
endFrame: number;
};
export type ImageCompositionProps = { export type ImageCompositionProps = {
images: string[]; images: string[];
overlayText?: string; totalDuration: number;
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
words: TimedWord[];
captionStyle: CaptionStyle;
audioUrl?: string; audioUrl?: string;
wordTimings?: WordTiming[];
musicUrl?: string; musicUrl?: string;
musicVolume?: number; musicVolume?: number;
logoUrl?: string; logoUrl?: string;
/** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
showWatermark?: boolean;
storeName?: string; storeName?: string;
endingFrames?: number; /** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
endingSeconds?: number;
}; };
/** /**
@@ -75,217 +78,45 @@ const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effec
); );
}; };
/** /** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
* CaptionGroup — renders a group of words (CapCut-style). export const ImageComposition: React.FC<ImageCompositionProps> = ({
* Active word is highlighted in yellow with glow; others are white. images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
* Background: semi-transparent dark pill. }) => {
*/
const CaptionGroup: React.FC<{
words: WordTiming[];
activeIdx: number; // index within this group (0-3), -1 if none active
groupFirstFrame: number;
fps: number;
}> = ({ words, activeIdx, groupFirstFrame, fps }) => {
const frame = useCurrentFrame();
// Fade in when this group first appears
const fadeIn = spring({
frame: frame - groupFirstFrame,
fps,
config: { damping: 30, stiffness: 120 },
durationInFrames: 10,
});
return (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: "0 22px",
maxWidth: "90%",
opacity: fadeIn,
}}
>
{words.map((w, i) => {
const isActive = i === activeIdx;
return (
<span
key={i}
style={{
fontFamily: "'Arial Black', 'Impact', 'Helvetica Neue', sans-serif",
fontSize: isActive ? 82 : 74,
fontWeight: 900,
lineHeight: 1.15,
color: isActive ? "#FFE600" : "white",
textTransform: "uppercase",
textShadow: isActive
? "0 0 30px rgba(255,230,0,0.6), 0 4px 24px rgba(0,0,0,0.95)"
: "0 3px 18px rgba(0,0,0,0.9)",
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.85)" : "2px rgba(0,0,0,0.75)",
display: "inline-block",
transform: isActive ? "scale(1.1)" : "scale(1)",
transition: "transform 0.05s",
}}
>
{w.word}
</span>
);
})}
</div>
);
};
export const ImageComposition = ({
images, audioUrl, wordTimings, musicUrl, musicVolume = 0.3,
logoUrl, storeName, endingFrames = 90,
}: ImageCompositionProps) => {
const { fps, durationInFrames } = useVideoConfig(); const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame(); const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
const hasEnding = !!(logoUrl || storeName); const contentFrames = durationInFrames - endingFrames;
const effectiveEndingFrames = hasEnding ? endingFrames : 0; const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
const contentFrames = durationInFrames - effectiveEndingFrames; const endingStart = contentFrames / fps;
const durationPerImage = Math.floor(contentFrames / Math.max(images.length, 1));
const endingStart = contentFrames;
// Ending slide spring animation
const endingProgress = spring({
frame: frame - endingStart,
fps,
config: { damping: 20, stiffness: 80 },
durationInFrames: 30,
});
// Group words into sets of 3 for caption display
const WORDS_PER_GROUP = 3;
const activeWordIdx = wordTimings
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
: -1;
// Only show caption when a word is actively being spoken
const renderCaption = frame < endingStart && activeWordIdx >= 0;
const groupIdx = activeWordIdx >= 0 ? Math.floor(activeWordIdx / WORDS_PER_GROUP) : 0;
const groupStart = groupIdx * WORDS_PER_GROUP;
const groupWords = wordTimings ? wordTimings.slice(groupStart, groupStart + WORDS_PER_GROUP) : [];
const activeIdxInGroup = activeWordIdx - groupStart;
// First frame of the current group (for fade-in)
const groupFirstFrame = groupWords.length > 0 ? groupWords[0].startFrame : 0;
return ( return (
<AbsoluteFill style={{ backgroundColor: "black" }}> <AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Images — displayed immediately, no entrance transition */}
{images.map((imgUrl, index) => ( {images.map((imgUrl, index) => (
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}> <Sequence key={index} from={index * perImage} durationInFrames={perImage}>
<ImageSlide src={imgUrl} effectIndex={index} /> <ImageSlide src={imgUrl} effectIndex={index} />
</Sequence> </Sequence>
))} ))}
{/* TTS Audio */}
{audioUrl && <Html5Audio src={audioUrl} />} {audioUrl && <Html5Audio src={audioUrl} />}
{musicUrl && (
{/* Background music */} <Html5Audio
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />} src={musicUrl}
loop
{/* Caption overlay — CapCut style */} volume={(frame) =>
{renderCaption && ( (words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
<AbsoluteFill ? musicVolume * 0.35
style={{ : musicVolume) *
justifyContent: "flex-end", interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
alignItems: "center", extrapolateLeft: "clamp",
paddingBottom: 180, extrapolateRight: "clamp",
}} })
> }
{/* Semi-transparent dark background pill */} />
<div
style={{
backgroundColor: "rgba(0,0,0,0.55)",
borderRadius: 28,
paddingTop: 22,
paddingBottom: 22,
paddingLeft: 36,
paddingRight: 36,
maxWidth: "92%",
backdropFilter: "blur(4px)",
}}
>
<CaptionGroup
words={groupWords}
activeIdx={activeIdxInGroup}
groupFirstFrame={groupFirstFrame}
fps={fps}
/>
</div>
</AbsoluteFill>
)} )}
{/* Logo watermark — bottom right during content */} <Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
{logoUrl && frame < endingStart && ( {logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}> {hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
<Img
src={logoUrl}
style={{
width: 150,
height: 150,
objectFit: "contain",
opacity: 0.85,
borderRadius: 20,
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
}}
/>
</AbsoluteFill>
)}
{/* Ending slide — large centered logo + store name */}
{hasEnding && (
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
<AbsoluteFill
style={{
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: 48,
}}
>
{logoUrl && (
<Img
src={logoUrl}
style={{
width: 520,
height: 520,
objectFit: "contain",
opacity: endingProgress,
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
borderRadius: 32,
filter: "drop-shadow(0 4px 32px rgba(255,255,255,0.2))",
}}
/>
)}
{storeName && (
<div
style={{
color: "white",
fontSize: 90,
fontFamily: "'Arial Black', Impact, sans-serif",
fontWeight: 900,
textAlign: "center",
opacity: endingProgress,
letterSpacing: 3,
textShadow: "0 4px 24px rgba(255,255,255,0.2)",
transform: `translateY(${interpolate(endingProgress, [0, 1], [30, 0])}px)`,
}}
>
{storeName}
</div>
)}
</AbsoluteFill>
</Sequence>
)}
</AbsoluteFill> </AbsoluteFill>
); );
}; };
+106
View File
@@ -0,0 +1,106 @@
import React from "react";
import { AbsoluteFill, Freeze, Html5Audio, OffthreadVideo, Sequence, interpolate, useVideoConfig } from "remotion";
import { FADE_SECONDS, type CaptionStyle, type TimedWord } from "@shared/captions";
import { Captions } from "./components/Captions";
import { FadeOut, Outro, Watermark } from "./components/Branding";
export type ReelVideoProps = {
videoUrl: string;
/** Durée de la vidéo source (s) : au-delà, la dernière image est figée (aperçu). */
videoDuration: number;
totalDuration: number;
words: TimedWord[];
captionStyle: CaptionStyle;
logoUrl?: string;
/** Petit logo affiché pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */
showWatermark?: boolean;
storeName?: string;
logoStart?: number | null;
fadeStart?: number | null;
fadeDuration?: number | null;
/** Rendu final : piste son déjà mixée (voix, musique baissée sous la voix, niveau normalisé). */
mixedAudioUrl?: string;
/** Aperçu : pistes séparées, mixées dans le navigateur. */
voiceUrl?: string;
voiceDelay?: number;
musicUrl?: string;
musicVolume?: number;
};
/** Reel à partir d'une vidéo : image, sous-titres animés, logo et effet de fin. */
export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
const { fps } = useVideoConfig();
const {
videoUrl, videoDuration, totalDuration, words, captionStyle, logoUrl, showWatermark = true, storeName,
logoStart = null, fadeStart = null, fadeDuration = FADE_SECONDS, mixedAudioUrl, voiceUrl, voiceDelay = 2, musicUrl, musicVolume = 0.25,
} = props;
const videoFrames = Math.max(1, Math.floor(videoDuration * fps));
const totalFrames = Math.max(1, Math.round(totalDuration * fps));
// Le son d'origine est remplacé par la musique choisie ; sans musique, il reste
// en fond (baissé sous la voix). Au rendu final, il est déjà dans la piste mixée.
const keepVideoSound = !mixedAudioUrl && !musicUrl;
const video = (
<OffthreadVideo
src={videoUrl}
muted={!keepVideoSound}
volume={keepVideoSound && voiceUrl ? (frame) => previewMusicVolume(frame / fps, words, 1, fadeStart, fadeDuration ?? FADE_SECONDS) : 1}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
);
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
{totalFrames > videoFrames ? (
<>
<Sequence durationInFrames={videoFrames}>{video}</Sequence>
<Sequence from={videoFrames}>
<Freeze frame={videoFrames - 1}>{video}</Freeze>
</Sequence>
</>
) : (
video
)}
{mixedAudioUrl && <Html5Audio src={mixedAudioUrl} />}
{!mixedAudioUrl && voiceUrl && (
<Sequence from={Math.round(voiceDelay * fps)}>
<Html5Audio src={voiceUrl} />
</Sequence>
)}
{!mixedAudioUrl && musicUrl && (
<Html5Audio
src={musicUrl}
loop
volume={(frame) => previewMusicVolume(frame / fps, words, musicVolume, fadeStart, fadeDuration ?? FADE_SECONDS)}
/>
)}
<Captions words={words} style={captionStyle} hideAfter={logoStart} />
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={logoStart} />}
{logoStart != null && (logoUrl || storeName) && (
<Outro start={logoStart} logoUrl={logoUrl} storeName={storeName} />
)}
{fadeStart != null && <FadeOut start={fadeStart} duration={fadeDuration ?? FADE_SECONDS} />}
</AbsoluteFill>
);
};
/** Aperçu : le fond sonore (musique ou son d'origine) baisse pendant la parole et s'éteint avec le fondu final. */
function previewMusicVolume(
time: number,
words: TimedWord[],
base: number,
fadeStart: number | null,
fadeDuration: number,
): number {
const speaking = words.some((w) => time >= w.start - 0.2 && time <= w.end + 0.3);
const fade =
fadeStart == null
? 1
: interpolate(time, [fadeStart, fadeStart + fadeDuration], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (speaking ? base * 0.35 : base) * fade;
}
+34 -11
View File
@@ -1,22 +1,45 @@
import { Composition } from "remotion"; import { Composition, type CalculateMetadataFunction } from "remotion";
import { ImageComposition, ImageCompositionProps } from "./ImageComposition"; import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
/** La durée vient des propriétés : chaque Reel a la sienne. */
function byTotalDuration<P extends { totalDuration: number }>(): CalculateMetadataFunction<P> {
return ({ props }) => ({ durationInFrames: Math.max(1, Math.round(props.totalDuration * REEL_FPS)) });
}
export const RemotionRoot: React.FC = () => { export const RemotionRoot: React.FC = () => {
return ( return (
<> <>
<Composition
id="ReelVideo"
component={ReelVideo}
fps={REEL_FPS}
width={REEL_WIDTH}
height={REEL_HEIGHT}
durationInFrames={REEL_FPS * 10}
calculateMetadata={byTotalDuration<ReelVideoProps>()}
defaultProps={{
videoUrl: "",
videoDuration: 10,
totalDuration: 10,
words: [],
captionStyle: DEFAULT_CAPTION_STYLE,
} as ReelVideoProps}
/>
<Composition <Composition
id="ImageVideo" id="ImageVideo"
component={ImageComposition} component={ImageComposition}
durationInFrames={360} fps={REEL_FPS}
fps={30} width={REEL_WIDTH}
width={1080} height={REEL_HEIGHT}
height={1920} durationInFrames={REEL_FPS * 25}
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
defaultProps={{ defaultProps={{
images: [ images: ["https://placehold.co/1080x1920?text=Image+1"],
"https://placehold.co/1080x1920?text=Image+1", totalDuration: 25,
"https://placehold.co/1080x1920?text=Image+2", words: [],
"https://placehold.co/1080x1920?text=Image+3" captionStyle: DEFAULT_CAPTION_STYLE,
],
} as ImageCompositionProps} } as ImageCompositionProps}
/> />
</> </>
@@ -0,0 +1,92 @@
import React from "react";
import { AbsoluteFill, Img, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { CAPTION_FONT } from "../fonts";
/** Petit logo en haut à droite, hors des zones couvertes par l'interface des réseaux. */
export const Watermark: React.FC<{ logoUrl: string; until?: number | null }> = ({ logoUrl, until }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (until != null && frame / fps >= until) return null;
return (
<AbsoluteFill style={{ alignItems: "flex-end", justifyContent: "flex-start", padding: "150px 48px" }}>
<Img
src={logoUrl}
style={{
width: 150,
height: 150,
objectFit: "contain",
opacity: 0.9,
filter: "drop-shadow(0 2px 10px rgba(0,0,0,0.55))",
}}
/>
</AbsoluteFill>
);
};
/**
* Effet de fin : l'image s'assombrit, le logo apparaît en grand puis le nom
* du magasin glisse en dessous.
*/
export const Outro: React.FC<{ start: number; logoUrl?: string; storeName?: string; opaque?: boolean }> = ({
start,
logoUrl,
storeName,
opaque = false,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const local = frame - Math.round(start * fps);
if (local < 0) return null;
const enter = spring({ frame: local, fps, config: { damping: 18, stiffness: 90 }, durationInFrames: 24 });
const name = spring({ frame: local - 8, fps, config: { damping: 18, stiffness: 90 }, durationInFrames: 24 });
const dim = interpolate(local, [0, 12], [0, opaque ? 1 : 0.62], { extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", gap: 44 }}>
<AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${dim})` }} />
{logoUrl && (
<Img
src={logoUrl}
style={{
width: 480,
height: 480,
objectFit: "contain",
opacity: enter,
transform: `scale(${interpolate(enter, [0, 1], [0.7, 1])})`,
filter: "drop-shadow(0 6px 30px rgba(0,0,0,0.5))",
}}
/>
)}
{storeName && (
<div
style={{
fontFamily: CAPTION_FONT,
fontWeight: 800,
fontSize: 84,
color: "#FFFFFF",
textAlign: "center",
maxWidth: "86%",
lineHeight: 1.1,
opacity: name,
transform: `translateY(${interpolate(name, [0, 1], [30, 0])}px)`,
textShadow: "0 4px 24px rgba(0,0,0,0.6)",
}}
>
{storeName}
</div>
)}
</AbsoluteFill>
);
};
/** Fondu au noir final. */
export const FadeOut: React.FC<{ start: number; duration: number }> = ({ start, duration }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame / fps, [start, start + duration], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return opacity > 0 ? <AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${opacity})` }} /> : null;
};
+142
View File
@@ -0,0 +1,142 @@
import React, { useMemo } from "react";
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { groupLines, type CaptionStyle, type TimedWord } from "@shared/captions";
import { CAPTION_FONT } from "../fonts";
interface CaptionsProps {
words: TimedWord[];
style: CaptionStyle;
/** Les sous-titres disparaissent à partir de cet instant (effet de fin). */
hideAfter?: number | null;
}
/** Couleur de mise en avant du mot prononcé, par style. */
const ACCENT = { bold: "#FFE600", neon: "#7C3AED", minimal: "#FFFFFF" } as const;
/**
* Sous-titres mot à mot, synchronisés sur la voix.
* Une ligne de 1 à 3 mots à la fois, placée au-dessus des boutons qu'Instagram
* et TikTok superposent en bas de l'écran.
*/
export const Captions: React.FC<CaptionsProps> = ({ words, style, hideAfter }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const time = frame / fps;
const lines = useMemo(() => groupLines(words), [words]);
if (hideAfter != null && time >= hideAfter) return null;
// Ligne courante : du début de son premier mot au début de la suivante
const index = lines.findIndex((line, i) => {
const start = line[0].start;
const next = lines[i + 1]?.[0].start ?? line[line.length - 1].end + 0.4;
return time >= start && time < next;
});
if (index < 0) return null;
const line = lines[index];
const lineFrame = Math.round(line[0].start * fps);
const appear = spring({ frame: frame - lineFrame, fps, config: { damping: 14, stiffness: 180 }, durationInFrames: 8 });
return (
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 520 }}>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: style === "neon" ? "12px 12px" : style === "bold" ? "4px 38px" : "0 24px",
maxWidth: "84%",
transform: `scale(${0.85 + 0.15 * appear})`,
opacity: appear,
}}
>
{line.map((word, i) => {
const spoken = time >= word.start;
const active = spoken && (i === line.length - 1 || time < line[i + 1].start);
const pop = spring({
frame: frame - Math.round(word.start * fps),
fps,
config: { damping: 12, stiffness: 220 },
durationInFrames: 6,
});
return (
<Word key={`${index}-${i}`} text={word.text} style={style} active={active} spoken={spoken} pop={pop} />
);
})}
</div>
</AbsoluteFill>
);
};
const Word: React.FC<{ text: string; style: CaptionStyle; active: boolean; spoken: boolean; pop: number }> = ({
text,
style,
active,
spoken,
pop,
}) => {
const base: React.CSSProperties = {
fontFamily: CAPTION_FONT,
display: "inline-block",
lineHeight: 1.1,
textAlign: "center",
paintOrder: "stroke fill",
};
if (style === "bold") {
return (
<span
style={{
...base,
fontSize: 92,
fontWeight: 900,
textTransform: "uppercase",
color: active ? ACCENT.bold : "#FFFFFF",
WebkitTextStroke: "12px #000000",
textShadow: "0 8px 24px rgba(0,0,0,0.55)",
transform: `scale(${active ? 1 + 0.12 * pop : 1})`,
}}
>
{text}
</span>
);
}
if (style === "neon") {
return (
<span
style={{
...base,
fontSize: 80,
fontWeight: 800,
color: "#FFFFFF",
padding: "6px 18px",
borderRadius: 18,
backgroundColor: active ? ACCENT.neon : "rgba(0,0,0,0.45)",
boxShadow: active ? "0 0 36px rgba(124,58,237,0.75)" : "none",
transform: `scale(${active ? 1 + 0.08 * pop : 1})`,
}}
>
{text}
</span>
);
}
return (
<span
style={{
...base,
fontSize: 78,
fontWeight: 700,
color: ACCENT.minimal,
opacity: spoken ? 1 : 0.45,
textShadow: "0 4px 18px rgba(0,0,0,0.85), 0 0 2px rgba(0,0,0,0.9)",
transform: `translateY(${active ? -6 * pop : 0}px)`,
}}
>
{text}
</span>
);
};
+14
View File
@@ -0,0 +1,14 @@
/**
* Police des sous-titres et de l'effet de fin, embarquée dans le bundle : le
* rendu ne dépend d'aucun service externe (Google Fonts).
*
* Chargée en CSS (@font-face) plutôt qu'avec delayRender : un onglet de rendu
* rechargé en cours de route restait bloqué sur l'attente de la police. Les
* sous-titres n'apparaissent qu'après le délai de la voix, la police est
* chargée bien avant.
*/
import "@fontsource/montserrat/700.css";
import "@fontsource/montserrat/800.css";
import "@fontsource/montserrat/900.css";
export const CAPTION_FONT = "Montserrat, 'Arial Black', Arial, sans-serif";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

+20 -1
View File
@@ -29,6 +29,11 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
# Image construite sur place : nom figé pour ne pas dépendre du nom de la
# stack, et `pull_policy: build` pour que Docker la reconstruise au lieu
# d'aller la chercher sur Docker Hub (où elle n'existe pas).
image: socialflow-app:latest
pull_policy: build
container_name: socialflow-app container_name: socialflow-app
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -38,12 +43,20 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
GEMINI_API_KEY: ${GEMINI_API_KEY} GEMINI_API_KEY: ${GEMINI_API_KEY}
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me} SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
APP_URL: ${APP_URL:-http://localhost:5555} # URL publique (ex. https://socialflow.exemple.fr). Laissée vide, elle est
# déduite des en-têtes du reverse proxy — ne jamais forcer localhost ici,
# les URI de redirection OAuth (Facebook/TikTok) en dépendent.
APP_URL: ${APP_URL:-}
# URL interne Docker pour que ffmpeg-api puisse télécharger les fichiers audio # URL interne Docker pour que ffmpeg-api puisse télécharger les fichiers audio
INTERNAL_APP_URL: http://socialflow-app:5555 INTERNAL_APP_URL: http://socialflow-app:5555
# Configuration FFmpeg Service # Configuration FFmpeg Service
FFMPEG_API_URL: http://ffmpeg-api:8000 FFMPEG_API_URL: http://ffmpeg-api:8000
FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key} FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
# Rendu des Reels vidéo : remotion (sous-titres animés, identique à
# l'aperçu) ou ffmpeg (plus rapide, sous-titres classiques)
REEL_RENDERER: ${REEL_RENDERER:-remotion}
# Onglets Chromium en parallèle pour Remotion (défaut : moitié des cœurs)
RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-}
networks: networks:
- internal - internal
- nginx_default - nginx_default
@@ -63,10 +76,16 @@ services:
build: build:
context: ./ffmpeg-service context: ./ffmpeg-service
dockerfile: Dockerfile dockerfile: Dockerfile
args:
# Modèle Whisper (calage des sous-titres) intégré à l'image
WHISPER_MODEL: ${WHISPER_MODEL:-base}
image: socialflow-ffmpeg-api:latest
pull_policy: build
container_name: socialflow-ffmpeg container_name: socialflow-ffmpeg
restart: unless-stopped restart: unless-stopped
environment: environment:
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key} API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
networks: networks:
- internal - internal
expose: expose:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+4
View File
@@ -0,0 +1,4 @@
__pycache__
.pytest_cache
.ruff_cache
tests
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
.pytest_cache/
.ruff_cache/
+25 -22
View File
@@ -1,38 +1,41 @@
FROM python:3.11-slim FROM python:3.11-slim
# Install system dependencies including FFmpeg with vidstab support and fonts # Polices des sous-titres (Montserrat) et des emojis, installées au build
# We need to build/install libvidstab and compile ffmpeg with it, OR use static build # plutôt que téléchargées à chaque démarrage.
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
wget \ wget xz-utils ca-certificates fontconfig \
xz-utils \ fonts-montserrat fonts-dejavu-core fonts-noto-color-emoji \
fonts-dejavu \
fontconfig \
build-essential \
python3-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Download static FFmpeg build with all filters including vidstab # FFmpeg statique (vidstab, zimg pour le tone-mapping HDR, libass).
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ # Version épinglée : une mise à jour ne change plus le rendu à notre insu.
&& tar xf ffmpeg-release-amd64-static.tar.xz \ ARG FFMPEG_VERSION=7.0.2
&& mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \ RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz \
&& mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \ || wget -q https://johnvansickle.com/ffmpeg/old-releases/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz \
&& tar xf ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz \
&& mv ffmpeg-*-amd64-static/ffmpeg ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \
&& rm -rf ffmpeg-* \ && rm -rf ffmpeg-* \
&& fc-cache -fv && fc-cache -f
WORKDIR /app WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Modèle Whisper (calage des sous-titres) téléchargé au build : aucun accès
# réseau nécessaire au premier rendu.
ARG WHISPER_MODEL=base
ENV WHISPER_MODEL=${WHISPER_MODEL} \
HF_HOME=/opt/hf-cache \
HOME=/tmp \
XDG_CACHE_HOME=/tmp/.cache
RUN python -c "from faster_whisper import WhisperModel; WhisperModel('${WHISPER_MODEL}', device='cpu', compute_type='int8')"
# Modèle déjà présent : plus aucune requête vers Hugging Face au démarrage
ENV HF_HUB_OFFLINE=1
COPY main.py . COPY main.py .
COPY app ./app
# Create API Key env var (should be overridden in docker-compose)
ENV API_KEY=default-dev-key
# Expose port
EXPOSE 8000 EXPOSE 8000
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""Service audio et vidéo des Reels SocialFlow."""
+151
View File
@@ -0,0 +1,151 @@
"""Calage mot à mot du texte affiché sur la voix.
Le texte affiché (ponctuation, majuscules) diffère légèrement des mots
reconnus ou annoncés par le moteur de voix : on apparie les deux séquences
(difflib) et on interpole les mots sans correspondance. Remplace ffsubsync,
qui ne faisait qu'un décalage global du texte réparti uniformément.
"""
import asyncio
import difflib
import logging
import re
import unicodedata
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from . import config
log = logging.getLogger(__name__)
@dataclass
class Word:
text: str
start: float
end: float
def to_dict(self) -> dict:
return {"text": self.text, "start": round(self.start, 3), "end": round(self.end, 3)}
def normalize(token: str) -> str:
"""Forme comparable d'un mot : minuscules, sans accents ni ponctuation."""
decomposed = unicodedata.normalize("NFKD", token.lower())
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
return re.sub(r"[^a-z0-9]", "", stripped)
def display_tokens(text: str) -> list[str]:
"""Mots à afficher ; la ponctuation isolée est rattachée au mot précédent."""
tokens: list[str] = []
for raw in text.split():
if tokens and not normalize(raw):
tokens[-1] += raw if raw in ",.!?;:…" else f" {raw}"
else:
tokens.append(raw)
return tokens
def align_words(display_text: str, spoken: list[Word], total_duration: float | None = None) -> list[Word]:
"""Attribue à chaque mot affiché un début et une fin tirés des mots prononcés."""
tokens = display_tokens(display_text)
if not tokens:
return []
if not spoken:
return _spread(tokens, 0.0, total_duration or len(tokens) * 0.4)
timed: list[Word | None] = [None] * len(tokens)
matcher = difflib.SequenceMatcher(
a=[normalize(t) for t in tokens], b=[normalize(w.text) for w in spoken], autojunk=False
)
for block in matcher.get_matching_blocks():
for k in range(block.size):
src = spoken[block.b + k]
timed[block.a + k] = Word(tokens[block.a + k], src.start, src.end)
# Mots non appariés : répartis dans le trou entre leurs voisins datés
end_of_speech = max(spoken[-1].end, total_duration or 0.0)
i = 0
while i < len(tokens):
if timed[i] is not None:
i += 1
continue
j = i
while j < len(tokens) and timed[j] is None:
j += 1
gap_start = timed[i - 1].end if i > 0 else 0.0
gap_end = timed[j].start if j < len(tokens) else end_of_speech
if gap_end - gap_start < 0.05 * (j - i):
gap_end = gap_start + 0.25 * (j - i)
timed[i:j] = _spread(tokens[i:j], gap_start, gap_end)
i = j
words = [w for w in timed if w is not None]
# Monotonie stricte : un mot ne commence jamais avant la fin du précédent
for prev, cur in zip(words, words[1:]):
cur.start = max(cur.start, prev.start + 0.01)
cur.end = max(cur.end, cur.start + 0.05)
return words
def spread_words(text: str, start: float, end: float) -> list[Word]:
"""Mots d'un texte répartis sur un intervalle (sous-titre SRT sans voix)."""
tokens = display_tokens(text)
return _spread(tokens, start, end) if tokens and end > start else []
def _spread(tokens: list[str], start: float, end: float) -> list[Word]:
"""Répartit des mots sur un intervalle au prorata de leur longueur."""
weights = [max(1, len(normalize(t))) for t in tokens]
total = sum(weights)
words, cursor = [], start
for token, weight in zip(tokens, weights):
duration = (end - start) * weight / total
words.append(Word(token, cursor, cursor + duration))
cursor += duration
return words
@lru_cache(maxsize=1)
def _model():
from faster_whisper import WhisperModel
log.info("Chargement du modèle Whisper %s", config.WHISPER_MODEL)
return WhisperModel(config.WHISPER_MODEL, device="cpu", compute_type=config.WHISPER_COMPUTE_TYPE)
# Silence ajouté avant la voix pour Whisper : sur une voix qui démarre
# aussitôt, il laissait souvent passer le premier mot (« Alerte »).
LEAD_SILENCE = 0.5
def _transcribe_sync(audio: Path) -> list[Word]:
import numpy as np
from faster_whisper.audio import decode_audio
samples = decode_audio(str(audio), sampling_rate=16000)
padded = np.concatenate([np.zeros(int(16000 * LEAD_SILENCE), dtype=samples.dtype), samples])
# Pas de texte attendu en amorce (initial_prompt) ni d'enchaînement sur le
# segment précédent : les deux font halluciner Whisper, qui répète la phrase
# et masque une fin tronquée. On veut entendre ce qui est réellement dit.
segments, _ = _model().transcribe(
padded,
language="fr",
word_timestamps=True,
condition_on_previous_text=False,
vad_filter=False,
beam_size=5,
)
return [
Word(w.word.strip(), max(0.0, w.start - LEAD_SILENCE), max(0.0, w.end - LEAD_SILENCE))
for segment in segments
for w in (segment.words or [])
if w.word.strip()
]
async def transcribe(audio: Path) -> list[Word]:
"""Mots prononcés et leurs instants (Whisper, exécuté hors de la boucle async)."""
return await asyncio.to_thread(_transcribe_sync, audio)
+429
View File
@@ -0,0 +1,429 @@
"""API HTTP du service : voix, rendu des Reels et récupération des fichiers."""
import asyncio
import base64
import contextlib
import logging
import shutil
import time
from pathlib import Path
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from . import align, config, jobs, proc, render, srt_voice, subtitles, tts, voices
from .audio import encode_preview
from .text import clean_text
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("reels")
if not config.API_KEY:
raise RuntimeError("API_KEY doit être défini : le service refuse de démarrer sans clé.")
# Un seul encodage à la fois : ils saturent le CPU, les suivants attendent
render_slot = asyncio.Semaphore(1)
@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI):
removed = jobs.purge_expired()
log.info("Service prêt (%d dossier(s) expiré(s) purgé(s))", removed)
async def purge_loop():
while True:
await asyncio.sleep(600)
jobs.purge_expired()
task = asyncio.create_task(purge_loop())
yield
task.cancel()
app = FastAPI(title="SocialFlow Reels", lifespan=lifespan)
def require_key(x_api_key: str | None = Header(None)) -> None:
if x_api_key != config.API_KEY:
raise HTTPException(status_code=401, detail="Invalid API Key")
class TtsRequest(BaseModel):
text: str
tts_voice: str | None = None
tts_engine: str | None = "gemini"
tts_style: str | None = None
gemini_api_key: str | None = None
class SrtCue(BaseModel):
start: float
end: float
text: str
class ReelRequest(BaseModel):
video_base64: str | None = None
video_url: str | None = None
text: str | None = None
# Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant
srt_cues: list[SrtCue] | None = None
music_url: str | None = None
watermark_url: str | None = None
store_name: str | None = None
font_size: int = 64
music_volume: float = 0.25
tts_enabled: bool = False
tts_voice: str | None = None
tts_engine: str | None = "gemini"
tts_style: str | None = None
gemini_api_key: str | None = None
draw_text: bool = True
stabilize: bool = False
enable_ending_effect: bool = True
# Préparation Remotion : le logo est composé par Remotion, mais sa présence
# allonge la vidéo pour laisser place à l'effet de fin.
has_logo: bool = False
# Petit logo en coin pendant la vidéo (le grand logo de fin dépend de enable_ending_effect)
show_watermark: bool = True
# Champs d'anciennes versions, acceptés et ignorés
music_id: str | None = None
word_duration: float | None = None
@app.get("/health", dependencies=[Depends(require_key)])
async def health():
return {"status": "ok", "version": 2}
@app.get("/voices", dependencies=[Depends(require_key)])
async def list_voices():
return voices.catalog()
@app.post("/preview-tts", dependencies=[Depends(require_key)])
async def preview_tts(request: TtsRequest):
text = clean_text(request.text)
if not text:
raise HTTPException(status_code=400, detail="Texte vide après nettoyage (emojis et hashtags retirés)")
job_id, workdir = jobs.new_job()
try:
track = await _synthesize(text, request.text, request, workdir)
preview = workdir / "preview.mp3"
await encode_preview(track.path, preview)
return {
"success": True,
"audio_base64": base64.b64encode(preview.read_bytes()).decode(),
"duration": track.duration,
"words": [w.to_dict() for w in track.words],
"engine": track.engine,
"voice": track.voice,
"warnings": track.warnings,
}
finally:
jobs.remove_job(job_id)
@app.post("/process-reel", dependencies=[Depends(require_key)])
async def process_reel(request: ReelRequest):
"""Rendu complet par FFmpeg ; le MP4 se récupère via GET /files/{job_id}/output.mp4."""
return await _guarded(_process, request)
@app.post("/prepare-reel", dependencies=[Depends(require_key)])
async def prepare_reel(request: ReelRequest):
"""Prépare un rendu Remotion : image recadrée à la durée finale, piste son
finale et minutage des mots. Rien n'est incrusté dans l'image."""
return await _guarded(_prepare, request)
async def _guarded(handler, request: ReelRequest) -> dict:
job_id, workdir = jobs.new_job()
started = time.monotonic()
try:
async with render_slot:
return await handler(request, job_id, workdir, started)
except HTTPException:
jobs.remove_job(job_id)
raise
except proc.CommandError as error:
jobs.remove_job(job_id)
log.error("Rendu %s en échec : %s", job_id, error)
raise HTTPException(status_code=500, detail=str(error)) from error
except Exception as error:
jobs.remove_job(job_id)
log.exception("Rendu %s en échec", job_id)
raise HTTPException(status_code=500, detail=f"Erreur de rendu : {error}") from error
@app.get("/files/{job_id}/{name}", dependencies=[Depends(require_key)])
async def get_file(job_id: str, name: str):
path = jobs.job_file(job_id, name)
if not path:
raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré")
media_type = {"mp4": "video/mp4", "wav": "audio/wav"}.get(name.rsplit(".", 1)[-1])
return FileResponse(path, media_type=media_type)
@app.delete("/jobs/{job_id}", dependencies=[Depends(require_key)])
async def delete_job(job_id: str):
jobs.remove_job(job_id)
return {"success": True}
# --- Orchestration --------------------------------------------------------
async def _synthesize(text: str, display_source: str | None, request, workdir: Path) -> tts.VoiceTrack:
try:
return await tts.synthesize(
text=text,
display_text=clean_text(display_source) or text,
engine=request.tts_engine,
voice=request.tts_voice,
style=request.tts_style,
gemini_api_key=request.gemini_api_key,
workdir=workdir,
)
except Exception as error:
log.exception("Voix impossible à générer")
raise HTTPException(status_code=502, detail=f"La voix n'a pas pu être générée : {error}") from error
def _srt_cues(request: ReelRequest) -> list[srt_voice.Cue]:
"""Sous-titres lisibles du SRT, dans l'ordre (vide sans SRT)."""
cues = [
srt_voice.Cue(c.start, c.end, c.text)
for c in request.srt_cues or []
if c.end > c.start and clean_text(c.text)
]
return sorted(cues, key=lambda c: c.start)
async def _synthesize_srt(cues: list[srt_voice.Cue], request, workdir: Path) -> tts.VoiceTrack:
try:
return await srt_voice.synthesize_cues(
cues=cues,
engine=request.tts_engine,
voice=request.tts_voice,
style=request.tts_style,
gemini_api_key=request.gemini_api_key,
workdir=workdir,
)
except Exception as error:
log.exception("Voix SRT impossible à générer")
raise HTTPException(status_code=502, detail=f"La voix n'a pas pu être générée : {error}") from error
async def _download(url: str, target: Path, what: str, required: bool) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=15), follow_redirects=True) as client:
async with client.stream("GET", url, headers={"User-Agent": "Mozilla/5.0"}) as response:
response.raise_for_status()
with target.open("wb") as out:
async for chunk in response.aiter_bytes(1 << 20):
out.write(chunk)
return True
except Exception as error:
if required:
detail = f"Téléchargement de {what} impossible : {error}"
raise HTTPException(status_code=400, detail=detail) from error
log.warning("Téléchargement de %s impossible, on continue sans : %s", what, error)
return False
class Stopwatch:
def __init__(self, started: float):
self.started = started
self.last = time.monotonic()
self.stats: dict[str, float] = {}
def lap(self, name: str) -> None:
now = time.monotonic()
self.stats[name] = round(now - self.last, 2)
self.last = now
def summary(self) -> dict[str, float]:
return {**self.stats, "total": round(time.monotonic() - self.started, 2)}
async def _gather(request: ReelRequest, workdir: Path, clock: Stopwatch, *, fetch_logo: bool):
"""Téléchargements, analyse, voix et stabilisation : communs aux deux modes."""
video = workdir / "input.mp4"
if request.video_base64:
video.write_bytes(base64.b64decode(request.video_base64))
elif request.video_url:
await _download(request.video_url, video, "la vidéo", required=True)
else:
raise HTTPException(status_code=400, detail="Aucune vidéo fournie")
music = workdir / "music.audio"
has_music = bool(request.music_url) and await _download(
request.music_url, music, "la musique", required=False
)
watermark = workdir / "watermark.png"
has_watermark = (
fetch_logo
and bool(request.watermark_url)
and await _download(request.watermark_url, watermark, "le logo", required=False)
)
info = await proc.probe(video)
if info.duration <= 0:
raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)")
clock.lap("download")
track = None
cues = _srt_cues(request)
spoken_text = clean_text(request.text)
if request.tts_enabled and cues:
track = await _synthesize_srt(cues, request, workdir)
elif request.tts_enabled and spoken_text:
track = await _synthesize(spoken_text, request.text, request, workdir)
clock.lap("tts")
plan = render.RenderPlan(
video=video,
video_duration=info.duration,
output=workdir / "output.mp4",
is_hdr=info.is_hdr,
music=music if has_music else None,
music_volume=request.music_volume,
voice=track.path if track else None,
voice_duration=track.duration if track else 0.0,
# Avec un SRT, la voix est déjà posée aux instants du fichier
voice_delay=0.0 if cues else config.VOICE_DELAY,
watermark=watermark if has_watermark else None,
outro_expected=not fetch_logo and request.has_logo,
ending_effect=request.enable_ending_effect,
show_watermark=request.show_watermark,
keep_original_audio=info.has_audio,
)
if request.stabilize:
transforms = workdir / "transforms.trf"
try:
await proc.run(render.stabilize_detect_command(video, transforms), timeout=600)
plan.stabilize_transforms = transforms
except proc.CommandError as error:
log.warning("Stabilisation ignorée : %s", error)
clock.lap("stabilize")
return plan, track, info
async def _caption_words(request: ReelRequest, plan: render.RenderPlan, track, info) -> list[align.Word]:
"""Mots à afficher, en secondes depuis le début de la vidéo."""
cues = _srt_cues(request)
if cues:
if not request.draw_text:
return []
if track:
return track.words
# Sans voix : chaque sous-titre s'affiche sur son propre intervalle
return [w for c in cues for w in align.spread_words(clean_text(c.text), c.start, c.end)]
display = clean_text(request.text)
if not request.draw_text or not display:
return []
if track:
return [align.Word(w.text, w.start + plan.voice_delay, w.end + plan.voice_delay) for w in track.words]
return await _caption_words_without_voice(display, plan.video, info, plan)
def _keep_only(workdir: Path, keep: set[Path]) -> None:
"""Seuls les fichiers à télécharger restent jusqu'à la récupération."""
for entry in workdir.iterdir():
if entry in keep:
continue
if entry.is_dir():
shutil.rmtree(entry, ignore_errors=True) # voix SRT : un dossier par sous-titre
else:
entry.unlink(missing_ok=True)
def _voice_info(track) -> dict:
return {
"tts_engine": track.engine if track else None,
"tts_voice": track.voice if track else None,
"warnings": track.warnings if track else [],
}
async def _process(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
clock = Stopwatch(started)
plan, track, info = await _gather(request, workdir, clock, fetch_logo=True)
words = await _caption_words(request, plan, track, info)
if words:
captions = workdir / "captions.ass"
font_size = max(48, round(request.font_size * 1.4))
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
plan.captions = captions
if request.store_name and plan.has_outro and plan.watermark:
outro = workdir / "outro.ass"
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
plan.outro = outro
clock.lap("subtitles")
await proc.run(render.build_command(plan), timeout=1200)
clock.lap("encode")
duration = (await proc.probe(plan.output)).duration
log.info("Rendu %s terminé : %.1f s de vidéo, étapes %s", job_id, duration, clock.summary())
_keep_only(workdir, {plan.output})
return {
"success": True,
"job_id": job_id,
"output_path": f"/files/{job_id}/output.mp4",
"duration": duration,
**_voice_info(track),
"processing_stats": clock.summary(),
}
async def _prepare(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
clock = Stopwatch(started)
plan, track, info = await _gather(request, workdir, clock, fetch_logo=False)
words = await _caption_words(request, plan, track, info)
clock.lap("subtitles")
video_out = workdir / "video.mp4"
audio_out = workdir / "audio.wav"
await proc.run(render.build_prepared_video_command(plan, video_out), timeout=1200)
clock.lap("video")
mix = render.build_audio_mix_command(plan, audio_out)
if mix:
await proc.run(mix, timeout=600)
clock.lap("audio")
log.info("Préparation %s terminée : %.1f s, étapes %s", job_id, plan.total_duration, clock.summary())
_keep_only(workdir, {video_out, audio_out})
return {
"success": True,
"job_id": job_id,
"video_path": f"/files/{job_id}/video.mp4",
"audio_path": f"/files/{job_id}/audio.wav" if mix else None,
"total_duration": plan.total_duration,
"video_duration": info.duration,
"logo_start": plan.logo_start if plan.has_outro else None,
"fade_start": plan.fade_start if plan.ending_effect else None,
"fade_duration": plan.fade_duration if plan.ending_effect else None,
"words": [w.to_dict() for w in words],
**_voice_info(track),
"processing_stats": clock.summary(),
}
async def _caption_words_without_voice(display: str, video: Path, info, plan: render.RenderPlan):
"""Sans voix de synthèse : calage sur la parole de la vidéo si elle en contient,
sinon texte réparti sur la durée (hors effet de fin)."""
if info.has_audio:
try:
spoken = await align.transcribe(video)
if len(spoken) >= max(2, len(display.split()) // 3):
return align.align_words(display, spoken, info.duration)
except Exception as error: # noqa: BLE001 — on retombe sur la répartition
log.warning("Transcription de la vidéo impossible : %s", error)
end = plan.logo_start if plan.has_outro else plan.total_duration
return align.align_words(display, [], max(1.0, end - 0.5))
+94
View File
@@ -0,0 +1,94 @@
"""Traitement de la voix : filtrage, compression et niveau sonore constant."""
import json
from pathlib import Path
from . import proc
# Nettoyage de la voix : coupe les basses inutiles, retire le souffle de fond
# (réduction douce, sans gate qui hacherait les fins de phrases) et arrondit
# les pics. Pas de gain ajouté : c'est la normalisation qui règle le niveau.
CLEAN_CHAIN = (
"highpass=f=80,"
"afftdn=nr=10:nf=-50:tn=1,"
"acompressor=threshold=0.125:ratio=2.5:attack=5:release=150:makeup=1"
)
# Niveau cible de la voix : intelligible par-dessus la musique sans saturer
TARGET = "I=-16:TP=-1.5:LRA=11"
# Fondu de 5 ms aux extrémités : pas de clic au début ni à la fin
EDGE_FADE = 0.005
async def process_voice(source: Path, target: Path) -> None:
"""Produit un WAV 48 kHz mono prêt à mixer.
Normalisation en deux passes, en mode linéaire : un seul gain appliqué à
toute la voix. Le mode dynamique (une passe) relevait les passages calmes
et amplifiait souffles et bruits entre les phrases."""
measure = await proc.run(
[
"ffmpeg", "-hide_banner", "-nostats", "-i", str(source),
"-af", f"{CLEAN_CHAIN},loudnorm={TARGET}:print_format=json", "-f", "null", "-",
],
timeout=120,
stderr_output=True,
) # fmt: skip
stats = _loudnorm_stats(measure)
if stats:
loudnorm = (
f"loudnorm={TARGET}:linear=true:"
f"measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:"
f"measured_LRA={stats['input_lra']}:measured_thresh={stats['input_thresh']}:"
f"offset={stats['target_offset']}"
)
else:
loudnorm = f"loudnorm={TARGET}"
duration = (await proc.probe(source)).duration
fades = f"afade=t=in:d={EDGE_FADE},afade=t=out:st={max(0.0, duration - EDGE_FADE):.3f}:d={EDGE_FADE}"
await proc.run(
[
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
"-af", f"{CLEAN_CHAIN},{loudnorm},aresample=48000,{fades}",
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(target),
],
timeout=120,
) # fmt: skip
def _loudnorm_stats(ffmpeg_log: str) -> dict[str, str] | None:
"""Mesures de la première passe (bloc JSON écrit par loudnorm)."""
start, end = ffmpeg_log.rfind("{"), ffmpeg_log.rfind("}")
if start < 0 or end <= start:
return None
try:
stats = json.loads(ffmpeg_log[start : end + 1])
except json.JSONDecodeError:
return None
keys = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
if not all(k in stats for k in keys):
return None
# Voix quasi muette : les mesures valent -inf, la normalisation simple suffit
if any("inf" in str(stats[k]) for k in keys):
return None
return {k: stats[k] for k in keys}
async def encode_preview(source: Path, target: Path) -> None:
"""MP3 de bonne qualité pour l'écoute dans le navigateur."""
await proc.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(source),
"-c:a",
"libmp3lame",
"-b:a",
"160k",
str(target),
],
timeout=120,
)
+38
View File
@@ -0,0 +1,38 @@
"""Configuration lue dans l'environnement."""
import os
from pathlib import Path
# Pas de clé par défaut : un service exposé avec une clé connue de tous
# n'est pas protégé. Le démarrage échoue si elle manque.
API_KEY = os.environ.get("API_KEY", "")
TEMP_DIR = Path(os.environ.get("TEMP_DIR", "/tmp/ffmpeg_processing"))
# Durée de conservation des fichiers produits (téléchargés par l'application
# puis supprimés ; la purge ne rattrape que les oublis).
FILE_TTL_SECONDS = int(os.environ.get("FILE_TTL_SECONDS", "3600"))
GEMINI_TTS_MODEL = os.environ.get("GEMINI_TTS_MODEL", "gemini-2.5-flash-preview-tts")
# Gemini tire sa lecture au hasard : une température basse et une graine fixe
# donnent une intonation stable d'une génération à l'autre.
GEMINI_TTS_TEMPERATURE = float(os.environ.get("GEMINI_TTS_TEMPERATURE", "0.3"))
GEMINI_TTS_SEED = int(os.environ.get("GEMINI_TTS_SEED", "20260928"))
# Générations tentées quand la lecture ne correspond pas au texte
GEMINI_TTS_ATTEMPTS = int(os.environ.get("GEMINI_TTS_ATTEMPTS", "3"))
# Modèle Whisper utilisé pour caler les sous-titres sur la voix.
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")
WHISPER_COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "int8")
# Proxy sortant éventuel pour Edge TTS (aiohttp ne lit pas HTTPS_PROXY seul).
OUTBOUND_PROXY = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or None
# Rendu
WIDTH = 1080
HEIGHT = 1920
FPS = 30
# La voix démarre après ce délai (le temps de capter l'attention).
VOICE_DELAY = 2.0
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
+44
View File
@@ -0,0 +1,44 @@
"""Dossiers de travail des rendus et purge des fichiers oubliés."""
import re
import shutil
import time
import uuid
from pathlib import Path
from . import config
_SAFE_ID = re.compile(r"^[0-9a-f-]{36}$")
_SAFE_NAME = re.compile(r"^[\w.-]+$")
def new_job() -> tuple[str, Path]:
job_id = str(uuid.uuid4())
workdir = config.TEMP_DIR / job_id
workdir.mkdir(parents=True)
return job_id, workdir
def job_file(job_id: str, name: str) -> Path | None:
"""Fichier d'un job, ou None si l'identifiant ou le nom sont suspects."""
if not _SAFE_ID.match(job_id) or not _SAFE_NAME.match(name):
return None
path = config.TEMP_DIR / job_id / name
return path if path.is_file() else None
def remove_job(job_id: str) -> None:
if _SAFE_ID.match(job_id):
shutil.rmtree(config.TEMP_DIR / job_id, ignore_errors=True)
def purge_expired() -> int:
"""Supprime les dossiers plus vieux que FILE_TTL_SECONDS."""
config.TEMP_DIR.mkdir(parents=True, exist_ok=True)
limit = time.time() - config.FILE_TTL_SECONDS
removed = 0
for entry in config.TEMP_DIR.iterdir():
if entry.is_dir() and entry.stat().st_mtime < limit:
shutil.rmtree(entry, ignore_errors=True)
removed += 1
return removed
+79
View File
@@ -0,0 +1,79 @@
"""Exécution asynchrone de FFmpeg/ffprobe : le serveur reste disponible
pendant un encodage (l'ancien subprocess.run bloquait toute l'API)."""
import asyncio
import json
import logging
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
class CommandError(RuntimeError):
"""Commande externe en échec, avec la fin de sa sortie d'erreur."""
def __init__(self, cmd: list[str], returncode: int, stderr: str):
tail = "\n".join(stderr.strip().splitlines()[-15:])
super().__init__(f"{cmd[0]} a échoué (code {returncode}) :\n{tail}")
self.returncode = returncode
self.stderr = stderr
async def run(cmd: list[str], timeout: float = 900, *, stderr_output: bool = False) -> str:
"""Lance une commande et renvoie sa sortie standard (ou d'erreur, où FFmpeg
écrit les mesures de ses filtres d'analyse)."""
log.debug("exec: %s", " ".join(cmd))
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
except TimeoutError as error:
process.kill()
await process.wait()
raise CommandError(cmd, -1, f"délai de {timeout:.0f} s dépassé") from error
if process.returncode != 0:
raise CommandError(cmd, process.returncode, stderr.decode(errors="replace"))
return (stderr if stderr_output else stdout).decode(errors="replace")
@dataclass
class MediaInfo:
duration: float
has_audio: bool
width: int = 0
height: int = 0
color_transfer: str = ""
@property
def is_hdr(self) -> bool:
# HLG (iPhone) ou PQ (HDR10)
return self.color_transfer in ("arib-std-b67", "smpte2084")
async def probe(path: Path) -> MediaInfo:
out = await run(
[
"ffprobe",
"-v",
"error",
"-print_format",
"json",
"-show_format",
"-show_streams",
str(path),
],
timeout=60,
)
data = json.loads(out)
streams = data.get("streams", [])
video = next((s for s in streams if s.get("codec_type") == "video"), {})
duration = float(data.get("format", {}).get("duration") or video.get("duration") or 0)
return MediaInfo(
duration=duration,
has_audio=any(s.get("codec_type") == "audio" for s in streams),
width=int(video.get("width") or 0),
height=int(video.get("height") or 0),
color_transfer=video.get("color_transfer") or "",
)
+243
View File
@@ -0,0 +1,243 @@
"""Contrôles de la voix générée : texte réellement lu et silences.
Gemini lit parfois mal : mots sautés, fin tronquée, consigne de style lue à
voix haute. On compare donc ce que Whisper entend au texte attendu, et on
découpe la voix d'après son niveau sonore réel (mesuré toutes les 10 ms)
plutôt qu'aux bornes des mots estimées par Whisper, trop imprécises pour ne
pas rogner une syllabe.
"""
import asyncio
import difflib
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from . import proc
from .align import Word, normalize
MIN_COVERAGE = 0.92
@dataclass
class ReadingCheck:
coverage: float # part des mots attendus effectivement entendus
extra: float # mots entendus en trop (consigne lue, ajouts), rapportés au texte
ending_ok: bool # la fin du texte est bien lue
missing: list[str] = field(default_factory=list) # mots attendus non entendus (diagnostic)
@property
def acceptable(self) -> bool:
# Comparaison lettre à lettre : une lecture complète atteint ~97 %
# (petites erreurs de Whisper) ; sous 92 %, des mots ont été sautés.
return self.coverage >= MIN_COVERAGE and self.extra <= 0.25 and self.ending_ok
def describe(self) -> str:
parts = [f"{self.coverage:.0%} des mots lus"]
if not self.ending_ok:
parts.append("fin tronquée")
if self.extra > 0.25:
parts.append("mots ajoutés")
if self.missing and self.coverage < MIN_COVERAGE:
parts.append("non entendus : " + " ".join(self.missing[:12]))
return ", ".join(parts)
def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
"""Compare le texte attendu à ce qui a été entendu (Whisper), lettre à
lettre : les petites erreurs de reconnaissance (« petit » pour « petits »,
« week -end » découpé) ne comptent pas comme des mots manquants, alors
qu'un mot sauté, une fin tronquée ou une phrase ajoutée se voient."""
expected = "".join(comparable_tokens(expected_text.split()))
heard = "".join(comparable_tokens([w.text for w in spoken]))
if not expected:
return ReadingCheck(1.0, 0.0, True)
if not heard:
return ReadingCheck(0.0, 0.0, False)
matcher = difflib.SequenceMatcher(a=expected, b=heard, autojunk=False)
blocks = [b for b in matcher.get_matching_blocks() if b.size]
matched = sum(b.size for b in blocks)
# Fin lue : une bonne partie des dernières lettres du texte est reconnue
tail_start = max(0, len(expected) - 8)
tail_matched = sum(max(0, min(b.a + b.size, len(expected)) - max(b.a, tail_start)) for b in blocks)
return ReadingCheck(
coverage=matched / len(expected),
extra=max(0, len(heard) - matched) / len(expected),
ending_ok=tail_matched >= min(4, len(expected) - tail_start),
missing=_missing_words(expected_text, spoken),
)
# Unités écrites de plusieurs façons par Whisper (« 40 cm » pour « 40 centimètres »)
UNITS = {
"e", "eur", "euro", "euros", "centime", "centimes", "cm", "centimetre", "centimetres",
"mm", "millimetre", "millimetres", "m", "metre", "metres", "km", "kilometre", "kilometres",
"g", "gramme", "grammes", "kg", "kilo", "kilos", "kilogramme", "kilogrammes",
"l", "litre", "litres", "cl", "centilitre", "centilitres", "ml", "millilitre", "millilitres",
"pourcent", "pourcents", "x", "fois",
} # fmt: skip
def comparable_tokens(words: list[str]) -> list[str]:
"""Mots normalisés, sans les nombres ni l'unité qui les suit : Whisper écrit
un prix ou une mesure à sa façon (« 11 € 99 », « 11,99 euros », « 40 cm »),
ce qui passait pour des mots sautés et relançait la génération."""
tokens: list[str] = []
after_number = False
for word in words:
token = normalize(word)
if not token:
continue
if any(c.isdigit() for c in token):
after_number = True
continue
if after_number and token in UNITS:
continue
after_number = False
tokens.append(token)
return tokens
def _missing_words(expected_text: str, spoken: list[Word]) -> list[str]:
"""Mots attendus sans équivalent proche dans ce qui a été entendu."""
heard = set(comparable_tokens([w.text for w in spoken]))
kept = set(comparable_tokens(expected_text.split()))
missing = []
for word in expected_text.split():
token = normalize(word)
if token in kept and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
missing.append(word.strip(".,!?;:…"))
return missing
# Enveloppe sonore : niveau de la voix toutes les 10 ms
FRAME = 0.01
# Est « audible » ce qui dépasse le niveau de crête moins 38 dB : un seuil
# relatif respecte les fins de phrases douces (« s » final, voix qui retombe),
# qu'un seuil absolu prenait pour du silence.
ACTIVE_RANGE_DB = 38.0
# Marges gardées autour de la parole
LEAD_PAD = 0.08
TAIL_PAD = 0.30
# Pause entre deux phrases : au moins 80 ms de silence, cherchée jusqu'à 1,2 s
# de la borne estimée par Whisper
MIN_PAUSE = 0.08
PAUSE_SEARCH = 1.2
@dataclass
class Envelope:
db: np.ndarray # niveau (dBFS) de chaque tranche de 10 ms
@property
def duration(self) -> float:
return len(self.db) * FRAME
@property
def threshold(self) -> float:
if not len(self.db):
return 0.0
return float(np.percentile(self.db, 95)) - ACTIVE_RANGE_DB
def _frames(self, start: float, end: float) -> tuple[int, int]:
first = max(0, int(start / FRAME))
last = min(len(self.db), int(np.ceil(end / FRAME)))
return first, max(first, last)
def quietest(self, start: float, end: float) -> float:
"""Milieu de la zone la plus calme d'un intervalle : la coupe tombe au
cœur de la pause, loin des deux phrases qu'elle sépare."""
first, last = self._frames(start, end)
if last - first < 1:
return (start + end) / 2
smooth = np.convolve(self.db, np.ones(5) / 5, mode="same")[first:last]
quiet = smooth <= smooth.min() + 3.0 # à 3 dB du minimum
best_start, best_length, run_start = 0, 0, None
for i, is_quiet in enumerate([*quiet, False]):
if is_quiet and run_start is None:
run_start = i
elif not is_quiet and run_start is not None:
if i - run_start > best_length:
best_start, best_length = run_start, i - run_start
run_start = None
return (first + best_start + best_length / 2) * FRAME
def first_sound(self, start: float, end: float) -> float | None:
first, last = self._frames(start, end)
active = np.nonzero(self.db[first:last] > self.threshold)[0]
return (first + int(active[0])) * FRAME if len(active) else None
def last_sound(self, start: float, end: float) -> float | None:
first, last = self._frames(start, end)
active = np.nonzero(self.db[first:last] > self.threshold)[0]
return (first + int(active[-1]) + 1) * FRAME if len(active) else None
def pauses(self, min_length: float = MIN_PAUSE) -> list[tuple[float, float]]:
"""Silences (sous le seuil d'audibilité) d'au moins min_length secondes."""
quiet = self.db <= self.threshold
runs, run_start = [], None
for i, is_quiet in enumerate([*quiet, False]):
if is_quiet and run_start is None:
run_start = i
elif not is_quiet and run_start is not None:
if (i - run_start) * FRAME >= min_length:
runs.append((run_start * FRAME, i * FRAME))
run_start = None
return runs
def speech_span(self, start: float, end: float) -> tuple[float, float]:
"""Parole contenue dans [start, end], avec ses marges, sans en sortir."""
onset, offset = self.first_sound(start, end), self.last_sound(start, end)
if onset is None or offset is None:
return start, end
return max(start, onset - LEAD_PAD), min(end, offset + TAIL_PAD)
def envelope_from_samples(samples: np.ndarray, rate: int) -> Envelope:
per_frame = int(rate * FRAME)
count = len(samples) // per_frame
if count == 0:
return Envelope(np.array([-120.0]))
frames = samples[: count * per_frame].reshape(count, per_frame).astype(np.float64)
rms = np.sqrt(np.mean(frames**2, axis=1))
return Envelope(20 * np.log10(np.maximum(rms, 1e-6)))
async def load_envelope(path: Path) -> Envelope:
"""Décode l'audio (mono 16 kHz) et mesure son niveau toutes les 10 ms."""
process = await asyncio.create_subprocess_exec(
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(path),
"-ac", "1", "-ar", "16000", "-f", "f32le", "-",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) # fmt: skip
raw, err = await process.communicate()
if process.returncode != 0:
raise proc.CommandError(["ffmpeg"], process.returncode, err.decode(errors="replace"))
return envelope_from_samples(np.frombuffer(raw, dtype=np.float32), 16000)
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float, after: float = 0.0) -> float:
"""Coupe entre deux phrases, au cœur d'une vraie pause.
Les bornes Whisper sont imprécises, et fausses quand un mot n'a pas été
reconnu (un prix en fin de phrase : sa place est alors estimée) ; couper
à la borne estimée tombait parfois au milieu d'une phrase. On choisit donc
la pause mesurée la plus proche de la borne estimée, les longues pauses
(fin de phrase) étant préférées aux courtes (virgule)."""
low = max(after, min(previous_end, next_start) - 0.1)
high = max(low + 0.05, max(previous_end, next_start) + 0.2)
best: tuple[float, float, float] | None = None
for start, end in envelope.pauses():
if end <= after + 0.05 or start <= 0.0 or end >= envelope.duration:
continue # silences avant la première et après la dernière phrase exclus
distance = max(0.0, low - end, start - high)
if distance > PAUSE_SEARCH:
continue
score = distance - (end - start)
if best is None or score < best[0]:
best = (score, max(start, after), end)
if best:
return envelope.quietest(best[1], best[2])
return envelope.quietest(low, high)
+280
View File
@@ -0,0 +1,280 @@
"""Construction des commandes FFmpeg d'un Reel (fonctions pures, testables).
Deux usages :
- rendu complet par FFmpeg (sous-titres ASS incrustés) : `build_command` ;
- préparation pour Remotion, qui compose ensuite les sous-titres animés, le
logo et l'effet de fin : `build_prepared_video_command` (image seule, déjà
recadrée et allongée) et `build_audio_mix_command` (piste son finale).
"""
from dataclasses import dataclass
from pathlib import Path
from . import config
from .subtitles import filter_path
FADE_SECONDS = 2.0
# Fondu raccourci quand la voix finit tard, mais jamais sec
MIN_FADE = 0.3
LOGO_SECONDS = 5.0
# Silence laissé après la dernière phrase avant la fin de la vidéo
VOICE_TAIL = 0.8
# Durée minimale de l'effet de fin (grand logo + nom du magasin) après la voix
OUTRO_MIN = 2.5
# HDR (HLG/PQ) → SDR BT.709 : sans cela, les vidéos iPhone sortent ternes
TONEMAP = (
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,"
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
)
COLOR_TAGS = ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"]
@dataclass
class RenderPlan:
video: Path
video_duration: float
output: Path
is_hdr: bool = False
stabilize_transforms: Path | None = None
music: Path | None = None
music_volume: float = 0.25
voice: Path | None = None
voice_duration: float = 0.0
voice_delay: float = config.VOICE_DELAY
captions: Path | None = None
watermark: Path | None = None
outro: Path | None = None # nom du magasin (effet de fin)
# Effet de fin prévu sans que le logo passe par FFmpeg (rendu Remotion)
outro_expected: bool = False
ending_effect: bool = True
# Petit logo en coin pendant la vidéo (le grand logo de fin dépend de ending_effect)
show_watermark: bool = True
keep_original_audio: bool = False
@property
def has_outro(self) -> bool:
return self.ending_effect and (self.watermark is not None or self.outro_expected)
@property
def speech_end(self) -> float:
return self.voice_delay + self.voice_duration if self.voice else 0.0
@property
def total_duration(self) -> float:
"""La vidéo s'allonge (dernière image figée) si la voix dure plus longtemps."""
duration = self.video_duration
if self.voice:
duration = max(duration, self.speech_end + VOICE_TAIL)
if self.has_outro:
duration = max(duration, self.speech_end + OUTRO_MIN)
return round(duration, 3)
@property
def logo_start(self) -> float:
"""Le grand logo n'arrive qu'une fois la voix terminée."""
return round(max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end), 3)
@property
def fade_start(self) -> float:
"""Le fondu final ne commence jamais avant la fin de la voix : il
rognait la dernière phrase quand la vidéo finissait peu après."""
start = max(0.0, self.total_duration - FADE_SECONDS)
if self.voice:
start = max(start, min(self.speech_end + 0.15, self.total_duration - MIN_FADE))
return round(start, 3)
@property
def fade_duration(self) -> float:
return round(max(MIN_FADE, self.total_duration - self.fade_start), 3)
@property
def freeze_duration(self) -> float:
return max(0.0, self.total_duration - self.video_duration)
def video_filters(plan: RenderPlan) -> list[str]:
"""Stabilisation, HDR, recadrage 9:16, cadence fixe, dernière image figée."""
chain = []
if plan.stabilize_transforms:
chain.append(
f"vidstabtransform=input={filter_path(plan.stabilize_transforms)}:smoothing=30:relative=1:zoom=5,"
"unsharp=5:5:0.6:5:5:0.0"
)
if plan.is_hdr:
chain.append(TONEMAP)
chain.append(
f"scale={config.WIDTH}:{config.HEIGHT}:force_original_aspect_ratio=increase:flags=lanczos,"
f"crop={config.WIDTH}:{config.HEIGHT},setsar=1,fps={config.FPS}"
)
if plan.freeze_duration > 0:
chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}")
return chain
def uses_original_audio(plan: RenderPlan) -> bool:
"""Sans musique choisie, la bande son d'origine de la vidéo sert de fond sonore."""
return plan.music is None and plan.keep_original_audio
def audio_graph(
plan: RenderPlan, music_idx: int | None, voice_idx: int | None, original_idx: int | None = None
) -> tuple[list[str], str | None]:
"""Graphe audio : voix décalée sur un fond sonore (musique bouclée, ou à
défaut bande son d'origine) baissé sous la voix, niveau final ~ -14 LUFS.
Renvoie les filtres et l'étiquette de sortie."""
graph: list[str] = []
if voice_idx is not None:
delay_ms = int(plan.voice_delay * 1000)
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
bed = None
if music_idx is not None:
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[bed]")
bed = "bed"
elif original_idx is not None:
# Son d'origine conservé à son niveau ; il se tait après la fin de la vidéo
graph.append(f"[{original_idx}:a:0]aresample=48000,apad[bed]")
bed = "bed"
if voice_idx is not None and bed:
# Le fond sonore baisse automatiquement quand la voix parle (ducking)
graph.append("[voice]asplit=2[vmix][vkey]")
graph.append(f"[{bed}][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[ducked]")
graph.append("[ducked][vmix]amix=inputs=2:duration=longest:normalize=0[mix]")
mix = "mix"
elif voice_idx is not None:
mix = "voice"
elif bed:
mix = bed
else:
return graph, None
tail = ["loudnorm=I=-14:TP=-1.5:LRA=20", "aresample=48000"]
if plan.ending_effect:
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={plan.fade_duration}")
graph.append(f"[{mix}]{','.join(tail)}[aout]")
return graph, "aout"
def _audio_inputs(plan: RenderPlan, first_index: int) -> tuple[list[str], int | None, int | None, int]:
args: list[str] = []
index = first_index
music_idx = voice_idx = None
if plan.music:
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
args += ["-stream_loop", "-1", "-i", str(plan.music)]
music_idx, index = index, index + 1
if plan.voice:
args += ["-i", str(plan.voice)]
voice_idx, index = index, index + 1
return args, music_idx, voice_idx, index
def _video_encoding(crf: int, preset: str) -> list[str]:
return [
"-c:v", "libx264", "-preset", preset, "-crf", str(crf),
"-maxrate", "10M", "-bufsize", "20M",
"-profile:v", "high", "-level", "4.2",
"-g", str(config.FPS * 2), "-keyint_min", str(config.FPS),
"-pix_fmt", "yuv420p", *COLOR_TAGS,
] # fmt: skip
def build_command(plan: RenderPlan) -> list[str]:
"""Rendu complet par FFmpeg, sous-titres ASS incrustés."""
total = plan.total_duration
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 1)
cmd += audio_args
wm_idx = None
if plan.watermark:
cmd += ["-i", str(plan.watermark)]
wm_idx = index
chain = video_filters(plan)
if plan.captions:
chain.append(f"subtitles='{filter_path(plan.captions)}'")
graph = [f"[0:v]{','.join(chain)}[vbase]"]
current = "vbase"
if wm_idx is not None:
corner = "W-w-30:H-h-30"
big_logo = bool(plan.outro and plan.ending_effect)
logo_start = plan.logo_start
if big_logo and plan.show_watermark:
graph.append(f"[{wm_idx}:v]split=2[wm_small0][wm_big0]")
graph.append("[wm_small0]scale=200:-1[wm_small]")
elif plan.show_watermark:
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
elif big_logo:
graph.append(f"[{wm_idx}:v]null[wm_big0]")
if plan.show_watermark:
until = f":enable='lt(t,{logo_start:.3f})'" if big_logo else ""
graph.append(f"[{current}][wm_small]overlay={corner}{until}[vwm1]")
current = "vwm1"
if big_logo:
graph.append("[wm_big0]scale=-1:300[wm_big]")
graph.append(
f"[{current}][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]"
)
current = "vwm2"
if plan.outro and plan.ending_effect:
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
current = "vout0"
tail = []
if plan.ending_effect:
tail.append(f"fade=t=out:st={plan.fade_start:.3f}:d={plan.fade_duration}")
tail.append("format=yuv420p")
graph.append(f"[{current}]{','.join(tail)}[vout]")
# La vidéo (entrée 0) fournit la bande son d'origine si aucune musique n'est choisie
original_idx = 0 if uses_original_audio(plan) else None
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
graph += audio_filters
audio_map = ["-map", f"[{audio_out}]"] if audio_out else []
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
cmd += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-movflags", "+faststart"]
cmd.append(str(plan.output))
return cmd
def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
"""Image seule, prête pour Remotion : recadrée, en cadence fixe, à la durée finale.
Qualité élevée (CRF 16) : elle sera réencodée une fois par Remotion."""
chain = [*video_filters(plan), "format=yuv420p"]
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
"-vf", ",".join(chain), "-an", "-t", f"{plan.total_duration:.3f}",
*_video_encoding(16, "veryfast"), "-movflags", "+faststart", str(output),
] # fmt: skip
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
"""Piste son finale (WAV 48 kHz stéréo), ou None s'il n'y a aucun son à produire."""
total = f"{plan.total_duration:.3f}"
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 0)
original_idx = None
if uses_original_audio(plan):
audio_args += ["-i", str(plan.video)]
original_idx = index
graph, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
if not audio_out:
return None
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
] # fmt: skip
def stabilize_detect_command(video: Path, transforms: Path) -> list[str]:
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(video),
"-vf", f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
"-f", "null", "-",
] # fmt: skip
+235
View File
@@ -0,0 +1,235 @@
"""Voix calée sur un fichier SRT.
Tout le texte est lu d'une seule traite, puis la voix est découpée phrase par
phrase au point le plus calme de chaque pause, et chaque morceau est posé à l'instant de son
sous-titre. Une lecture unique garde la même intonation, le même rythme et le
même niveau d'un bout à l'autre ; lire chaque sous-titre séparément donnait
une voix différente à chaque phrase, et le découpage aux bornes estimées par
Whisper rognait des syllabes.
Si la lecture unique ne peut pas être répartie entre les sous-titres, on lit
chaque sous-titre séparément (même voix, même graine), en découpant là aussi
au niveau sonore mesuré.
Quand un morceau est plus long que son sous-titre, il est accéléré (sans
changer la hauteur de la voix) pour finir à temps.
"""
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from . import proc, quality, tts
from .align import Word, display_tokens
from .text import clean_text
log = logging.getLogger(__name__)
# Débordement toléré sur le silence qui suit un sous-titre avant d'accélérer
OVERFLOW_TOLERANCE = 0.25
# Au-delà, l'accélération s'entend nettement : l'utilisateur est prévenu
AUDIBLE_SPEEDUP = 1.35
_SENTENCE_END = re.compile(r"[.!?…]$")
@dataclass
class Cue:
start: float
end: float
text: str
@dataclass
class Segment:
"""Morceau de la voix lue, à poser à l'instant d'un sous-titre."""
source_start: float
source_end: float
words: list[Word]
def cue_windows(cues: list[Cue]) -> list[float]:
"""Durée disponible pour lire chaque sous-titre sans empiéter sur le suivant."""
windows = []
for i, cue in enumerate(cues):
limit = cue.end + OVERFLOW_TOLERANCE
if i + 1 < len(cues):
limit = min(limit, max(cue.end, cues[i + 1].start))
windows.append(max(0.1, limit - cue.start))
return windows
def speed_factor(duration: float, window: float) -> float:
"""Accélération nécessaire pour tenir dans la fenêtre (1 = vitesse normale)."""
return max(1.0, duration / window) if window > 0 else 1.0
def atempo_chain(factor: float) -> str:
"""Filtre atempo ; découpé en étapes ≤ 2 pour les anciennes versions de FFmpeg."""
steps = []
remaining = factor
while remaining > 2.0:
steps.append(2.0)
remaining /= 2.0
steps.append(remaining)
return ",".join(f"atempo={s:.4f}" for s in steps)
def as_sentence(text: str) -> str:
"""Chaque sous-titre finit par une ponctuation : la voix y marque une pause,
ce qui laisse un silence où couper."""
text = clean_text(text)
return text if _SENTENCE_END.search(text) else f"{text}."
def split_by_cue(words: list[Word], cue_texts: list[str]) -> list[list[Word]] | None:
"""Répartit les mots de la lecture complète entre les sous-titres
(les mots affichés suivent l'ordre du texte). None si les comptes divergent."""
counts = [len(display_tokens(t)) for t in cue_texts]
if sum(counts) != len(words) or 0 in counts:
return None
groups, index = [], 0
for count in counts:
groups.append(words[index : index + count])
index += count
return groups
def segments_from_reading(groups: list[list[Word]], envelope: quality.Envelope) -> list[Segment] | None:
"""Découpe la lecture complète entre les phrases, au point le plus calme de
chaque pause, puis retire le silence autour de chaque phrase en gardant une
marge (300 ms après le dernier son : les fins de phrases ne sont pas rognées)."""
cuts = [0.0]
for previous, following in zip(groups, groups[1:]):
cuts.append(quality.sentence_cut(envelope, previous[-1].end, following[0].start, after=cuts[-1]))
cuts.append(envelope.duration)
if any(b <= a for a, b in zip(cuts, cuts[1:])):
return None
segments = []
for i, group in enumerate(groups):
seg_start, seg_end = envelope.speech_span(cuts[i], cuts[i + 1])
segments.append(Segment(seg_start, seg_end, group))
return segments
async def synthesize_cues(
*,
cues: list[Cue],
engine: str | None,
voice: str | None,
style: str | None,
gemini_api_key: str | None,
workdir: Path,
) -> tts.VoiceTrack:
"""Voix complète : mots minutés en secondes depuis le début de la vidéo."""
cues = sorted((c for c in cues if clean_text(c.text)), key=lambda c: c.start)
if not cues:
raise ValueError("Aucun sous-titre lisible dans le fichier SRT")
options = dict(engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key)
texts = [as_sentence(c.text) for c in cues]
# 1. Lecture d'une seule traite, découpée dans les silences
script = " ".join(texts)
reading = await tts.synthesize(text=script, display_text=script, workdir=workdir, **options)
envelope = await quality.load_envelope(reading.path)
groups = split_by_cue(reading.words, texts)
segments = segments_from_reading(groups, envelope) if groups else None
if segments:
sources = [(reading.path, seg) for seg in segments]
warnings = list(reading.warnings)
used_engine, used_voice = reading.engine, reading.voice
else:
# 2. Repli : chaque sous-titre lu séparément, avec la voix de la lecture complète
log.warning("Lecture SRT non répartie entre les sous-titres : lecture phrase par phrase")
sources, warnings = [], list(reading.warnings)
used_engine, used_voice = reading.engine, reading.voice
for index, text in enumerate(texts):
cue_dir = workdir / f"cue_{index:03d}"
cue_dir.mkdir(exist_ok=True)
track = await tts.synthesize(
text=text,
display_text=text,
workdir=cue_dir,
**{**options, "engine": used_engine, "voice": used_voice or voice},
)
warnings += [w for w in track.warnings if w not in warnings]
cue_envelope = await quality.load_envelope(track.path)
span = cue_envelope.speech_span(0.0, cue_envelope.duration)
sources.append((track.path, Segment(span[0], span[1], track.words)))
placed: list[tuple[Path, float]] = []
words: list[Word] = []
for index, (cue, window, (source, segment)) in enumerate(zip(cues, cue_windows(cues), sources)):
factor = speed_factor(segment.source_end - segment.source_start, window)
if factor > AUDIBLE_SPEEDUP:
warnings.append(
f"Sous-titre {index + 1} (« {cue.text[:40]} ») lu {factor:.1f}× plus vite "
"pour tenir dans son minutage : raccourcissez-le ou allongez sa durée."
)
fitted = workdir / f"segment_{index:03d}.wav"
await proc.run(extract_command(source, segment, factor, fitted), timeout=120)
placed.append((fitted, cue.start))
added_period = not _SENTENCE_END.search(clean_text(cue.text))
for position, w in enumerate(segment.words):
text = w.text
if added_period and position == len(segment.words) - 1:
text = text.rstrip(".") # point ajouté pour la lecture, pas à afficher
start = cue.start + max(0.0, w.start - segment.source_start) / factor
end = cue.start + max(0.0, w.end - segment.source_start) / factor
words.append(Word(text or w.text, start, min(max(end, start + 0.05), cue.start + window)))
output = workdir / "voice_srt.wav"
await proc.run(mix_command(placed, output), timeout=300)
duration = (await proc.probe(output)).duration
log.info(
"Voix SRT prête : %s/%s, %d sous-titres, %.1f s (%s)",
used_engine,
used_voice,
len(cues),
duration,
"lecture unique" if segments else "phrase par phrase",
)
return tts.VoiceTrack(output, duration, _monotonic(words), used_engine, used_voice, warnings)
def extract_command(source: Path, segment: Segment, factor: float, output: Path) -> list[str]:
"""Extrait un morceau de voix, avec un fondu de 10 ms aux bords (pas de clic)
et l'accélération éventuelle."""
length = segment.source_end - segment.source_start
fade_out = max(0.0, length - 0.01)
filters = (
f"atrim=start={segment.source_start:.3f}:end={segment.source_end:.3f},asetpts=PTS-STARTPTS,"
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01,{atempo_chain(factor)}"
)
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
"-af", filters, "-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
] # fmt: skip
def mix_command(segments: list[tuple[Path, float]], output: Path) -> list[str]:
"""Pose chaque segment de voix à son instant, sur une seule piste."""
command = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
for path, _ in segments:
command += ["-i", str(path)]
graph = [f"[{i}:a]adelay={int(round(start * 1000))}:all=1[s{i}]" for i, (_, start) in enumerate(segments)]
labels = "".join(f"[s{i}]" for i in range(len(segments)))
graph.append(
f"{labels}amix=inputs={len(segments)}:duration=longest:dropout_transition=0:normalize=0[voice]"
)
return command + [
"-filter_complex", ";".join(graph), "-map", "[voice]",
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
] # fmt: skip
def _monotonic(words: list[Word]) -> list[Word]:
for prev, cur in zip(words, words[1:]):
cur.start = max(cur.start, prev.start + 0.01)
cur.end = max(cur.end, cur.start + 0.05)
return words
+128
View File
@@ -0,0 +1,128 @@
"""Sous-titres ASS : mot à mot, style « Reels » lisible sur tout fond."""
from pathlib import Path
from . import config
from .align import Word
# Couleurs ASS au format &HAABBGGRR
YELLOW = "&H0000E6FF"
WHITE = "&H00FFFFFF"
BLACK = "&H00000000"
SHADOW = "&H64000000"
# Au-delà, une ligne déborde ou se lit mal en un coup d'œil
MAX_WORDS_PER_LINE = 3
MAX_CHARS_PER_LINE = 18
# Bas du texte à ~70 % de la hauteur : au-dessus de la légende et des boutons
# qu'Instagram et TikTok superposent en bas de l'écran.
MARGIN_V = 560
POP_MS = 90
def ass_time(seconds: float) -> str:
seconds = max(0.0, seconds)
centis = int(round(seconds * 100))
hours, centis = divmod(centis, 360000)
minutes, centis = divmod(centis, 6000)
secs, centis = divmod(centis, 100)
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
def escape(text: str) -> str:
return text.replace("\\", "").replace("{", "(").replace("}", ")")
def header(styles: list[str]) -> str:
return (
"[Script Info]\n"
"ScriptType: v4.00+\n"
f"PlayResX: {config.WIDTH}\n"
f"PlayResY: {config.HEIGHT}\n"
"ScaledBorderAndShadow: yes\n"
"WrapStyle: 2\n\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
"BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
+ "".join(f"{s}\n" for s in styles)
+ "\n[Events]\n"
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
def group_lines(words: list[Word]) -> list[list[Word]]:
"""Découpe en lignes courtes, coupées de préférence à la ponctuation."""
lines: list[list[Word]] = []
current: list[Word] = []
for word in words:
chars = sum(len(w.text) + 1 for w in current) + len(word.text)
if current and (len(current) >= MAX_WORDS_PER_LINE or chars > MAX_CHARS_PER_LINE):
lines.append(current)
current = []
current.append(word)
if word.text.endswith((".", "!", "?", ",", ":", ";", "…")):
lines.append(current)
current = []
if current:
lines.append(current)
return lines
def karaoke_line(line: list[Word], line_end: float) -> str:
"""Chaque mot passe du blanc au jaune et « saute » légèrement quand il est dit."""
parts = []
line_start = line[0].start
for index, word in enumerate(line):
next_start = line[index + 1].start if index + 1 < len(line) else line_end
fill_cs = max(1, int(round((next_start - word.start) * 100)))
t0 = int(round((word.start - line_start) * 1000))
pop = (
f"\\fscx100\\fscy100"
f"\\t({t0},{t0 + POP_MS},\\fscx112\\fscy112)"
f"\\t({t0 + POP_MS},{t0 + 2 * POP_MS},\\fscx100\\fscy100)"
)
parts.append(f"{{\\kf{fill_cs}{pop}}}{escape(word.text)}")
return " ".join(parts)
def write_captions(
words: list[Word], path: Path, *, offset: float, font_size: int, end_time: float | None = None
) -> None:
"""Écrit le fichier ASS des sous-titres, décalés de `offset` secondes."""
style = (
f"Style: Caption,{config.SUBTITLE_FONT},{font_size},{YELLOW},{WHITE},{BLACK},{SHADOW},"
f"-1,0,0,0,100,100,0,0,1,6,3,2,80,80,{MARGIN_V},1"
)
lines = group_lines(words)
events = []
for index, line in enumerate(lines):
start = line[0].start
natural_end = line[-1].end + 0.25
if index + 1 < len(lines):
end = min(max(natural_end, line[-1].end), lines[index + 1][0].start)
else:
end = max(natural_end, end_time - offset if end_time else natural_end)
end = max(end, start + 0.3)
events.append(
f"Dialogue: 0,{ass_time(start + offset)},{ass_time(end + offset)},Caption,,0,0,0,,"
f"{karaoke_line(line, end)}"
)
path.write_text(header([style]) + "\n".join(events) + "\n", encoding="utf-8")
def write_outro(store_name: str, path: Path, start: float, end: float) -> None:
"""Nom du magasin sous le logo, en fondu, pendant les dernières secondes."""
style = (
f"Style: Outro,{config.SUBTITLE_FONT},72,{WHITE},{WHITE},{BLACK},{SHADOW},"
"-1,0,0,0,100,100,0,0,1,3,4,2,60,60,700,1"
)
event = (
f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Outro,,0,0,0,,{{\\fad(1200,0)}}{escape(store_name)}"
)
path.write_text(header([style]) + event + "\n", encoding="utf-8")
def filter_path(path: Path) -> str:
"""Chemin utilisable dans un filtre FFmpeg (échappement de : et ')."""
return str(path).replace("\\", "/").replace(":", "\\:").replace("'", "\\'")
+20
View File
@@ -0,0 +1,20 @@
"""Nettoyage du texte avant synthèse et affichage."""
import re
import emoji
_HIDDEN = str.maketrans("", "", "​")
_HASHTAG = re.compile(r"#[\wÀ-ɏ]+")
_URL = re.compile(r"https?://\S+")
def clean_text(text: str | None) -> str:
"""Retire emojis, hashtags, liens et caractères invisibles."""
if not text:
return ""
text = text.translate(_HIDDEN)
text = emoji.replace_emoji(text, replace="")
text = _URL.sub("", text)
text = _HASHTAG.sub("", text)
return " ".join(text.split())
+141
View File
@@ -0,0 +1,141 @@
"""Synthèse vocale : moteur au choix, repli sur Edge, voix traitée et mots minutés."""
import logging
from dataclasses import dataclass, field
from pathlib import Path
from .. import align, audio, config, proc, quality
from ..align import Word
from . import edge, gemini
log = logging.getLogger(__name__)
@dataclass
class VoiceTrack:
path: Path # WAV 48 kHz traité
duration: float
words: list[Word] # mots affichés, minutés depuis le début de la voix
engine: str
voice: str
warnings: list[str] = field(default_factory=list)
async def synthesize(
*,
text: str,
display_text: str,
engine: str | None,
voice: str | None,
style: str | None,
gemini_api_key: str | None,
workdir: Path,
) -> VoiceTrack:
warnings: list[str] = []
processed: Path | None = None
spoken: list[Word] = []
used_engine, used_voice = "edge", ""
if (engine or "gemini") == "gemini":
if not gemini_api_key:
warnings.append("Clé Gemini absente : voix Edge utilisée à la place.")
else:
result = await _gemini_checked(text, voice, style, gemini_api_key, workdir, warnings)
if result:
processed, spoken, used_voice, reading_ok = result
used_engine = "gemini"
if reading_ok:
processed, spoken = await _trim_stray_sounds(processed, spoken, workdir)
if processed is None:
raw, spoken, used_voice = await edge.synthesize(text, voice, style, workdir)
processed = workdir / "voice.wav"
await audio.process_voice(raw, processed)
duration = (await proc.probe(processed)).duration
if not spoken:
spoken = await align.transcribe(processed)
words = align.align_words(display_text, spoken, duration)
log.info("Voix prête : %s/%s, %.1f s, %d mots", used_engine, used_voice, duration, len(words))
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
async def _gemini_checked(
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, warnings: list[str]
) -> tuple[Path, list[Word], str, bool] | None:
"""Voix Gemini dont la lecture a été vérifiée : Whisper réécoute chaque
génération ; mots sautés, fin tronquée ou consigne lue à voix haute
déclenchent une nouvelle génération. Renvoie None pour se replier sur Edge."""
best: tuple[quality.ReadingCheck, Path, list[Word], str] | None = None
last_error: Exception | None = None
for attempt in range(max(1, config.GEMINI_TTS_ATTEMPTS)):
try:
raw, gemini_voice = await gemini.synthesize(text, voice, style, api_key, workdir, attempt)
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis repli sur Edge
log.warning("Gemini TTS en échec (tentative %d) : %s", attempt + 1, error)
last_error = error
continue
processed = workdir / f"voice_gemini_{attempt}.wav"
await audio.process_voice(raw, processed)
spoken = await align.transcribe(processed)
check = quality.check_reading(text, spoken)
log.info("Lecture Gemini (tentative %d) : %s", attempt + 1, check.describe())
if best is None or _score(check) > _score(best[0]):
best = (check, processed, spoken, gemini_voice)
if check.acceptable:
break
if best is None:
warnings.append(f"Gemini indisponible ({last_error}) : voix Edge utilisée à la place.")
return None
check, processed, spoken, gemini_voice = best
if not check.acceptable:
if check.coverage < 0.7:
warnings.append(f"Gemini a mal lu le texte ({check.describe()}) : voix Edge utilisée à la place.")
return None
warnings.append(f"Lecture Gemini imparfaite ({check.describe()}) : écoutez le résultat.")
return processed, spoken, gemini_voice, check.acceptable
# Au-delà de la fin du dernier mot, ce n'est plus de la parole
STRAY_AFTER_LAST_WORD = 0.6
STRAY_BEFORE_FIRST_WORD = 0.4
async def _trim_stray_sounds(path: Path, spoken: list[Word], workdir: Path) -> tuple[Path, list[Word]]:
"""Retire les bruits que Gemini ajoute parfois avant le premier ou après le
dernier mot. Appelé seulement quand la lecture a été vérifiée : le dernier
mot est bien reconnu, on ne coupe donc jamais de parole."""
if not spoken:
return path, spoken
envelope = await quality.load_envelope(path)
duration = envelope.duration
window_start = max(0.0, spoken[0].start - STRAY_BEFORE_FIRST_WORD)
window_end = min(duration, spoken[-1].end + STRAY_AFTER_LAST_WORD)
start, end = envelope.speech_span(window_start, window_end)
if start < 0.02 and end > duration - 0.02:
return path, spoken
trimmed = workdir / "voice_trimmed.wav"
fade_out = max(0.0, end - start - 0.01)
await proc.run(
[
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(path),
"-af", f"atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS,"
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01",
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(trimmed),
],
timeout=60,
) # fmt: skip
log.info("Voix recadrée sur la parole : %.2f–%.2f s (sur %.2f s)", start, end, duration)
shifted = [Word(w.text, w.start - start, w.end - start) for w in spoken if w.start < end]
return trimmed, shifted
def _score(check: quality.ReadingCheck) -> float:
return check.coverage - check.extra + (0.2 if check.ending_ok else 0.0)
+58
View File
@@ -0,0 +1,58 @@
"""Synthèse vocale Edge (gratuite, fournit le minutage des mots)."""
import logging
from pathlib import Path
import edge_tts
from .. import config
from ..align import Word
from ..voices import edge_fallbacks, resolve_edge_voice
log = logging.getLogger(__name__)
# Réglages de lecture par style (Edge n'interprète pas de consigne en texte)
STYLE_PROSODY = {
"dynamic": ("+8%", "+2Hz"),
"promo": ("+10%", "+3Hz"),
"calm": ("-8%", "-2Hz"),
"warm": ("-2%", "+0Hz"),
}
async def synthesize(
text: str, voice: str | None, style: str | None, workdir: Path
) -> tuple[Path, list[Word], str]:
"""Génère la voix ; renvoie le MP3, les mots minutés et la voix utilisée."""
rate, pitch = STYLE_PROSODY.get(style or "", ("+0%", "+0Hz"))
last_error: Exception | None = None
for candidate in edge_fallbacks(resolve_edge_voice(voice)):
target = workdir / "edge.mp3"
try:
communicate = edge_tts.Communicate(
text,
candidate.id,
rate=rate,
pitch=pitch,
# edge-tts 7 ne renvoie plus que les phrases par défaut
boundary="WordBoundary",
proxy=config.OUTBOUND_PROXY,
)
words: list[Word] = []
with target.open("wb") as out:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
out.write(chunk["data"])
elif chunk["type"] == "WordBoundary":
start = chunk["offset"] / 10_000_000
words.append(Word(chunk["text"], start, start + chunk["duration"] / 10_000_000))
if target.stat().st_size == 0:
raise RuntimeError("audio vide")
log.info("Edge TTS : voix=%s, %d mots minutés", candidate.id, len(words))
return target, words, candidate.id
except Exception as error: # noqa: BLE001 — on essaie la voix suivante
log.warning("Edge TTS %s en échec : %s", candidate.id, error)
last_error = error
raise RuntimeError(f"Aucune voix Edge disponible : {last_error}")
+122
View File
@@ -0,0 +1,122 @@
"""Synthèse vocale Gemini (API generateContent, modalité audio)."""
import base64
import logging
import re
from pathlib import Path
import httpx
from .. import config, proc
from ..voices import resolve_gemini_voice, style_instruction
log = logging.getLogger(__name__)
API_ROOT = "https://generativelanguage.googleapis.com/v1beta/models"
FALLBACK_MODEL = "gemini-2.5-flash-preview-tts"
class GeminiError(RuntimeError):
pass
def build_prompt(text: str, style: str | None) -> str:
"""Texte envoyé au modèle : la consigne de style précède le texte à lire.
La consigne demande une lecture intégrale, sans ajout : le modèle sautait
parfois des mots ou tronquait la fin."""
instruction = style_instruction(style)
if not instruction:
return text
return f"{instruction}. Lis tout le texte, mot pour mot, sans rien ajouter :\n{text}"
# Paramètres de tirage refusés par un modèle : on ne les renvoie plus
_unsupported_sampling: set[str] = set()
async def _request(model: str, prompt: str, voice: str, api_key: str, seed: int) -> dict:
generation: dict = {
"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}},
}
if model not in _unsupported_sampling:
# Intonation stable d'une génération à l'autre
generation["temperature"] = config.GEMINI_TTS_TEMPERATURE
generation["seed"] = seed
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": generation}
async with httpx.AsyncClient(timeout=120) as client:
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
response = await client.post(
f"{API_ROOT}/{model}:generateContent",
json=payload,
headers={"x-goog-api-key": api_key},
)
rejected_sampling = response.status_code == 400 and any(
word in response.text.lower() for word in ("seed", "temperature", "unknown name", "invalid json")
)
if rejected_sampling and "temperature" in generation:
# Modèle qui refuse température ou graine : même requête sans eux
log.warning("Gemini %s refuse les paramètres de tirage : %s", model, response.text[:200])
_unsupported_sampling.add(model)
return await _request(model, prompt, voice, api_key, seed)
if response.status_code != 200:
raise GeminiError(f"Gemini {model} : HTTP {response.status_code} {response.text[:300]}")
return response.json()
async def synthesize(
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, attempt: int = 0
) -> tuple[Path, str]:
"""Génère la voix ; renvoie un WAV brut et le nom de la voix utilisée.
Chaque nouvelle tentative change de graine pour obtenir une autre lecture."""
seed = config.GEMINI_TTS_SEED + attempt
gemini_voice = resolve_gemini_voice(voice).id
prompt = build_prompt(text, style)
model = config.GEMINI_TTS_MODEL
log.info("Gemini TTS : modèle=%s voix=%s style=%s (%d car.)", model, gemini_voice, style, len(text))
try:
data = await _request(model, prompt, gemini_voice, api_key, seed)
except GeminiError as error:
if model == FALLBACK_MODEL or "HTTP 404" not in str(error):
raise
log.warning("Modèle %s indisponible, repli sur %s", model, FALLBACK_MODEL)
data = await _request(FALLBACK_MODEL, prompt, gemini_voice, api_key, seed)
try:
part = next(p for p in data["candidates"][0]["content"]["parts"] if "inlineData" in p)["inlineData"]
except (KeyError, IndexError, StopIteration) as error:
raise GeminiError(f"Réponse Gemini sans audio : {str(data)[:300]}") from error
audio = base64.b64decode(part["data"])
mime = part.get("mimeType", "")
raw = workdir / f"gemini_raw_{attempt}.wav"
if "wav" in mime:
raw.write_bytes(audio)
else:
# PCM brut 16 bits (« audio/L16;codec=pcm;rate=24000 »)
rate_match = re.search(r"rate=(\d+)", mime)
pcm = workdir / f"gemini_{attempt}.pcm"
pcm.write_bytes(audio)
await proc.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"s16le",
"-ar",
rate_match.group(1) if rate_match else "24000",
"-ac",
"1",
"-i",
str(pcm),
str(raw),
],
timeout=60,
)
pcm.unlink(missing_ok=True)
return raw, gemini_voice
+131
View File
@@ -0,0 +1,131 @@
"""Catalogue des voix et des styles de lecture."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Voice:
id: str
label: str
gender: str # "female" | "male"
# Voix natives de Gemini TTS (toutes multilingues, françaises comprises).
GEMINI_VOICES: tuple[Voice, ...] = (
Voice("Kore", "Kore — ferme", "female"),
Voice("Aoede", "Aoede — légère", "female"),
Voice("Leda", "Leda — jeune", "female"),
Voice("Zephyr", "Zephyr — lumineuse", "female"),
Voice("Callirrhoe", "Callirrhoe — décontractée", "female"),
Voice("Autonoe", "Autonoe — lumineuse", "female"),
Voice("Despina", "Despina — douce", "female"),
Voice("Erinome", "Erinome — claire", "female"),
Voice("Laomedeia", "Laomedeia — enjouée", "female"),
Voice("Achernar", "Achernar — tendre", "female"),
Voice("Gacrux", "Gacrux — mûre", "female"),
Voice("Pulcherrima", "Pulcherrima — affirmée", "female"),
Voice("Vindemiatrix", "Vindemiatrix — délicate", "female"),
Voice("Sulafat", "Sulafat — chaleureuse", "female"),
Voice("Charon", "Charon — informative", "male"),
Voice("Puck", "Puck — enjouée", "male"),
Voice("Fenrir", "Fenrir — enthousiaste", "male"),
Voice("Orus", "Orus — ferme", "male"),
Voice("Enceladus", "Enceladus — soufflée", "male"),
Voice("Iapetus", "Iapetus — claire", "male"),
Voice("Umbriel", "Umbriel — décontractée", "male"),
Voice("Algieba", "Algieba — veloutée", "male"),
Voice("Algenib", "Algenib — rocailleuse", "male"),
Voice("Rasalgethi", "Rasalgethi — informative", "male"),
Voice("Alnilam", "Alnilam — ferme", "male"),
Voice("Schedar", "Schedar — posée", "male"),
Voice("Achird", "Achird — amicale", "male"),
Voice("Zubenelgenubi", "Zubenelgenubi — naturelle", "male"),
Voice("Sadachbia", "Sadachbia — vive", "male"),
Voice("Sadaltager", "Sadaltager — experte", "male"),
)
EDGE_VOICES: tuple[Voice, ...] = (
Voice("fr-FR-VivienneMultilingualNeural", "Vivienne", "female"),
Voice("fr-FR-DeniseNeural", "Denise", "female"),
Voice("fr-FR-EloiseNeural", "Eloise", "female"),
Voice("fr-FR-RemyMultilingualNeural", "Rémy", "male"),
Voice("fr-FR-HenriNeural", "Henri", "male"),
)
# Consignes de lecture ajoutées au texte envoyé à Gemini.
STYLES: dict[str, tuple[str, str]] = {
"neutral": ("Neutre", ""),
"dynamic": (
"Dynamique",
"Lis ce texte en français sur un ton dynamique et enthousiaste, avec un rythme "
"entraînant, comme une vidéo courte sur les réseaux sociaux",
),
"warm": (
"Chaleureux",
"Lis ce texte en français sur un ton chaleureux, souriant et proche, comme si tu conseillais un ami",
),
"calm": (
"Calme",
"Lis ce texte en français sur un ton calme, posé et rassurant, sans te presser",
),
"promo": (
"Promo",
"Lis ce texte en français comme une annonce promotionnelle énergique, en "
"insistant sur les offres et les prix",
),
}
_GEMINI_BY_ID = {v.id.lower(): v for v in GEMINI_VOICES}
_EDGE_BY_ID = {v.id: v for v in EDGE_VOICES}
def resolve_gemini_voice(voice: str | None) -> Voice:
"""Voix Gemini à utiliser.
Accepte aussi les anciens identifiants « fr-FR-Standard-X » encore stockés
dans des jobs (B/D = voix masculine).
"""
if voice and voice.lower() in _GEMINI_BY_ID:
return _GEMINI_BY_ID[voice.lower()]
if voice and voice.endswith(("-B", "-D")) or voice == "male":
return _GEMINI_BY_ID["charon"]
return _GEMINI_BY_ID["kore"]
def voice_gender(voice: str | None) -> str:
"""Genre d'une voix, quel que soit le moteur d'origine."""
if not voice:
return "female"
if voice.lower() in _GEMINI_BY_ID:
return _GEMINI_BY_ID[voice.lower()].gender
if voice in _EDGE_BY_ID:
return _EDGE_BY_ID[voice].gender
if voice == "male" or voice.endswith(("-B", "-D")):
return "male"
return "male" if any(name in voice for name in ("Remy", "Henri", "Paul")) else "female"
def resolve_edge_voice(voice: str | None) -> Voice:
"""Voix Edge à utiliser ; une voix Gemini est remplacée par une voix Edge du même genre."""
if voice in _EDGE_BY_ID:
return _EDGE_BY_ID[voice]
gender = voice_gender(voice)
return next(v for v in EDGE_VOICES if v.gender == gender)
def edge_fallbacks(primary: Voice) -> list[Voice]:
"""Voix françaises de secours du même genre (jamais d'anglais)."""
same = [v for v in EDGE_VOICES if v.gender == primary.gender and v != primary]
return [primary, *same]
def style_instruction(style: str | None) -> str:
return STYLES.get(style or "neutral", STYLES["neutral"])[1]
def catalog() -> dict:
return {
"gemini": [v.__dict__ for v in GEMINI_VOICES],
"edge": [v.__dict__ for v in EDGE_VOICES],
"styles": [{"id": k, "label": v[0]} for k, v in STYLES.items()],
}
+5 -1381
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.5
ruff==0.11.8
+8 -9
View File
@@ -1,9 +1,8 @@
fastapi==0.109.0 fastapi==0.115.12
uvicorn==0.27.0 uvicorn[standard]==0.34.2
python-multipart==0.0.6 pydantic==2.11.4
requests==2.31.0 httpx==0.28.1
pydantic==2.6.0 edge-tts==7.2.8
edge-tts==6.1.12 emoji==2.14.1
emoji faster-whisper==1.2.1
ffsubsync==0.4.26 numpy>=1.26
httpx>=0.25.0
+9
View File
@@ -0,0 +1,9 @@
line-length = 110
target-version = "py311"
[lint]
select = ["E", "F", "W", "I", "B", "UP"]
ignore = [
"B008", # Depends() dans les signatures FastAPI
"B905", # zip() sur des séquences dont les longueurs sont liées par construction
]
-14
View File
@@ -1,14 +0,0 @@
[Script Info]
ScriptType: v4.00+
PlayResX: 1080
PlayResY: 1920
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Sans,65,&H0000FFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,4,2,5,50,50,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:02.00,0:00:02.71,Default,,0,0,0,,{\kf31}Test {\kf31}test {\kf10}1
Dialogue: 0,0:00:02.71,0:00:02.87,Default,,0,0,0,,{\kf10}2 {\kf10}3
-13
View File
@@ -1,13 +0,0 @@
[Script Info]
ScriptType: v4.00+
PlayResX: 1080
PlayResY: 1920
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Sans,65,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,4,2,5,50,50,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:02.00,0:00:04.00,Default,,0,0,0,,Hello World
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
import os
os.environ.setdefault("API_KEY", "test-key")
+31
View File
@@ -0,0 +1,31 @@
from app.align import Word, align_words, display_tokens, normalize
def test_normalize_strips_accents_and_punctuation():
assert normalize("Été,") == "ete"
assert normalize("«") == ""
def test_display_tokens_attach_isolated_punctuation():
assert display_tokens("Bonjour ! Venez vite .") == ["Bonjour!", "Venez", "vite."]
def test_align_uses_engine_timings_and_keeps_display_text():
spoken = [Word("Bonjour", 0.05, 0.6), Word("découvrez", 0.99, 1.35), Word("nos", 1.35, 1.44)]
words = align_words("Bonjour, découvrez nos", spoken)
assert [w.text for w in words] == ["Bonjour,", "découvrez", "nos"]
assert words[1].start == 0.99
def test_unmatched_words_are_interpolated_between_neighbours():
spoken = [Word("les", 0.0, 0.2), Word("prix", 1.0, 1.3)]
words = align_words("les super promos prix", spoken)
assert [w.text for w in words] == ["les", "super", "promos", "prix"]
assert 0.2 <= words[1].start < words[2].start < 1.0
assert all(b.start > a.start for a, b in zip(words, words[1:]))
def test_without_spoken_words_text_is_spread_over_duration():
words = align_words("un deux trois", [], total_duration=3.0)
assert words[0].start == 0.0
assert abs(words[-1].end - 3.0) < 1e-6
+105
View File
@@ -0,0 +1,105 @@
import numpy as np
from app.align import Word
from app.quality import FRAME, Envelope, check_reading, envelope_from_samples, sentence_cut
def _heard(text: str) -> list[Word]:
return [Word(t, i * 0.3, i * 0.3 + 0.25) for i, t in enumerate(text.split())]
def test_complete_reading_is_accepted():
check = check_reading(
"Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés en magasin")
)
assert check.acceptable and check.coverage == 1.0
def test_truncated_ending_is_rejected():
check = check_reading("Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés"))
assert not check.ending_ok and not check.acceptable
def test_instruction_read_aloud_is_rejected():
heard = _heard("lis ce texte sur un ton dynamique découvrez nos nouveautés en magasin")
assert not check_reading("Découvrez nos nouveautés en magasin", heard).acceptable
def _envelope(levels):
"""Enveloppe synthétique : liste de (durée en s, niveau en dB)."""
db = []
for seconds, level in levels:
db += [level] * int(round(seconds / FRAME))
return Envelope(np.array(db, dtype=float))
def test_soft_sentence_endings_are_kept():
# Parole à -20 dB, fin de phrase douce à -50 dB (relatif : -30 dB, audible),
# puis vrai silence à -90 dB
env = _envelope([(0.2, -90), (1.0, -20), (0.3, -50), (0.6, -90)])
start, end = env.speech_span(0.0, env.duration)
assert round(start, 2) == 0.12 # 80 ms avant la parole
assert round(end, 2) == 1.8 # 300 ms après la fin douce (à 1,5 s)
def test_cut_falls_in_the_quietest_part_of_the_pause():
env = _envelope([(1.0, -20), (0.15, -60), (0.2, -85), (0.15, -60), (1.0, -20)])
cut = sentence_cut(env, previous_end=0.95, next_start=1.55)
assert 1.15 <= cut <= 1.35
def test_envelope_from_samples_measures_level():
rate = 16000
samples = np.concatenate([np.zeros(rate // 2), 0.5 * np.ones(rate // 2)]).astype(np.float32)
env = envelope_from_samples(samples, rate)
assert env.db[0] < -100 and abs(env.db[-1] - 20 * np.log10(0.5)) < 0.1
def test_small_recognition_errors_are_not_missing_words():
expected = "Découvrez nos plantes de saison à petits prix. On vous attend ce week-end."
heard = _heard("Découvrez nos plans de saison à petit prix. On vous attend ce week -end.")
check = check_reading(expected, heard)
assert check.acceptable, check
def test_skipped_words_are_detected_and_named():
expected = (
"Pour Halloween découvrez nos costumes nos bonbons et toute notre décoration effrayante en magasin."
)
heard = _heard("Pour Halloween découvrez nos costumes et toute notre décoration en magasin.")
check = check_reading(expected, heard)
assert not check.acceptable
assert "bonbons" in check.missing and "effrayante" in check.missing
assert "non entendus" in check.describe()
def test_prices_and_units_written_differently_are_not_missing_words():
expected = "Alerte léopard ! Le coussin de 40 centimètres à 11,99 €. Le cabas à 13,99 €."
heard = _heard("Alerte léopard ! Le coussin de 40 cm à 11 € 99. Le cabas à 13,99 euros.")
check = check_reading(expected, heard)
assert check.acceptable, check
assert check.missing == []
def test_skipped_word_next_to_a_price_is_still_detected():
expected = "Le coussin léopard à 11,99 €. Le cabas doux et chaud à 13,99 €."
heard = _heard("Le coussin à 11,99 €. Le cabas à 13,99 €.")
check = check_reading(expected, heard)
assert not check.acceptable
assert "léopard" in check.missing
def test_cut_uses_the_real_pause_when_word_timing_is_estimated():
# Phrase 1 jusqu'à 2,0 s (dont un prix non reconnu), virgule à 1,0 s,
# pause de fin de phrase 2,0–2,4 s. Whisper place la fin de la phrase
# et le début de la suivante vers 2,9 s (mots interpolés).
env = _envelope([(0.3, -90), (0.7, -20), (0.1, -90), (0.9, -20), (0.4, -90), (1.5, -20), (0.3, -90)])
cut = sentence_cut(env, previous_end=2.9, next_start=2.95)
assert 2.0 <= cut <= 2.4
def test_cut_prefers_the_sentence_pause_over_a_comma():
env = _envelope([(0.3, -90), (1.0, -20), (0.12, -90), (0.8, -20), (0.45, -90), (1.0, -20), (0.3, -90)])
# Borne estimée entre la virgule (1,3 s) et la fin de phrase (2,22 s)
cut = sentence_cut(env, previous_end=1.7, next_start=1.75)
assert 2.22 <= cut <= 2.67
+171
View File
@@ -0,0 +1,171 @@
from pathlib import Path
from app.render import RenderPlan, build_command
def _graph(cmd):
return cmd[cmd.index("-filter_complex") + 1]
def test_voice_longer_than_video_extends_with_frozen_frame():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=5.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=10.0,
)
assert plan.total_duration == 12.8
cmd = build_command(plan)
assert "tpad=stop_mode=clone:stop_duration=7.800" in _graph(cmd)
assert cmd[cmd.index("-t") + 1] == "12.800"
def test_music_is_looped_and_ducked_under_voice():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=20.0,
output=Path("out.mp4"),
music=Path("m.mp3"),
voice=Path("v.wav"),
voice_duration=5.0,
)
cmd = build_command(plan)
assert cmd[cmd.index("m.mp3") - 3 : cmd.index("m.mp3")] == ["-stream_loop", "-1", "-i"]
graph = _graph(cmd)
assert "sidechaincompress" in graph
assert "amix=inputs=2:duration=longest" in graph
assert "loudnorm=I=-14" in graph
def test_no_brightness_hack_and_hdr_tonemapped():
plan = RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), is_hdr=True)
graph = _graph(build_command(plan))
assert "eq=" not in graph
assert "tonemap=tonemap=hable" in graph
assert "flags=lanczos" in graph
def test_original_audio_kept_when_nothing_added():
plan = RenderPlan(
video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), keep_original_audio=True
)
cmd = build_command(plan)
assert "[0:a:0]aresample=48000,apad[bed]" in _graph(cmd)
assert cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "[aout]"
def test_original_audio_stays_under_the_voice_when_no_music_is_chosen():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=10.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=4.0,
keep_original_audio=True,
)
graph = _graph(build_command(plan))
assert "[0:a:0]aresample=48000,apad[bed]" in graph
assert "[bed][vkey]sidechaincompress" in graph
def test_chosen_music_replaces_the_original_audio():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=10.0,
output=Path("out.mp4"),
music=Path("m.mp3"),
keep_original_audio=True,
)
assert "0:a:0" not in _graph(build_command(plan))
def test_prepared_audio_mix_keeps_original_audio_under_voice():
from app.render import build_audio_mix_command
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=10.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=4.0,
keep_original_audio=True,
)
cmd = build_audio_mix_command(plan, Path("a.wav"))
# Entrées : voix (0) puis vidéo d'origine (1)
assert cmd[cmd.index("in.mp4") - 1] == "-i"
assert "[1:a:0]aresample=48000,apad[bed]" in " ".join(cmd)
def test_encoding_targets_social_networks():
cmd = build_command(RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4")))
joined = " ".join(cmd)
assert "-crf 19" in joined and "-b:v" not in joined
assert "-ar 48000" in joined and "-b:a 192k" in joined
assert "-colorspace bt709" in joined
def test_big_logo_waits_for_the_end_of_the_voice():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=6.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=4.8,
watermark=Path("logo.png"),
outro=Path("o.ass"),
)
assert plan.logo_start == 6.8
assert plan.total_duration == 9.3
assert "gte(t,6.800)" in _graph(build_command(plan))
def test_corner_logo_can_be_hidden_while_keeping_the_ending_logo():
base = dict(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"), watermark=Path("logo.png"))
hidden = _graph(build_command(RenderPlan(**base, outro=Path("o.ass"), show_watermark=False)))
assert "W-w-30:H-h-30" not in hidden
assert "overlay=(W-w)/2" in hidden
nothing = _graph(build_command(RenderPlan(**base, show_watermark=False)))
assert "overlay" not in nothing
def test_prepared_video_has_no_audio_and_final_duration():
from app.render import build_prepared_video_command
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=6.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=5.0,
outro_expected=True,
)
cmd = build_prepared_video_command(plan, Path("video.mp4"))
assert "-an" in cmd
assert cmd[cmd.index("-t") + 1] == "9.500" # 2 s + 5 s de voix + 2,5 s d'effet de fin
assert plan.logo_start == 7.0
def test_audio_mix_absent_without_any_sound():
from app.render import build_audio_mix_command
plan = RenderPlan(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"))
assert build_audio_mix_command(plan, Path("a.wav")) is None
plan.keep_original_audio = True
assert "loudnorm=I=-14" in " ".join(build_audio_mix_command(plan, Path("a.wav")))
def test_final_fade_never_starts_before_the_voice_ends():
# Vidéo courte sans logo : 2 s + 5 s de voix, la vidéo finit 0,8 s après.
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=4.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=5.0,
)
assert plan.total_duration == 7.8
assert plan.fade_start >= plan.speech_end
assert plan.fade_duration >= 0.3
assert f"afade=t=out:st={plan.fade_start:.3f}" in _graph(build_command(plan))
+81
View File
@@ -0,0 +1,81 @@
from pathlib import Path
import numpy as np
from app.align import Word, spread_words
from app.quality import Envelope
from app.render import RenderPlan
from app.srt_voice import (
Cue,
as_sentence,
atempo_chain,
cue_windows,
mix_command,
segments_from_reading,
speed_factor,
split_by_cue,
)
def test_window_stops_at_next_cue_and_tolerates_a_short_overflow():
cues = [Cue(0.0, 2.0, "a"), Cue(2.1, 4.0, "b"), Cue(6.0, 7.0, "c")]
assert cue_windows(cues) == [2.1, 2.15, 1.25]
def test_voice_is_sped_up_only_when_too_long():
assert speed_factor(1.5, 2.0) == 1.0
assert speed_factor(3.0, 2.0) == 1.5
def test_atempo_is_split_into_supported_steps():
assert atempo_chain(1.25) == "atempo=1.2500"
assert atempo_chain(3.0) == "atempo=2.0000,atempo=1.5000"
def test_each_cue_becomes_a_sentence_for_the_reading():
assert as_sentence("Venez vite") == "Venez vite."
assert as_sentence("Promo !") == "Promo !"
def test_words_of_the_single_reading_are_split_between_cues():
words = [Word(t, i, i + 0.5) for i, t in enumerate(["Bonjour.", "Venez", "vite."])]
groups = split_by_cue(words, ["Bonjour.", "Venez vite."])
assert [[w.text for w in g] for g in groups] == [["Bonjour."], ["Venez", "vite."]]
assert split_by_cue(words, ["Bonjour."]) is None
def test_reading_is_cut_in_the_pause_without_clipping_sentence_endings():
# Whisper situe mal la fin de « Bonjour » (0,9 s) : la voix retombe
# doucement jusqu'à 1,05 s, puis vraie pause jusqu'à 1,6 s.
db = [-90.0] * 15 + [-20.0] * 75 + [-50.0] * 15 + [-90.0] * 55 + [-20.0] * 100 + [-90.0] * 40
env = Envelope(np.array(db))
groups = [[Word("Bonjour.", 0.2, 0.9)], [Word("Venez", 1.7, 2.1), Word("vite.", 2.1, 2.6)]]
first, second = segments_from_reading(groups, env)
assert first.source_end >= 1.05 + 0.25 # fin douce gardée, avec sa marge
assert first.source_end <= second.source_start
assert 1.5 <= second.source_start < 1.6
def test_each_segment_is_placed_at_its_cue_start():
cmd = mix_command([(Path("a.wav"), 0.0), (Path("b.wav"), 3.25)], Path("voice.wav"))
graph = cmd[cmd.index("-filter_complex") + 1]
assert "[1:a]adelay=3250:all=1[s1]" in graph
assert "amix=inputs=2" in graph and "normalize=0" in graph
def test_srt_voice_starts_without_delay():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=5.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=8.0,
voice_delay=0.0,
)
assert plan.speech_end == 8.0
assert plan.total_duration == 8.8
def test_words_spread_over_their_cue():
words = spread_words("Bonjour à tous", 4.0, 5.0)
assert words[0].start == 4.0 and round(words[-1].end, 3) == 5.0
+23
View File
@@ -0,0 +1,23 @@
from app.align import Word
from app.subtitles import ass_time, group_lines, write_captions
def test_ass_time_format():
assert ass_time(0) == "0:00:00.00"
assert ass_time(61.234) == "0:01:01.23"
def test_lines_break_on_punctuation_and_length():
words = [Word(t, i, i + 0.5) for i, t in enumerate("Bonjour, venez découvrir nos nouveautés".split())]
lines = group_lines(words)
assert [w.text for w in lines[0]] == ["Bonjour,"]
assert all(len(line) <= 3 for line in lines)
def test_captions_are_offset_and_escape_braces(tmp_path):
path = tmp_path / "c.ass"
write_captions([Word("{prix}", 0.0, 0.5), Word("fous", 0.5, 1.0)], path, offset=2.0, font_size=80)
content = path.read_text()
assert "Dialogue: 0,0:00:02.00," in content
assert "{prix}" not in content.split("[Events]")[1].replace("{\\", "")
assert "(prix)" in content
+23
View File
@@ -0,0 +1,23 @@
from app.tts.gemini import build_prompt
from app.voices import GEMINI_VOICES, edge_fallbacks, resolve_edge_voice, resolve_gemini_voice
def test_thirty_gemini_voices():
assert len(GEMINI_VOICES) == 30
def test_legacy_gemini_identifiers_still_resolve():
assert resolve_gemini_voice("fr-FR-Standard-B").id == "Charon"
assert resolve_gemini_voice("fr-FR-Standard-A").id == "Kore"
assert resolve_gemini_voice("puck").id == "Puck"
def test_edge_fallbacks_are_french_and_same_gender():
voice = resolve_edge_voice("Fenrir") # voix Gemini masculine
assert voice.gender == "male"
assert all(v.id.startswith("fr-FR") and v.gender == "male" for v in edge_fallbacks(voice))
def test_style_prompt_precedes_text():
assert build_prompt("Salut", "neutral") == "Salut"
assert build_prompt("Salut", "dynamic").endswith(":\nSalut")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

BIN
View File
Binary file not shown.
+512 -1
View File
@@ -13,6 +13,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/montserrat": "^5.3.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",
@@ -128,7 +129,8 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "^5.4.20" "vite": "^5.4.20",
"vitest": "^3.2.7"
}, },
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.8" "bufferutil": "^4.0.8"
@@ -2303,6 +2305,15 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
}, },
"node_modules/@fontsource/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@@ -6885,6 +6896,17 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -6969,6 +6991,13 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-transform": { "node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.11", "version": "0.1.11",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
@@ -7242,6 +7271,94 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -7612,6 +7729,16 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types": { "node_modules/ast-types": {
"version": "0.16.1", "version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -7887,6 +8014,16 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -7963,6 +8100,33 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -8440,6 +8604,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/define-data-property": { "node_modules/define-data-property": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -8987,6 +9161,16 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -9040,6 +9224,16 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -10540,6 +10734,13 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -11309,6 +11510,23 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/pause": { "node_modules/pause": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
@@ -12614,6 +12832,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -12700,6 +12925,13 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/stackframe": { "node_modules/stackframe": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
@@ -12715,6 +12947,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/streamsearch": { "node_modules/streamsearch": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -12843,6 +13082,26 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/strnum": { "node_modules/strnum": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
@@ -13192,6 +13451,98 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz",
"integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -13592,6 +13943,36 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-node/node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": { "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -14022,6 +14403,119 @@
"@esbuild/win32-x64": "0.21.5" "@esbuild/win32-x64": "0.21.5"
} }
}, },
"node_modules/vitest": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
@@ -14182,6 +14676,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wide-align": { "node_modules/wide-align": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+5 -2
View File
@@ -8,13 +8,15 @@
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", "build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js", "start": "NODE_ENV=production node dist/index.js",
"check": "tsc", "check": "tsc",
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1016.0", "@aws-sdk/client-s3": "^3.1016.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/montserrat": "^5.3.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",
@@ -130,7 +132,8 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "^5.4.20" "vite": "^5.4.20",
"vitest": "^3.2.7"
}, },
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.8" "bufferutil": "^4.0.8"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

+11
View File
@@ -0,0 +1,11 @@
# Fichiers servis à la racine du domaine
Le contenu de ce dossier est exposé publiquement, sans authentification, à la
racine du domaine (`https://<domaine>/<nom-du-fichier>`).
Il sert aux fichiers de vérification de propriété du domaine réclamés par les
plateformes : TikTok (`tiktok<clé>.txt`, requis par « URL properties » avant de
pouvoir utiliser la Content Posting API), Google Search Console, Meta, etc.
Pour en ajouter un, il suffit de déposer le fichier ici — aucune modification de
code n'est nécessaire, et le fichier est servi sans reconstruire le frontend.
@@ -0,0 +1 @@
tiktok-developers-site-verification=Hjd5evuj6TppdUaAJmwmDg34f1fs0qMs
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env node
// Pre-bundle Remotion composition during Docker build.
// This warms up the Webpack cache so the first user render is fast.
const { bundle } = require('@remotion/bundler');
const path = require('path');
bundle({ entryPoint: path.resolve(__dirname, '../client/src/remotion/index.ts') })
.then(loc => console.log('✅ Remotion pre-bundle OK:', loc))
.catch(e => { console.warn('⚠️ Remotion pre-bundle skipped:', e.message); process.exit(0); });
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
// Pré-bundle des compositions Remotion pendant le build Docker : le cache
// Webpack est chaud, le premier rendu d'un utilisateur démarre plus vite.
// Même configuration que server/services/reels/remotionRenderer.ts.
import path from "node:path";
import { fileURLToPath } from "node:url";
import { bundle } from "@remotion/bundler";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
bundle({
entryPoint: path.join(root, "client/src/remotion/index.ts"),
webpackOverride: (config) => ({
...config,
resolve: {
...config.resolve,
alias: { ...(config.resolve?.alias ?? {}), "@shared": path.join(root, "shared") },
},
}),
})
.then((location) => console.log("✅ Remotion pre-bundle OK:", location))
.catch((error) => {
console.warn("⚠️ Remotion pre-bundle skipped:", error.message);
process.exit(0);
});
+21 -5
View File
@@ -2,14 +2,30 @@ import cron from 'node-cron';
import { TokenManager } from './services/token_manager'; import { TokenManager } from './services/token_manager';
import { AnalyticsService } from './services/analytics'; import { AnalyticsService } from './services/analytics';
import { purgeOldMedia } from './services/media-purge'; import { purgeOldMedia } from './services/media-purge';
import { tiktokService } from './services/tiktok';
export function startTokenCron() { export function startTokenCron() {
// Run every day at midnight (00:00) — Token health check // Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
cron.schedule('0 0 * * *', async () => { cron.schedule('*/2 * * * *', async () => {
console.log('[Cron] Running daily token check...'); try {
await TokenManager.checkAndRefreshTokens(); await tiktokService.syncPendingPublications();
} catch (error) {
console.error('[Cron] TikTok publish status sync failed:', error);
}
}); });
console.log('[Cron] Token refresh job scheduled (0 0 * * *).'); console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
// Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons.
// Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour
// réduit d'autant la fenêtre pendant laquelle une publication échouerait.
cron.schedule('0 0,12 * * *', async () => {
console.log('[Cron] Running token check...');
const results = await TokenManager.checkAndRefreshTokens();
const renewed = results.filter(r => r.renewed).length;
const failing = results.filter(r => r.status === 'expired' || r.status === 'error').length;
console.log(`[Cron] Token check done: ${results.length} contrôlés, ${renewed} renouvelés, ${failing} en échec.`);
});
console.log('[Cron] Token refresh job scheduled (0 0,12 * * *).');
// Run twice daily (08:00 and 20:00) — Analytics sync for all pages // Run twice daily (08:00 and 20:00) — Analytics sync for all pages
cron.schedule('0 8,20 * * *', async () => { cron.schedule('0 8,20 * * *', async () => {
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more