Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d47763b29 | ||
|
|
f5e2461da1 | ||
|
|
9b66a6dc5a | ||
|
|
3c6a66bf64 | ||
|
|
d24ab0d4b7 | ||
|
|
fcbc6b706f | ||
|
|
3af7f9418b | ||
|
|
94e961d0f1 | ||
|
|
25bf0981bd | ||
|
|
d7f002a64f | ||
|
|
55e502aa8d | ||
|
|
ac8b494f24 | ||
|
|
c69fe4e462 | ||
|
|
0570088722 | ||
|
|
3e00a3ca67 | ||
|
|
d0f63c68e4 | ||
|
|
b95cd23ebc |
No files matched your search
@@ -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,102 @@ 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=
|
||||||
|
#
|
||||||
|
# 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=
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 125 KiB |
@@ -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 || [];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}%
|
||||||
|
|||||||
@@ -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,197 @@
|
|||||||
|
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,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
@@ -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">
|
||||||
@@ -1069,7 +1056,7 @@ export default function NewReel() {
|
|||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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;
|
||||||
|
/** 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, 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));
|
||||||
|
const hasAddedAudio = Boolean(mixedAudioUrl || voiceUrl || musicUrl);
|
||||||
|
const video = (
|
||||||
|
<OffthreadVideo src={videoUrl} muted={hasAddedAudio} 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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={FADE_SECONDS} />}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Aperçu : la musique baisse pendant la parole et s'éteint avec le fondu final. */
|
||||||
|
function previewMusicVolume(time: number, words: TimedWord[], base: number, fadeStart: number | null): 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 + FADE_SECONDS], [1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
return (speaking ? base * 0.35 : base) * fade;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 168 KiB |
@@ -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:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
tests
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
@@ -1,38 +1,39 @@
|
|||||||
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')"
|
||||||
|
|
||||||
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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Service audio et vidéo des Reels SocialFlow."""
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe_sync(audio: Path, hint: str | None) -> list[Word]:
|
||||||
|
segments, _ = _model().transcribe(
|
||||||
|
str(audio),
|
||||||
|
language="fr",
|
||||||
|
word_timestamps=True,
|
||||||
|
initial_prompt=hint or None,
|
||||||
|
vad_filter=False,
|
||||||
|
beam_size=5,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
Word(w.word.strip(), float(w.start), float(w.end))
|
||||||
|
for segment in segments
|
||||||
|
for w in (segment.words or [])
|
||||||
|
if w.word.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe(audio: Path, hint: str | None = None) -> list[Word]:
|
||||||
|
"""Mots prononcés et leurs instants (Whisper, exécuté hors de la boucle async)."""
|
||||||
|
return await asyncio.to_thread(_transcribe_sync, audio, hint)
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""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,
|
||||||
|
"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, hint=display)
|
||||||
|
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))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Traitement de la voix : filtrage, compression et niveau sonore constant."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import proc
|
||||||
|
|
||||||
|
# Coupe les basses inutiles, lisse la dynamique et amène la voix à -16 LUFS :
|
||||||
|
# elle reste intelligible par-dessus la musique sans saturer.
|
||||||
|
VOICE_CHAIN = (
|
||||||
|
"highpass=f=80,"
|
||||||
|
"acompressor=threshold=0.1:ratio=3:attack=5:release=120:makeup=2,"
|
||||||
|
"loudnorm=I=-16:TP=-1.5:LRA=7,"
|
||||||
|
"aresample=48000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_voice(source: Path, target: Path) -> None:
|
||||||
|
"""Produit un WAV 48 kHz mono prêt à mixer."""
|
||||||
|
await proc.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
str(source),
|
||||||
|
"-af",
|
||||||
|
VOICE_CHAIN,
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
str(target),
|
||||||
|
],
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""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")
|
||||||
|
|
||||||
|
# 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")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""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) -> str:
|
||||||
|
"""Lance une commande et renvoie sa sortie standard."""
|
||||||
|
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 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 "",
|
||||||
|
)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""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
|
||||||
|
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:
|
||||||
|
return max(0.0, self.total_duration - FADE_SECONDS)
|
||||||
|
|
||||||
|
@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 audio_graph(
|
||||||
|
plan: RenderPlan, music_idx: int | None, voice_idx: int | None
|
||||||
|
) -> tuple[list[str], str | None]:
|
||||||
|
"""Graphe audio : voix décalée, musique bouclée et baissée 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]")
|
||||||
|
if music_idx is not None:
|
||||||
|
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[music]")
|
||||||
|
|
||||||
|
if voice_idx is not None and music_idx is not None:
|
||||||
|
# La musique baisse automatiquement quand la voix parle (ducking)
|
||||||
|
graph.append("[voice]asplit=2[vmix][vkey]")
|
||||||
|
graph.append("[music][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 music_idx is not None:
|
||||||
|
mix = "music"
|
||||||
|
else:
|
||||||
|
return graph, None
|
||||||
|
|
||||||
|
tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
|
||||||
|
if plan.ending_effect:
|
||||||
|
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||||
|
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={FADE_SECONDS}")
|
||||||
|
tail.append("format=yuv420p")
|
||||||
|
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
||||||
|
|
||||||
|
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx)
|
||||||
|
graph += audio_filters
|
||||||
|
if audio_out:
|
||||||
|
audio_map = ["-map", f"[{audio_out}]"]
|
||||||
|
elif plan.keep_original_audio:
|
||||||
|
audio_map = ["-map", "0:a:0"]
|
||||||
|
else:
|
||||||
|
audio_map = []
|
||||||
|
|
||||||
|
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}"
|
||||||
|
if not plan.music and not plan.voice:
|
||||||
|
if not plan.keep_original_audio:
|
||||||
|
return None
|
||||||
|
# Son d'origine seul : même niveau final que les autres Reels
|
||||||
|
chain = ["aresample=48000", "loudnorm=I=-14:TP=-1.5:LRA=11", "apad"]
|
||||||
|
if plan.ending_effect:
|
||||||
|
chain.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||||
|
return [
|
||||||
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
|
||||||
|
"-map", "0:a:0", "-af", ",".join(chain), "-t", total,
|
||||||
|
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
audio_args, music_idx, voice_idx, _ = _audio_inputs(plan, 0)
|
||||||
|
graph, audio_out = audio_graph(plan, music_idx, voice_idx)
|
||||||
|
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
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Voix calée sur un fichier SRT.
|
||||||
|
|
||||||
|
Chaque sous-titre est lu séparément puis posé à son instant de début. Si la
|
||||||
|
lecture est plus longue que le sous-titre, elle est accélérée (sans changer
|
||||||
|
la hauteur de la voix) pour finir à temps : la voix respecte le minutage du
|
||||||
|
fichier, au lieu d'un texte lu d'une traite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import proc, tts
|
||||||
|
from .align import Word
|
||||||
|
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
|
||||||
|
# Marges gardées autour des mots lors du retrait des silences de la voix
|
||||||
|
TRIM_LEAD = 0.05
|
||||||
|
TRIM_TAIL = 0.12
|
||||||
|
# Au-delà, l'accélération s'entend nettement : l'utilisateur est prévenu
|
||||||
|
AUDIBLE_SPEEDUP = 1.35
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Cue:
|
||||||
|
start: float
|
||||||
|
end: float
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
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 spoken_span(words: list[Word], duration: float) -> tuple[float, float]:
|
||||||
|
"""Partie parlée de la voix : les silences de début et de fin sont retirés."""
|
||||||
|
if not words:
|
||||||
|
return 0.0, duration
|
||||||
|
start = max(0.0, words[0].start - TRIM_LEAD)
|
||||||
|
end = min(duration, words[-1].end + TRIM_TAIL)
|
||||||
|
return (start, end) if end > start else (0.0, duration)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
windows = cue_windows(cues)
|
||||||
|
segments: list[tuple[Path, float]] = []
|
||||||
|
words: list[Word] = []
|
||||||
|
used_engine, used_voice = engine or "gemini", voice or ""
|
||||||
|
|
||||||
|
for index, (cue, window) in enumerate(zip(cues, windows)):
|
||||||
|
cue_dir = workdir / f"cue_{index:03d}"
|
||||||
|
cue_dir.mkdir(exist_ok=True)
|
||||||
|
text = clean_text(cue.text)
|
||||||
|
track = await tts.synthesize(
|
||||||
|
text=text,
|
||||||
|
display_text=text,
|
||||||
|
engine=used_engine,
|
||||||
|
voice=voice,
|
||||||
|
style=style,
|
||||||
|
gemini_api_key=gemini_api_key,
|
||||||
|
workdir=cue_dir,
|
||||||
|
)
|
||||||
|
for warning in track.warnings:
|
||||||
|
if warning not in warnings:
|
||||||
|
warnings.append(warning)
|
||||||
|
# Après un repli sur Edge, on reste sur Edge : une seule voix du début à la fin
|
||||||
|
used_engine, used_voice = track.engine, track.voice
|
||||||
|
|
||||||
|
span_start, span_end = spoken_span(track.words, track.duration)
|
||||||
|
factor = speed_factor(span_end - span_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 = cue_dir / "fitted.wav"
|
||||||
|
trim = f"atrim=start={span_start:.3f}:end={span_end:.3f}"
|
||||||
|
await proc.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
str(track.path),
|
||||||
|
"-af",
|
||||||
|
f"{trim},asetpts=PTS-STARTPTS,{atempo_chain(factor)}",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
str(fitted),
|
||||||
|
],
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
segments.append((fitted, cue.start))
|
||||||
|
for w in track.words:
|
||||||
|
start = cue.start + max(0.0, w.start - span_start) / factor
|
||||||
|
end = cue.start + max(0.0, w.end - span_start) / factor
|
||||||
|
words.append(Word(w.text, start, min(max(end, start + 0.05), cue.start + window)))
|
||||||
|
|
||||||
|
output = workdir / "voice.wav"
|
||||||
|
await proc.run(mix_command(segments, output), timeout=300)
|
||||||
|
duration = (await proc.probe(output)).duration
|
||||||
|
log.info("Voix SRT prête : %s/%s, %d sous-titres, %.1f s", used_engine, used_voice, len(cues), duration)
|
||||||
|
return tts.VoiceTrack(output, duration, _monotonic(words), used_engine, used_voice, warnings)
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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("'", "\\'")
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""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, proc
|
||||||
|
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] = []
|
||||||
|
raw: 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:
|
||||||
|
try:
|
||||||
|
raw, used_voice = await gemini.synthesize(text, voice, style, gemini_api_key, workdir)
|
||||||
|
used_engine = "gemini"
|
||||||
|
except Exception as error: # noqa: BLE001 — repli sur Edge
|
||||||
|
log.warning("Gemini TTS en échec, repli sur Edge : %s", error)
|
||||||
|
warnings.append(f"Gemini indisponible ({error}) : voix Edge utilisée à la place.")
|
||||||
|
|
||||||
|
if raw 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:
|
||||||
|
# Gemini ne donne pas le minutage : Whisper le retrouve dans l'audio
|
||||||
|
spoken = await align.transcribe(processed, hint=text)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""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."""
|
||||||
|
instruction = style_instruction(style)
|
||||||
|
return f"{instruction} :\n{text}" if instruction else text
|
||||||
|
|
||||||
|
|
||||||
|
async def _request(model: str, prompt: str, voice: str, api_key: str) -> dict:
|
||||||
|
payload = {
|
||||||
|
"contents": [{"parts": [{"text": prompt}]}],
|
||||||
|
"generationConfig": {
|
||||||
|
"responseModalities": ["AUDIO"],
|
||||||
|
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
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
|
||||||
|
) -> tuple[Path, str]:
|
||||||
|
"""Génère la voix ; renvoie un WAV brut et le nom de la voix utilisée."""
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 / "gemini_raw.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 / "gemini.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
|
||||||
@@ -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()],
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.5
|
||||||
|
ruff==0.11.8
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
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
|
|
||||||
httpx>=0.25.0
|
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("API_KEY", "test-key")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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 cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "0:a:0"
|
||||||
|
|
||||||
|
|
||||||
|
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")))
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.align import Word, spread_words
|
||||||
|
from app.render import RenderPlan
|
||||||
|
from app.srt_voice import Cue, atempo_chain, cue_windows, mix_command, speed_factor, spoken_span
|
||||||
|
|
||||||
|
|
||||||
|
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_spoken_span_trims_silences_around_words():
|
||||||
|
words = [Word("Bonjour", 0.4, 0.9), Word("!", 0.9, 1.2)]
|
||||||
|
assert [round(t, 3) for t in spoken_span(words, 2.0)] == [0.35, 1.32]
|
||||||
|
assert spoken_span([], 2.0) == (0.0, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 168 KiB |
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 179 KiB |
@@ -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
|
||||||
@@ -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); });
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { schedulerService } from "./services/scheduler";
|
|||||||
import { ensureAdminUserExists } from "./init-admin";
|
import { ensureAdminUserExists } from "./init-admin";
|
||||||
import { startTokenCron } from "./cron";
|
import { startTokenCron } from "./cron";
|
||||||
import { migrate } from "./migrate";
|
import { migrate } from "./migrate";
|
||||||
|
import { startReelProcessing } from "./services/reels";
|
||||||
import { jamendoService } from "./services/jamendo";
|
import { jamendoService } from "./services/jamendo";
|
||||||
import { ffmpegService } from "./services/ffmpeg";
|
import { ffmpegService } from "./services/ffmpeg";
|
||||||
|
|
||||||
@@ -106,8 +107,10 @@ if (!process.env.SESSION_SECRET) {
|
|||||||
// Générer un secret aléatoire pour le dev si non défini
|
// Générer un secret aléatoire pour le dev si non défini
|
||||||
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// Déterminer si on utilise HTTPS basé sur APP_URL
|
// HTTPS déduit d'APP_URL ; à défaut, 'auto' laisse express-session décider par
|
||||||
const isHttps = process.env.APP_URL?.startsWith('https://') || false;
|
// requête à partir de X-Forwarded-Proto (cf. `trust proxy` plus haut), ce qui
|
||||||
|
// évite un cookie non sécurisé derrière un reverse proxy HTTPS sans APP_URL.
|
||||||
|
const isHttps: boolean | 'auto' = process.env.APP_URL?.startsWith('https://') || 'auto';
|
||||||
|
|
||||||
// Configuration du store de session pour production
|
// Configuration du store de session pour production
|
||||||
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
|
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
|
||||||
@@ -228,5 +231,11 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
// Start Token Refresh Cron
|
// Start Token Refresh Cron
|
||||||
startTokenCron();
|
startTokenCron();
|
||||||
|
|
||||||
|
// File des rendus de Reels : démarrée une fois le serveur à l'écoute, car
|
||||||
|
// le rendu d'images lit la voix et la musique via http://localhost.
|
||||||
|
startReelProcessing().catch((error) => {
|
||||||
|
console.error("❌ Démarrage de la file des Reels impossible :", error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -17,6 +17,9 @@ export async function migrate() {
|
|||||||
// Add 'reel' to post_type enum if not exists
|
// Add 'reel' to post_type enum if not exists
|
||||||
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`);
|
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`);
|
||||||
|
|
||||||
|
// Add 'tiktok' to platform enum if not exists
|
||||||
|
await client.query(`ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';`);
|
||||||
|
|
||||||
// 2. Create Tables if not exist
|
// 2. Create Tables if not exist
|
||||||
|
|
||||||
// post_analytics
|
// post_analytics
|
||||||
@@ -158,6 +161,126 @@ export async function migrate() {
|
|||||||
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// media.thumbnail_url (vignette conservée après suppression de la vidéo)
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ADD COLUMN IF NOT EXISTS "thumbnail_url" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// tiktok_config (application développeur TikTok, globale)
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "tiktok_config" (
|
||||||
|
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"client_key" text NOT NULL,
|
||||||
|
"client_secret" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// social_pages : champs OAuth TikTok
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "social_pages"
|
||||||
|
ADD COLUMN IF NOT EXISTS "refresh_token" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp,
|
||||||
|
ADD COLUMN IF NOT EXISTS "scopes" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "avatar_url" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// facebook_config (application développeur Facebook, globale)
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "facebook_config" (
|
||||||
|
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"app_id" text NOT NULL,
|
||||||
|
"app_secret" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// social_pages : renouvellement automatique des tokens Facebook
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "social_pages"
|
||||||
|
ADD COLUMN IF NOT EXISTS "user_access_token" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp,
|
||||||
|
ADD COLUMN IF NOT EXISTS "token_error" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// scheduled_posts : suivi de la publication asynchrone TikTok
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "scheduled_posts"
|
||||||
|
ADD COLUMN IF NOT EXISTS "publish_id" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "publish_status" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// reel_jobs : file d'attente persistante des rendus de Reels
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "reel_jobs" (
|
||||||
|
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||||
|
"post_id" varchar REFERENCES "posts"("id") ON DELETE cascade,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"params" jsonb NOT NULL,
|
||||||
|
"status" text NOT NULL DEFAULT 'pending',
|
||||||
|
"step" text,
|
||||||
|
"progress" integer NOT NULL DEFAULT 0,
|
||||||
|
"attempts" integer NOT NULL DEFAULT 0,
|
||||||
|
"result" jsonb,
|
||||||
|
"error" text,
|
||||||
|
"heartbeat_at" timestamp,
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamp NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. Index de performance
|
||||||
|
//
|
||||||
|
// Postgres n'indexe pas les clés étrangères tout seul : sans ces index, le
|
||||||
|
// calendrier, l'historique et le scheduler faisaient des parcours complets
|
||||||
|
// de table à chaque appel. Ils existaient dans migration-docker.sql, qui
|
||||||
|
// n'est monté nulle part — ce fichier-ci est le seul exécuté au démarrage.
|
||||||
|
//
|
||||||
|
// Chaque index est créé isolément : une table absente sur une vieille
|
||||||
|
// installation ne doit pas empêcher la création des suivants.
|
||||||
|
const indexes: [string, string][] = [
|
||||||
|
// Filtres par propriétaire
|
||||||
|
["idx_posts_user_id", `CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")`],
|
||||||
|
["idx_media_user_id", `CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")`],
|
||||||
|
["idx_social_pages_user_id", `CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")`],
|
||||||
|
["idx_ai_generations_user_id", `CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")`],
|
||||||
|
|
||||||
|
// Jointures
|
||||||
|
["idx_scheduled_posts_post_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")`],
|
||||||
|
["idx_post_media_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")`],
|
||||||
|
["idx_post_media_media_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")`],
|
||||||
|
["idx_post_analytics_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")`],
|
||||||
|
["idx_page_analytics_history_page_date", `CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")`],
|
||||||
|
["idx_user_page_permissions_user_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")`],
|
||||||
|
["idx_user_page_permissions_page_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")`],
|
||||||
|
|
||||||
|
// Calendrier et historique : « les publications de ces pages, sur cette période »
|
||||||
|
["idx_scheduled_posts_page_scheduled_at", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")`],
|
||||||
|
|
||||||
|
// Scheduler (toutes les minutes) : seules les publications non encore
|
||||||
|
// traitées comptent, d'où un index partiel qui reste minuscule.
|
||||||
|
["idx_scheduled_posts_pending", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL`],
|
||||||
|
|
||||||
|
// Suivi asynchrone TikTok (toutes les deux minutes)
|
||||||
|
["idx_scheduled_posts_publish_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL`],
|
||||||
|
|
||||||
|
// Reels en cours de génération
|
||||||
|
["idx_reel_jobs_queue", `CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')`],
|
||||||
|
["idx_posts_generation_status", `CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL`],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, statement] of indexes) {
|
||||||
|
try {
|
||||||
|
await client.query(statement);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Migration] Index ${name} non créé:`, error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[Migration] Safe migration completed.");
|
console.log("[Migration] Safe migration completed.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Migration] Error during migration:", error);
|
console.error("[Migration] Error during migration:", error);
|
||||||
|
|||||||