Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8fbe6d8e | ||
|
|
7c38ec459f | ||
|
|
d4c544e467 | ||
|
|
44899055b2 | ||
|
|
f3113a0f5a | ||
|
|
1d47763b29 | ||
|
|
f5e2461da1 | ||
|
|
9b66a6dc5a | ||
|
|
3c6a66bf64 | ||
|
|
d24ab0d4b7 |
No files matched your search
@@ -105,3 +105,39 @@ LEGAL_CONTACT_EMAIL=
|
||||
# Clé d'accès pour l'API externe /api/v1/*
|
||||
# Générer avec: openssl rand -hex 32
|
||||
EXTERNAL_API_KEY=your-secure-random-key-here
|
||||
|
||||
# ==========================================
|
||||
# REELS : VOIX ET RENDU VIDÉO (service ffmpeg-api)
|
||||
# ==========================================
|
||||
# Clé partagée entre l'application et le service FFmpeg (CHANGEZ CETTE VALEUR).
|
||||
FFMPEG_API_KEY=change-me-ffmpeg-key
|
||||
#
|
||||
# Clé Google AI Studio pour les voix Gemini (sinon : voix Edge, gratuite).
|
||||
# Peut aussi être saisie dans l'application, ce qui est prioritaire.
|
||||
GEMINI_API_KEY=
|
||||
#
|
||||
# Modèle Gemini TTS (défaut : gemini-2.5-flash-preview-tts ; repli automatique
|
||||
# sur celui-ci si le modèle choisi n'existe pas).
|
||||
# GEMINI_TTS_MODEL=
|
||||
#
|
||||
# Stabilité de la voix Gemini : température basse et graine fixe donnent la
|
||||
# même intonation d'une génération à l'autre ; chaque lecture est réécoutée et
|
||||
# régénérée (jusqu'à GEMINI_TTS_ATTEMPTS fois) si des mots manquent.
|
||||
# GEMINI_TTS_TEMPERATURE=0.3
|
||||
# GEMINI_TTS_SEED=20260928
|
||||
# GEMINI_TTS_ATTEMPTS=3
|
||||
#
|
||||
# Modèle Whisper utilisé pour caler les sous-titres mot à mot sur la voix
|
||||
# (tiny, base, small). Choisi à la construction de l'image : changer la valeur
|
||||
# demande de reconstruire ffmpeg-api.
|
||||
# WHISPER_MODEL=base
|
||||
#
|
||||
# Moteur de rendu des Reels vidéo :
|
||||
# - remotion (défaut) : sous-titres animés, logo et effet de fin identiques à
|
||||
# l'aperçu affiché dans l'application ; ~30 à 60 s pour un Reel de 30 s ;
|
||||
# - ffmpeg : plus rapide (~10 s), sous-titres karaoké classiques.
|
||||
# REEL_RENDERER=remotion
|
||||
#
|
||||
# Onglets Chromium utilisés en parallèle par Remotion (défaut : moitié des cœurs).
|
||||
# Réduisez-le si le serveur manque de mémoire.
|
||||
# RENDER_CONCURRENCY=
|
||||
@@ -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
|
||||
@@ -174,6 +174,24 @@ La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
|
||||
`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
|
||||
|
||||
1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env`
|
||||
|
||||
@@ -70,7 +70,7 @@ RUN rm -rf client/node_modules \
|
||||
/tmp/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)
|
||||
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 |
@@ -4,16 +4,20 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import type { Post } from "@shared/schema";
|
||||
|
||||
/** Labels for each progress stage */
|
||||
function getStageLabel(progress: number, status?: string): string {
|
||||
if (status === 'pending') return "En attente de traitement...";
|
||||
if (progress <= 5) return "Démarrage du traitement…";
|
||||
if (progress <= 15) return "Traitement vidéo (FFmpeg)…";
|
||||
if (progress <= 65) return "Encodage en cours…";
|
||||
if (progress <= 85) return "Upload vers le cloud…";
|
||||
if (progress <= 90) return "Liaison du média…";
|
||||
if (progress <= 95) return "Publication en cours…";
|
||||
return "Finalisation…";
|
||||
type OngoingReel = Post & { generationStep?: string | null };
|
||||
|
||||
/** Libellé de l'étape en cours, telle que rapportée par la file de rendu. */
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
prepare: "Préparation…",
|
||||
voice: "Voix et préparation de la vidéo…",
|
||||
render: "Montage : sous-titres, logo, effets…",
|
||||
store: "Enregistrement dans la médiathèque…",
|
||||
publish: "Publication…",
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -22,12 +26,13 @@ interface OngoingReelsProps {
|
||||
}
|
||||
|
||||
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
const { data: ongoingPosts = [] } = useQuery<Post[]>({
|
||||
const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
|
||||
queryKey: ["/api/reels/ongoing"],
|
||||
// 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?.length ?? 0) > 0 ? 3000 : 30000),
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000,
|
||||
});
|
||||
|
||||
// Nothing to show — render nothing (not even a card)
|
||||
@@ -43,8 +48,12 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
>
|
||||
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
|
||||
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
Génération en cours
|
||||
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-destructive" />
|
||||
)}
|
||||
Reels en cours
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
||||
@@ -73,8 +82,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
{isFailed ? (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-destructive">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs">
|
||||
{post.generationError || "Erreur lors du traitement"}
|
||||
<span className="text-xs break-words">
|
||||
Échec : {post.generationError || "erreur lors du traitement"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -87,7 +96,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getStageLabel(progress)}
|
||||
{getStageLabel(post)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{progress}%
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CAPTION_STYLE_OPTIONS, type CaptionStyle } from "@shared/captions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Aperçu miniature de chaque style de sous-titres. */
|
||||
const SAMPLE: Record<CaptionStyle, React.ReactNode> = {
|
||||
bold: (
|
||||
<span className="font-black uppercase tracking-tight" style={{ WebkitTextStroke: "1px #000", paintOrder: "stroke fill" }}>
|
||||
<span className="text-white">Nos </span>
|
||||
<span className="text-yellow-300">promos</span>
|
||||
</span>
|
||||
),
|
||||
neon: (
|
||||
<span className="font-extrabold">
|
||||
<span className="rounded px-1 bg-black/50 text-white">Nos</span>{" "}
|
||||
<span className="rounded px-1 bg-violet-600 text-white">promos</span>
|
||||
</span>
|
||||
),
|
||||
minimal: (
|
||||
<span className="font-bold">
|
||||
<span className="text-white">Nos </span>
|
||||
<span className="text-white/45">promos</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
export function CaptionStylePicker({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
value: CaptionStyle;
|
||||
onChange: (style: CaptionStyle) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2" role="radiogroup" aria-label="Style des sous-titres">
|
||||
{CAPTION_STYLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === option.id}
|
||||
title={option.description}
|
||||
onClick={() => onChange(option.id)}
|
||||
className={cn(
|
||||
"rounded-lg border p-2 text-left transition-colors",
|
||||
value === option.id ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md bg-gradient-to-br from-slate-700 to-slate-900",
|
||||
compact ? "h-9 text-[11px]" : "h-12 text-sm",
|
||||
)}
|
||||
>
|
||||
{SAMPLE[option.id]}
|
||||
</div>
|
||||
<p className={cn("mt-1 font-medium", compact ? "text-[11px]" : "text-xs")}>{option.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Player } from "@remotion/player";
|
||||
import { Loader2, Info } from "lucide-react";
|
||||
import {
|
||||
REEL_FPS,
|
||||
REEL_HEIGHT,
|
||||
REEL_WIDTH,
|
||||
VOICE_DELAY,
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
offsetWords,
|
||||
spreadWords,
|
||||
type CaptionStyle,
|
||||
type TimedWord,
|
||||
} from "@shared/captions";
|
||||
import { srtEnd, srtWords, type SrtCue } from "@shared/srt";
|
||||
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
|
||||
import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition";
|
||||
import type { VoicePreviewResult } from "./voice-picker";
|
||||
|
||||
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
|
||||
const WORDS_PER_SECOND = 2.6;
|
||||
|
||||
interface CommonProps {
|
||||
text: string;
|
||||
showCaptions: boolean;
|
||||
captionStyle: CaptionStyle;
|
||||
ttsEnabled: boolean;
|
||||
/** Voix générée pour ce texte et ces réglages, sinon null (minutage estimé). */
|
||||
voice: VoicePreviewResult | null;
|
||||
musicUrl?: string;
|
||||
musicVolume: number;
|
||||
logoUrl?: string | null;
|
||||
/** Petit logo pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */
|
||||
showWatermark: boolean;
|
||||
storeName?: string;
|
||||
endingEffect: boolean;
|
||||
/** Sous-titres SRT : remplacent `text`, minutés par le fichier. */
|
||||
srtCues?: SrtCue[] | null;
|
||||
}
|
||||
|
||||
type ReelPreviewProps =
|
||||
| (CommonProps & { kind: "video"; videoUrl: string })
|
||||
| (CommonProps & { kind: "images"; images: string[] });
|
||||
|
||||
/** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */
|
||||
function useVideoDuration(url: string | undefined): number | null {
|
||||
const [duration, setDuration] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
setDuration(null);
|
||||
if (!url) return;
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => setDuration(Number.isFinite(video.duration) ? video.duration : 15);
|
||||
video.onerror = () => setDuration(15);
|
||||
video.src = url;
|
||||
return () => {
|
||||
video.onloadedmetadata = null;
|
||||
video.onerror = null;
|
||||
video.src = "";
|
||||
};
|
||||
}, [url]);
|
||||
return duration;
|
||||
}
|
||||
|
||||
function estimatedVoiceDuration(text: string): number {
|
||||
const words = cleanCaptionText(text).split(" ").filter(Boolean).length;
|
||||
return words ? words / WORDS_PER_SECOND + 0.4 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aperçu du Reel dans le navigateur, avec les mêmes compositions Remotion que
|
||||
* le rendu final : sous-titres, logo, effet de fin et durée identiques.
|
||||
* Sans voix générée, le minutage des mots est estimé.
|
||||
*/
|
||||
export function ReelPreview(props: ReelPreviewProps) {
|
||||
const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined);
|
||||
const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } =
|
||||
props;
|
||||
const logoUrl = props.logoUrl ?? undefined;
|
||||
const storeName = endingEffect ? props.storeName || undefined : undefined;
|
||||
const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null;
|
||||
const cleanText = cleanCaptionText(text);
|
||||
const voiceDuration = srtCues
|
||||
? ttsEnabled ? srtEnd(srtCues) : 0
|
||||
: ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0;
|
||||
const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice;
|
||||
|
||||
const composition = useMemo(() => {
|
||||
if (props.kind === "images") {
|
||||
if (props.images.length === 0) return null;
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: props.images.length,
|
||||
voiceDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
const contentEnd = total - endingSeconds;
|
||||
let words: TimedWord[] = [];
|
||||
if (showCaptions && cleanText) {
|
||||
words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8));
|
||||
}
|
||||
const inputProps: ImageCompositionProps = {
|
||||
images: props.images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle,
|
||||
audioUrl: voice?.audioUrl,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
logoUrl,
|
||||
showWatermark,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
};
|
||||
return { component: ImageComposition, inputProps, total };
|
||||
}
|
||||
|
||||
if (videoDuration == null) return null;
|
||||
const timing = computeReelTiming({
|
||||
videoDuration,
|
||||
voiceDuration: voiceDuration || undefined,
|
||||
hasOutro: Boolean(logoUrl),
|
||||
endingEffect,
|
||||
voiceDelay: srtCues ? 0 : VOICE_DELAY,
|
||||
});
|
||||
let words: TimedWord[] = [];
|
||||
if (srtCues) {
|
||||
// La voix de l'aperçu n'est pas générée sous-titre par sous-titre : minutage du fichier
|
||||
if (showCaptions) words = srtWords(srtCues);
|
||||
} else if (showCaptions && cleanText) {
|
||||
if (voice) words = offsetWords(voice.words, VOICE_DELAY);
|
||||
else if (ttsEnabled) words = spreadWords(text, VOICE_DELAY, VOICE_DELAY + voiceDuration);
|
||||
else words = spreadWords(text, 0.5, (timing.logoStart ?? timing.total) - 0.5);
|
||||
}
|
||||
const inputProps: ReelVideoProps = {
|
||||
videoUrl: props.videoUrl,
|
||||
videoDuration,
|
||||
totalDuration: timing.total,
|
||||
words,
|
||||
captionStyle,
|
||||
logoUrl,
|
||||
showWatermark,
|
||||
storeName,
|
||||
logoStart: timing.logoStart,
|
||||
fadeStart: timing.fadeStart,
|
||||
fadeDuration: timing.fadeDuration,
|
||||
voiceUrl: srtCues ? undefined : voice?.audioUrl,
|
||||
voiceDelay: VOICE_DELAY,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
};
|
||||
return { component: ReelVideo, inputProps, total: timing.total };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.kind,
|
||||
props.kind === "video" ? props.videoUrl : props.images.join("|"),
|
||||
videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
|
||||
musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect,
|
||||
]);
|
||||
|
||||
if (!composition) {
|
||||
return (
|
||||
<div className="aspect-[9/16] w-full rounded-lg bg-black flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-white/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-hidden rounded-lg bg-black">
|
||||
<Player
|
||||
// Nouvelle instance quand la durée change : le lecteur repart du début
|
||||
key={`${props.kind}-${composition.total}`}
|
||||
component={composition.component as React.ComponentType<Record<string, unknown>>}
|
||||
inputProps={composition.inputProps as unknown as Record<string, unknown>}
|
||||
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
|
||||
fps={REEL_FPS}
|
||||
compositionWidth={REEL_WIDTH}
|
||||
compositionHeight={REEL_HEIGHT}
|
||||
style={{ width: "100%", aspectRatio: "9 / 16" }}
|
||||
controls
|
||||
loop
|
||||
acknowledgeRemotionLicense
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
||||
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
{srtCues
|
||||
? `Minutage du fichier SRT (la voix sera posée sur chaque sous-titre au rendu) · ${composition.total.toFixed(1)} s`
|
||||
: estimated
|
||||
? "Minutage estimé : cliquez sur « Tester la voix » pour caler les sous-titres sur la vraie voix."
|
||||
: `Aperçu fidèle au rendu final · ${composition.total.toFixed(1)} s`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useRef } from "react";
|
||||
import { FileText, Upload, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatSrtTime, parseSrt, srtEnd, type SrtCue } from "@shared/srt";
|
||||
|
||||
const MAX_SRT_BYTES = 512 * 1024;
|
||||
|
||||
export interface SrtFile {
|
||||
name: string;
|
||||
cues: SrtCue[];
|
||||
}
|
||||
|
||||
interface SrtUploadProps {
|
||||
value: SrtFile | null;
|
||||
onChange: (value: SrtFile | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import d'un fichier de sous-titres SRT. Tant qu'un fichier est chargé, il
|
||||
* remplace le texte libre : la voix lit chaque sous-titre à son instant.
|
||||
*/
|
||||
export function SrtUpload({ value, onChange }: SrtUploadProps) {
|
||||
const { toast } = useToast();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
if (file.size > MAX_SRT_BYTES) throw new Error("Fichier SRT trop volumineux (512 Ko au plus)");
|
||||
const cues = parseSrt(await file.text());
|
||||
onChange({ name: file.name, cues });
|
||||
toast({ title: "Sous-titres importés", description: `${cues.length} sous-titre(s) · ${formatSrtTime(srtEnd(cues))}` });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Fichier SRT invalide",
|
||||
description: error instanceof Error ? error.message : "Lecture impossible",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".srt,application/x-subrip,text/plain"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFile(e.target.files?.[0])}
|
||||
/>
|
||||
{value ? (
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 text-sm font-medium">
|
||||
<FileText className="w-4 h-4 shrink-0 text-primary" />
|
||||
<span className="truncate">{value.name}</span>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onChange(null)}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Retirer
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{value.cues.length} sous-titre(s) · jusqu'à {formatSrtTime(srtEnd(value.cues))}. La voix lira chaque
|
||||
sous-titre à son instant et accélérera si besoin pour tenir dans sa durée.
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-y-auto space-y-1 text-xs">
|
||||
{value.cues.map((cue, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="shrink-0 font-mono text-muted-foreground">
|
||||
{formatSrtTime(cue.start)}–{formatSrtTime(cue.end)}
|
||||
</span>
|
||||
<span>{cue.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Importer un fichier SRT
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Play, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, getErrorMessage } from "@/lib/queryClient";
|
||||
import {
|
||||
DEFAULT_VOICE,
|
||||
TTS_STYLE_OPTIONS,
|
||||
voicesFor,
|
||||
type TtsEngine,
|
||||
type TtsStyle,
|
||||
} from "@shared/voices";
|
||||
import type { TimedWord } from "@shared/captions";
|
||||
|
||||
export interface VoiceSettings {
|
||||
engine: TtsEngine;
|
||||
voice: string;
|
||||
style: TtsStyle;
|
||||
}
|
||||
|
||||
/** Voix générée par « Tester la voix », réutilisée par l'aperçu vidéo. */
|
||||
export interface VoicePreviewResult {
|
||||
audioUrl: string;
|
||||
duration: number;
|
||||
words: TimedWord[];
|
||||
/** Texte et réglages ayant servi : l'aperçu n'est valable que s'ils n'ont pas changé. */
|
||||
text: string;
|
||||
settings: VoiceSettings;
|
||||
}
|
||||
|
||||
export function isVoicePreviewCurrent(
|
||||
preview: VoicePreviewResult | null,
|
||||
text: string,
|
||||
settings: VoiceSettings,
|
||||
): preview is VoicePreviewResult {
|
||||
return (
|
||||
!!preview &&
|
||||
preview.text === text.trim() &&
|
||||
preview.settings.engine === settings.engine &&
|
||||
preview.settings.voice === settings.voice &&
|
||||
preview.settings.style === settings.style
|
||||
);
|
||||
}
|
||||
|
||||
interface VoicePickerProps {
|
||||
value: VoiceSettings;
|
||||
onChange: (value: VoiceSettings) => void;
|
||||
/** Texte lu par le bouton « Tester la voix ». */
|
||||
sampleText?: string;
|
||||
compact?: boolean;
|
||||
/** Appelé avec la voix générée (audio et minutage des mots). */
|
||||
onPreview?: (result: VoicePreviewResult) => void;
|
||||
}
|
||||
|
||||
const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !";
|
||||
|
||||
/**
|
||||
* Choix du moteur, de la voix et du ton, avec écoute de l'aperçu.
|
||||
* Partagé par les pages Reel (vidéo et images, bureau et mobile).
|
||||
*/
|
||||
export function VoicePicker({ value, onChange, sampleText, compact = false, onPreview }: VoicePickerProps) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => () => audioRef.current?.pause(), []);
|
||||
|
||||
const voices = voicesFor(value.engine);
|
||||
const groups = [
|
||||
{ label: "Voix féminines", items: voices.filter((v) => v.gender === "female") },
|
||||
{ label: "Voix masculines", items: voices.filter((v) => v.gender === "male") },
|
||||
];
|
||||
|
||||
const setEngine = (engine: TtsEngine) => {
|
||||
if (engine !== value.engine) onChange({ ...value, engine, voice: DEFAULT_VOICE[engine] });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
audioRef.current?.pause();
|
||||
setPlaying(false);
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
if (playing) return stop();
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = sampleText?.trim() || FALLBACK_SAMPLE;
|
||||
const response = await apiRequest("POST", "/api/reels/tts-preview", {
|
||||
text,
|
||||
ttsEngine: value.engine,
|
||||
ttsVoice: value.voice,
|
||||
ttsStyle: value.style,
|
||||
});
|
||||
const data = await response.json();
|
||||
for (const warning of data.warnings ?? []) {
|
||||
toast({ title: "Voix de secours utilisée", description: warning });
|
||||
}
|
||||
const audioUrl = `data:audio/mpeg;base64,${data.audioBase64}`;
|
||||
if (sampleText?.trim()) {
|
||||
onPreview?.({ audioUrl, duration: data.duration, words: data.words ?? [], text, settings: value });
|
||||
}
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setPlaying(false);
|
||||
await audio.play();
|
||||
setPlaying(true);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Impossible de tester la voix",
|
||||
description: getErrorMessage(error, "La voix n'a pas pu être générée."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const labelClass = compact ? "text-xs font-medium" : "text-sm font-medium";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className={`${labelClass} w-14 shrink-0`}>Moteur</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={compact ? "h-7 text-xs" : undefined}
|
||||
variant={value.engine === "gemini" ? "default" : "outline"}
|
||||
onClick={() => setEngine("gemini")}
|
||||
>
|
||||
Gemini (naturelle)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={compact ? "h-7 text-xs" : undefined}
|
||||
variant={value.engine === "edge" ? "default" : "outline"}
|
||||
onClick={() => setEngine("edge")}
|
||||
>
|
||||
Edge (gratuite)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className={`${labelClass} w-14 shrink-0`}>Voix</Label>
|
||||
<Select value={value.voice} onValueChange={(voice) => onChange({ ...value, voice })}>
|
||||
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
|
||||
<SelectValue placeholder="Choisir une voix" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{groups.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.items.map((voice) => (
|
||||
<SelectItem key={voice.id} value={voice.id}>
|
||||
{voice.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className={`${labelClass} w-14 shrink-0`}>Ton</Label>
|
||||
<Select value={value.style} onValueChange={(style) => onChange({ ...value, style: style as TtsStyle })}>
|
||||
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TTS_STYLE_OPTIONS.map((style) => (
|
||||
<SelectItem key={style.id} value={style.id}>
|
||||
{style.label} <span className="text-muted-foreground">— {style.description}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={compact ? "w-full h-8 text-xs" : "w-full"}
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
preview();
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
||||
) : playing ? (
|
||||
<Square className="w-3 h-3 mr-2" />
|
||||
) : (
|
||||
<Play className="w-3 h-3 mr-2" />
|
||||
)}
|
||||
{loading ? "Génération de la voix…" : playing ? "Arrêter" : "Tester la voix"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,13 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
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 type { SocialPage, Media } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
@@ -51,6 +58,8 @@ export default function MobileNewReel() {
|
||||
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
|
||||
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 [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
@@ -59,28 +68,23 @@ export default function MobileNewReel() {
|
||||
|
||||
// État TTS
|
||||
const [ttsEnabled, setTtsEnabled] = useState(false);
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// Gemini native TTS voices (Charon = homme, Kore = femme)
|
||||
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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
|
||||
// État audio preview
|
||||
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 [showLogo, setShowLogo] = useState(true);
|
||||
|
||||
// TTS Sync state
|
||||
const [syncInfo, setSyncInfo] = useState<{
|
||||
@@ -93,7 +97,7 @@ export default function MobileNewReel() {
|
||||
|
||||
// Auto-calculate TTS sync
|
||||
useEffect(() => {
|
||||
if (!ttsEnabled || !overlayText.trim()) {
|
||||
if (!ttsEnabled || !overlayText.trim() || srtFile) {
|
||||
setSyncInfo(null);
|
||||
return;
|
||||
}
|
||||
@@ -108,7 +112,7 @@ export default function MobileNewReel() {
|
||||
.catch(() => setSyncInfo(null));
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
|
||||
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]);
|
||||
|
||||
|
||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||
@@ -211,8 +215,10 @@ export default function MobileNewReel() {
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
toast({
|
||||
title: data.success ? "Reel créé !" : "Attention",
|
||||
description: data.success ? "Publication réussie" : "Certaines erreurs",
|
||||
title: "Création du Reel lancée",
|
||||
description: data.queued
|
||||
? "Un autre Reel est en cours : le vôtre démarrera juste après."
|
||||
: "Suivez l'avancement sur l'accueil.",
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
@@ -261,8 +267,9 @@ export default function MobileNewReel() {
|
||||
createReelMutation.mutate({
|
||||
videoMediaId: selectedVideoId,
|
||||
musicTrackId: selectedTrack?.id,
|
||||
overlayText: overlayText,
|
||||
description: overlayText,
|
||||
overlayText: srtFile ? undefined : overlayText,
|
||||
srtCues: srtFile?.cues,
|
||||
description: srtFile ? srtText(srtFile.cues) : overlayText,
|
||||
pageIds: selectedPages,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
musicVolume: musicVolume[0] / 100,
|
||||
@@ -271,7 +278,10 @@ export default function MobileNewReel() {
|
||||
ttsEnabled,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
ttsStyle,
|
||||
captionStyle,
|
||||
enableEndingEffect,
|
||||
showLogo,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -426,6 +436,7 @@ export default function MobileNewReel() {
|
||||
{/* TEXT STEP */}
|
||||
{currentStep === 'text' && (
|
||||
<div className="space-y-6">
|
||||
{!srtFile && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center"><Sparkles className="w-4 h-4 mr-2 text-primary" /> Assistant IA</CardTitle>
|
||||
@@ -443,8 +454,9 @@ export default function MobileNewReel() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{generatedVariants.length > 0 && (
|
||||
{!srtFile && generatedVariants.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{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é" }); }}>
|
||||
@@ -456,13 +468,34 @@ export default function MobileNewReel() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Texte Overlay</label>
|
||||
<Textarea
|
||||
value={overlayText}
|
||||
onChange={(e) => setOverlayText(e.target.value)}
|
||||
placeholder="Texte sur la vidéo..."
|
||||
className="text-lg"
|
||||
rows={3}
|
||||
/>
|
||||
<SrtUpload value={srtFile} onChange={setSrtFile} />
|
||||
{srtFile ? (
|
||||
<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="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">
|
||||
<Switch
|
||||
id="enable-ending-effect"
|
||||
@@ -492,85 +525,17 @@ export default function MobileNewReel() {
|
||||
<span>TTS — voix activée</span>
|
||||
</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">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="w-full h-8 text-xs"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
<VoicePicker
|
||||
value={voiceSettings}
|
||||
onChange={setVoiceSettings}
|
||||
sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
|
||||
onPreview={setVoicePreview}
|
||||
compact
|
||||
/>
|
||||
</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="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">Sync</span>
|
||||
@@ -600,6 +565,33 @@ export default function MobileNewReel() {
|
||||
{/* PUBLISH STEP */}
|
||||
{currentStep === 'publish' && (
|
||||
<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>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Diffusion</CardTitle>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -33,23 +38,11 @@ export default function MobileRemotionVideoPage() {
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// Gemini native TTS voices (Charon = homme, Kore = femme)
|
||||
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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
@@ -60,6 +53,12 @@ export default function MobileRemotionVideoPage() {
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
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 cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -69,6 +68,13 @@ export default function MobileRemotionVideoPage() {
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
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 tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
@@ -143,16 +149,6 @@ export default function MobileRemotionVideoPage() {
|
||||
|
||||
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) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
@@ -162,12 +158,19 @@ export default function MobileRemotionVideoPage() {
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
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 (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
@@ -196,6 +199,7 @@ export default function MobileRemotionVideoPage() {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -344,6 +348,14 @@ export default function MobileRemotionVideoPage() {
|
||||
)}
|
||||
<Textarea placeholder="Texte overlay…" value={overlayText}
|
||||
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">
|
||||
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
||||
@@ -352,41 +364,7 @@ export default function MobileRemotionVideoPage() {
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<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>
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -483,10 +461,38 @@ export default function MobileRemotionVideoPage() {
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
||||
{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</>}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
@@ -60,6 +67,8 @@ export default function NewReel() {
|
||||
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
|
||||
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 [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
@@ -67,8 +76,10 @@ export default function NewReel() {
|
||||
const [musicVolume, setMusicVolume] = useState([25]);
|
||||
const [ttsEnabled, setTtsEnabled] = 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 [showLogo, setShowLogo] = useState(true);
|
||||
|
||||
// TTS Sync state
|
||||
const [syncInfo, setSyncInfo] = useState<{
|
||||
@@ -79,24 +90,18 @@ export default function NewReel() {
|
||||
warnings: string[];
|
||||
} | null>(null);
|
||||
|
||||
// TTS Engine & Voice
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// Gemini native TTS voices (Charon = homme, Kore = femme)
|
||||
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' },
|
||||
];
|
||||
// Voix : moteur, voix et ton de lecture
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
||||
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);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
|
||||
// Enable TTS by default on mobile
|
||||
useEffect(() => {
|
||||
@@ -108,7 +113,7 @@ export default function NewReel() {
|
||||
|
||||
// Auto-calculate TTS sync when text changes
|
||||
useEffect(() => {
|
||||
if (!ttsEnabled || !overlayText.trim()) {
|
||||
if (!ttsEnabled || !overlayText.trim() || srtFile) {
|
||||
setSyncInfo(null);
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +128,7 @@ export default function NewReel() {
|
||||
.catch(() => setSyncInfo(null));
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
|
||||
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]);
|
||||
|
||||
|
||||
// État audio preview
|
||||
@@ -253,10 +258,10 @@ export default function NewReel() {
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
toast({
|
||||
title: data.success ? "Reel créé !" : "Reel créé avec avertissements",
|
||||
description: data.success
|
||||
? "Votre Reel a été publié avec succès"
|
||||
: "Certaines pages ont échoué",
|
||||
title: "Création du Reel lancée",
|
||||
description: data.queued
|
||||
? "Un autre Reel est en cours : le vôtre démarrera juste après. Suivez l'avancement sur le tableau de bord."
|
||||
: "Suivez l'avancement sur le tableau de bord.",
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
@@ -405,17 +410,21 @@ export default function NewReel() {
|
||||
createReelMutation.mutate({
|
||||
videoMediaId: selectedVideoId,
|
||||
musicTrackId: selectedTrack?.id,
|
||||
overlayText: overlayText,
|
||||
description: overlayText,
|
||||
overlayText: srtFile ? undefined : overlayText,
|
||||
srtCues: srtFile?.cues,
|
||||
description: srtFile ? srtText(srtFile.cues) : overlayText,
|
||||
pageIds: selectedPages,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
musicVolume: musicVolume[0] / 100,
|
||||
ttsEnabled,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
ttsStyle,
|
||||
captionStyle,
|
||||
drawText,
|
||||
stabilize: stabilize,
|
||||
enableEndingEffect,
|
||||
showLogo,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -685,7 +694,7 @@ export default function NewReel() {
|
||||
setSelectedTrack(null);
|
||||
setCurrentStep('text');
|
||||
}}>
|
||||
Passer
|
||||
Passer (garder le son de la vidéo)
|
||||
</Button>
|
||||
<Button onClick={() => setCurrentStep('text')}>
|
||||
Continuer
|
||||
@@ -700,6 +709,7 @@ export default function NewReel() {
|
||||
{/* ÉTAPE 3: Texte */}
|
||||
{currentStep === 'text' && (
|
||||
<>
|
||||
{!srtFile && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -727,8 +737,9 @@ export default function NewReel() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{generatedVariants.length > 0 && (
|
||||
{!srtFile && generatedVariants.length > 0 && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Variations générées</CardTitle>
|
||||
@@ -768,16 +779,26 @@ export default function NewReel() {
|
||||
Texte Overlay
|
||||
</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={overlayText}
|
||||
onChange={(e) => setOverlayText(e.target.value)}
|
||||
placeholder="Écrivez le texte qui apparaîtra sur votre Reel..."
|
||||
rows={4}
|
||||
/>
|
||||
<div className="mb-4">
|
||||
<SrtUpload value={srtFile} onChange={setSrtFile} />
|
||||
</div>
|
||||
{srtFile ? (
|
||||
<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">
|
||||
<Switch
|
||||
@@ -790,6 +811,26 @@ export default function NewReel() {
|
||||
</Label>
|
||||
</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">
|
||||
<Switch
|
||||
id="enable-ending-effect"
|
||||
@@ -819,91 +860,16 @@ export default function NewReel() {
|
||||
<span>TTS — voix activée</span>
|
||||
</div>
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<Label className="text-sm font-medium">Moteur:</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
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 className="mt-3">
|
||||
<VoicePicker
|
||||
value={voiceSettings}
|
||||
onChange={setVoiceSettings}
|
||||
sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
|
||||
onPreview={setVoicePreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Voice Selector */}
|
||||
<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 && (
|
||||
{syncInfo && !srtFile && (
|
||||
<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">
|
||||
<span className="font-medium">Sync texte/voix</span>
|
||||
@@ -921,8 +887,10 @@ export default function NewReel() {
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Le texte sera automatiquement synchronisé avec la voix.
|
||||
Les #hashtags et émojis ne seront pas lus.
|
||||
{srtFile
|
||||
? "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>
|
||||
</div>
|
||||
)}
|
||||
@@ -1045,31 +1013,22 @@ export default function NewReel() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedVideo ? (
|
||||
<div className="relative aspect-[9/16] bg-black rounded-lg overflow-hidden">
|
||||
<video
|
||||
src={selectedVideo.originalUrl}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
autoPlay
|
||||
/>
|
||||
{overlayText && (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<p className="text-white text-center text-lg font-bold drop-shadow-lg">
|
||||
{overlayText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<ReelPreview
|
||||
kind="video"
|
||||
videoUrl={selectedVideo.originalUrl}
|
||||
text={srtFile ? srtText(srtFile.cues) : overlayText}
|
||||
srtCues={srtFile?.cues}
|
||||
showCaptions={drawText}
|
||||
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 className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
@@ -1093,11 +1052,11 @@ export default function NewReel() {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Musique</span>
|
||||
<span>{selectedTrack ? selectedTrack.title : 'Aucune'}</span>
|
||||
<span>{selectedTrack ? selectedTrack.title : 'Son de la vidéo'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Texte</span>
|
||||
<span>{overlayText ? '✓' : '—'}</span>
|
||||
<span>{srtFile ? `SRT (${srtFile.cues.length})` : overlayText ? '✓' : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Pages</span>
|
||||
@@ -1109,115 +1068,6 @@ export default function NewReel() {
|
||||
</div >
|
||||
</div >
|
||||
</main >
|
||||
{/* Overlay de progression */}
|
||||
< ProcessingOverlay
|
||||
isVisible={createReelMutation.isPending}
|
||||
stabilize={stabilize}
|
||||
ttsEnabled={ttsEnabled}
|
||||
/>
|
||||
</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,7 +1,12 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -34,23 +39,11 @@ export default function RemotionVideoPage() {
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// Gemini native TTS voices (Charon = homme, Kore = femme)
|
||||
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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
@@ -61,6 +54,12 @@ export default function RemotionVideoPage() {
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
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 { toast } = useToast();
|
||||
|
||||
@@ -68,6 +67,13 @@ export default function RemotionVideoPage() {
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
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 tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
@@ -109,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) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
@@ -128,12 +124,19 @@ export default function RemotionVideoPage() {
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
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 (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
@@ -162,6 +165,7 @@ export default function RemotionVideoPage() {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -284,6 +288,14 @@ export default function RemotionVideoPage() {
|
||||
)}
|
||||
<Textarea placeholder="Votre texte overlay..." value={overlayText}
|
||||
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">
|
||||
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts">Activer la voix TTS</Label>
|
||||
@@ -292,41 +304,7 @@ export default function RemotionVideoPage() {
|
||||
<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>
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<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>
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -429,10 +407,38 @@ export default function RemotionVideoPage() {
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
||||
{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</>}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion";
|
||||
|
||||
export type WordTiming = {
|
||||
word: string;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
};
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { CaptionStyle, TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ImageCompositionProps = {
|
||||
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;
|
||||
wordTimings?: WordTiming[];
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
/** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
|
||||
showWatermark?: boolean;
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CaptionGroup — renders a group of words (CapCut-style).
|
||||
* Active word is highlighted in yellow with glow; others are white.
|
||||
* 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) => {
|
||||
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
|
||||
export const ImageComposition: React.FC<ImageCompositionProps> = ({
|
||||
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
|
||||
}) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const hasEnding = !!(logoUrl || storeName);
|
||||
const effectiveEndingFrames = hasEnding ? endingFrames : 0;
|
||||
const contentFrames = durationInFrames - effectiveEndingFrames;
|
||||
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;
|
||||
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
|
||||
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
|
||||
const contentFrames = durationInFrames - endingFrames;
|
||||
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames / fps;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{/* Images — displayed immediately, no entrance transition */}
|
||||
{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} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
{/* TTS Audio */}
|
||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||
|
||||
{/* Background music */}
|
||||
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
|
||||
|
||||
{/* Caption overlay — CapCut style */}
|
||||
{renderCaption && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
paddingBottom: 180,
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
{musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) =>
|
||||
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
|
||||
? musicVolume * 0.35
|
||||
: musicVolume) *
|
||||
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Logo watermark — bottom right during content */}
|
||||
{logoUrl && frame < endingStart && (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
|
||||
<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>
|
||||
)}
|
||||
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
|
||||
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
|
||||
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Freeze, Html5Audio, OffthreadVideo, Sequence, interpolate, useVideoConfig } from "remotion";
|
||||
import { FADE_SECONDS, type CaptionStyle, type TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { FadeOut, Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ReelVideoProps = {
|
||||
videoUrl: string;
|
||||
/** Durée de la vidéo source (s) : au-delà, la dernière image est figée (aperçu). */
|
||||
videoDuration: number;
|
||||
totalDuration: number;
|
||||
words: TimedWord[];
|
||||
captionStyle: CaptionStyle;
|
||||
logoUrl?: string;
|
||||
/** Petit logo affiché pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */
|
||||
showWatermark?: boolean;
|
||||
storeName?: string;
|
||||
logoStart?: number | null;
|
||||
fadeStart?: number | null;
|
||||
fadeDuration?: number | null;
|
||||
/** Rendu final : piste son déjà mixée (voix, musique baissée sous la voix, niveau normalisé). */
|
||||
mixedAudioUrl?: string;
|
||||
/** Aperçu : pistes séparées, mixées dans le navigateur. */
|
||||
voiceUrl?: string;
|
||||
voiceDelay?: number;
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
};
|
||||
|
||||
/** Reel à partir d'une vidéo : image, sous-titres animés, logo et effet de fin. */
|
||||
export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const {
|
||||
videoUrl, videoDuration, totalDuration, words, captionStyle, logoUrl, showWatermark = true, storeName,
|
||||
logoStart = null, fadeStart = null, fadeDuration = FADE_SECONDS, mixedAudioUrl, voiceUrl, voiceDelay = 2, musicUrl, musicVolume = 0.25,
|
||||
} = props;
|
||||
|
||||
const videoFrames = Math.max(1, Math.floor(videoDuration * fps));
|
||||
const totalFrames = Math.max(1, Math.round(totalDuration * fps));
|
||||
// Le son d'origine est remplacé par la musique choisie ; sans musique, il reste
|
||||
// en fond (baissé sous la voix). Au rendu final, il est déjà dans la piste mixée.
|
||||
const keepVideoSound = !mixedAudioUrl && !musicUrl;
|
||||
const video = (
|
||||
<OffthreadVideo
|
||||
src={videoUrl}
|
||||
muted={!keepVideoSound}
|
||||
volume={keepVideoSound && voiceUrl ? (frame) => previewMusicVolume(frame / fps, words, 1, fadeStart, fadeDuration ?? FADE_SECONDS) : 1}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{totalFrames > videoFrames ? (
|
||||
<>
|
||||
<Sequence durationInFrames={videoFrames}>{video}</Sequence>
|
||||
<Sequence from={videoFrames}>
|
||||
<Freeze frame={videoFrames - 1}>{video}</Freeze>
|
||||
</Sequence>
|
||||
</>
|
||||
) : (
|
||||
video
|
||||
)}
|
||||
|
||||
{mixedAudioUrl && <Html5Audio src={mixedAudioUrl} />}
|
||||
{!mixedAudioUrl && voiceUrl && (
|
||||
<Sequence from={Math.round(voiceDelay * fps)}>
|
||||
<Html5Audio src={voiceUrl} />
|
||||
</Sequence>
|
||||
)}
|
||||
{!mixedAudioUrl && musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) => previewMusicVolume(frame / fps, words, musicVolume, fadeStart, fadeDuration ?? FADE_SECONDS)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Captions words={words} style={captionStyle} hideAfter={logoStart} />
|
||||
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={logoStart} />}
|
||||
{logoStart != null && (logoUrl || storeName) && (
|
||||
<Outro start={logoStart} logoUrl={logoUrl} storeName={storeName} />
|
||||
)}
|
||||
{fadeStart != null && <FadeOut start={fadeStart} duration={fadeDuration ?? FADE_SECONDS} />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Aperçu : le fond sonore (musique ou son d'origine) baisse pendant la parole et s'éteint avec le fondu final. */
|
||||
function previewMusicVolume(
|
||||
time: number,
|
||||
words: TimedWord[],
|
||||
base: number,
|
||||
fadeStart: number | null,
|
||||
fadeDuration: number,
|
||||
): number {
|
||||
const speaking = words.some((w) => time >= w.start - 0.2 && time <= w.end + 0.3);
|
||||
const fade =
|
||||
fadeStart == null
|
||||
? 1
|
||||
: interpolate(time, [fadeStart, fadeStart + fadeDuration], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return (speaking ? base * 0.35 : base) * fade;
|
||||
}
|
||||
@@ -1,22 +1,45 @@
|
||||
import { Composition } from "remotion";
|
||||
import { ImageComposition, ImageCompositionProps } from "./ImageComposition";
|
||||
import { Composition, type CalculateMetadataFunction } from "remotion";
|
||||
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 = () => {
|
||||
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
|
||||
id="ImageVideo"
|
||||
component={ImageComposition}
|
||||
durationInFrames={360}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
fps={REEL_FPS}
|
||||
width={REEL_WIDTH}
|
||||
height={REEL_HEIGHT}
|
||||
durationInFrames={REEL_FPS * 25}
|
||||
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
|
||||
defaultProps={{
|
||||
images: [
|
||||
"https://placehold.co/1080x1920?text=Image+1",
|
||||
"https://placehold.co/1080x1920?text=Image+2",
|
||||
"https://placehold.co/1080x1920?text=Image+3"
|
||||
],
|
||||
images: ["https://placehold.co/1080x1920?text=Image+1"],
|
||||
totalDuration: 25,
|
||||
words: [],
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} 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 |
@@ -52,6 +52,11 @@ services:
|
||||
# Configuration FFmpeg Service
|
||||
FFMPEG_API_URL: http://ffmpeg-api:8000
|
||||
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:
|
||||
- internal
|
||||
- nginx_default
|
||||
@@ -71,12 +76,16 @@ services:
|
||||
build:
|
||||
context: ./ffmpeg-service
|
||||
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
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
|
||||
networks:
|
||||
- internal
|
||||
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,41 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies including FFmpeg with vidstab support and fonts
|
||||
# We need to build/install libvidstab and compile ffmpeg with it, OR use static build
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
xz-utils \
|
||||
fonts-dejavu \
|
||||
fontconfig \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
# Polices des sous-titres (Montserrat) et des emojis, installées au build
|
||||
# plutôt que téléchargées à chaque démarrage.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget xz-utils ca-certificates fontconfig \
|
||||
fonts-montserrat fonts-dejavu-core fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download static FFmpeg build with all filters including vidstab
|
||||
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
|
||||
&& tar xf ffmpeg-release-amd64-static.tar.xz \
|
||||
&& mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \
|
||||
&& mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \
|
||||
# FFmpeg statique (vidstab, zimg pour le tone-mapping HDR, libass).
|
||||
# Version épinglée : une mise à jour ne change plus le rendu à notre insu.
|
||||
ARG FFMPEG_VERSION=7.0.2
|
||||
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz \
|
||||
|| 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-* \
|
||||
&& fc-cache -fv
|
||||
&& fc-cache -f
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
# Modèle Whisper (calage des sous-titres) téléchargé au build : aucun accès
|
||||
# réseau nécessaire au premier rendu.
|
||||
ARG WHISPER_MODEL=base
|
||||
ENV WHISPER_MODEL=${WHISPER_MODEL} \
|
||||
HF_HOME=/opt/hf-cache \
|
||||
HOME=/tmp \
|
||||
XDG_CACHE_HOME=/tmp/.cache
|
||||
RUN python -c "from faster_whisper import WhisperModel; WhisperModel('${WHISPER_MODEL}', device='cpu', compute_type='int8')"
|
||||
# Modèle déjà présent : plus aucune requête vers Hugging Face au démarrage
|
||||
ENV HF_HUB_OFFLINE=1
|
||||
|
||||
COPY main.py .
|
||||
COPY app ./app
|
||||
|
||||
# Create API Key env var (should be overridden in docker-compose)
|
||||
ENV API_KEY=default-dev-key
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
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,151 @@
|
||||
"""Calage mot à mot du texte affiché sur la voix.
|
||||
|
||||
Le texte affiché (ponctuation, majuscules) diffère légèrement des mots
|
||||
reconnus ou annoncés par le moteur de voix : on apparie les deux séquences
|
||||
(difflib) et on interpole les mots sans correspondance. Remplace ffsubsync,
|
||||
qui ne faisait qu'un décalage global du texte réparti uniformément.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from . import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Word:
|
||||
text: str
|
||||
start: float
|
||||
end: float
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"text": self.text, "start": round(self.start, 3), "end": round(self.end, 3)}
|
||||
|
||||
|
||||
def normalize(token: str) -> str:
|
||||
"""Forme comparable d'un mot : minuscules, sans accents ni ponctuation."""
|
||||
decomposed = unicodedata.normalize("NFKD", token.lower())
|
||||
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||
return re.sub(r"[^a-z0-9]", "", stripped)
|
||||
|
||||
|
||||
def display_tokens(text: str) -> list[str]:
|
||||
"""Mots à afficher ; la ponctuation isolée est rattachée au mot précédent."""
|
||||
tokens: list[str] = []
|
||||
for raw in text.split():
|
||||
if tokens and not normalize(raw):
|
||||
tokens[-1] += raw if raw in ",.!?;:…" else f" {raw}"
|
||||
else:
|
||||
tokens.append(raw)
|
||||
return tokens
|
||||
|
||||
|
||||
def align_words(display_text: str, spoken: list[Word], total_duration: float | None = None) -> list[Word]:
|
||||
"""Attribue à chaque mot affiché un début et une fin tirés des mots prononcés."""
|
||||
tokens = display_tokens(display_text)
|
||||
if not tokens:
|
||||
return []
|
||||
if not spoken:
|
||||
return _spread(tokens, 0.0, total_duration or len(tokens) * 0.4)
|
||||
|
||||
timed: list[Word | None] = [None] * len(tokens)
|
||||
matcher = difflib.SequenceMatcher(
|
||||
a=[normalize(t) for t in tokens], b=[normalize(w.text) for w in spoken], autojunk=False
|
||||
)
|
||||
for block in matcher.get_matching_blocks():
|
||||
for k in range(block.size):
|
||||
src = spoken[block.b + k]
|
||||
timed[block.a + k] = Word(tokens[block.a + k], src.start, src.end)
|
||||
|
||||
# Mots non appariés : répartis dans le trou entre leurs voisins datés
|
||||
end_of_speech = max(spoken[-1].end, total_duration or 0.0)
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
if timed[i] is not None:
|
||||
i += 1
|
||||
continue
|
||||
j = i
|
||||
while j < len(tokens) and timed[j] is None:
|
||||
j += 1
|
||||
gap_start = timed[i - 1].end if i > 0 else 0.0
|
||||
gap_end = timed[j].start if j < len(tokens) else end_of_speech
|
||||
if gap_end - gap_start < 0.05 * (j - i):
|
||||
gap_end = gap_start + 0.25 * (j - i)
|
||||
timed[i:j] = _spread(tokens[i:j], gap_start, gap_end)
|
||||
i = j
|
||||
|
||||
words = [w for w in timed if w is not None]
|
||||
# Monotonie stricte : un mot ne commence jamais avant la fin du précédent
|
||||
for prev, cur in zip(words, words[1:]):
|
||||
cur.start = max(cur.start, prev.start + 0.01)
|
||||
cur.end = max(cur.end, cur.start + 0.05)
|
||||
return words
|
||||
|
||||
|
||||
def spread_words(text: str, start: float, end: float) -> list[Word]:
|
||||
"""Mots d'un texte répartis sur un intervalle (sous-titre SRT sans voix)."""
|
||||
tokens = display_tokens(text)
|
||||
return _spread(tokens, start, end) if tokens and end > start else []
|
||||
|
||||
|
||||
def _spread(tokens: list[str], start: float, end: float) -> list[Word]:
|
||||
"""Répartit des mots sur un intervalle au prorata de leur longueur."""
|
||||
weights = [max(1, len(normalize(t))) for t in tokens]
|
||||
total = sum(weights)
|
||||
words, cursor = [], start
|
||||
for token, weight in zip(tokens, weights):
|
||||
duration = (end - start) * weight / total
|
||||
words.append(Word(token, cursor, cursor + duration))
|
||||
cursor += duration
|
||||
return words
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _model():
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
log.info("Chargement du modèle Whisper %s", config.WHISPER_MODEL)
|
||||
return WhisperModel(config.WHISPER_MODEL, device="cpu", compute_type=config.WHISPER_COMPUTE_TYPE)
|
||||
|
||||
|
||||
# Silence ajouté avant la voix pour Whisper : sur une voix qui démarre
|
||||
# aussitôt, il laissait souvent passer le premier mot (« Alerte »).
|
||||
LEAD_SILENCE = 0.5
|
||||
|
||||
|
||||
def _transcribe_sync(audio: Path) -> list[Word]:
|
||||
import numpy as np
|
||||
from faster_whisper.audio import decode_audio
|
||||
|
||||
samples = decode_audio(str(audio), sampling_rate=16000)
|
||||
padded = np.concatenate([np.zeros(int(16000 * LEAD_SILENCE), dtype=samples.dtype), samples])
|
||||
# Pas de texte attendu en amorce (initial_prompt) ni d'enchaînement sur le
|
||||
# segment précédent : les deux font halluciner Whisper, qui répète la phrase
|
||||
# et masque une fin tronquée. On veut entendre ce qui est réellement dit.
|
||||
segments, _ = _model().transcribe(
|
||||
padded,
|
||||
language="fr",
|
||||
word_timestamps=True,
|
||||
condition_on_previous_text=False,
|
||||
vad_filter=False,
|
||||
beam_size=5,
|
||||
)
|
||||
return [
|
||||
Word(w.word.strip(), max(0.0, w.start - LEAD_SILENCE), max(0.0, w.end - LEAD_SILENCE))
|
||||
for segment in segments
|
||||
for w in (segment.words or [])
|
||||
if w.word.strip()
|
||||
]
|
||||
|
||||
|
||||
async def transcribe(audio: Path) -> list[Word]:
|
||||
"""Mots prononcés et leurs instants (Whisper, exécuté hors de la boucle async)."""
|
||||
return await asyncio.to_thread(_transcribe_sync, audio)
|
||||
@@ -0,0 +1,429 @@
|
||||
"""API HTTP du service : voix, rendu des Reels et récupération des fichiers."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import align, config, jobs, proc, render, srt_voice, subtitles, tts, voices
|
||||
from .audio import encode_preview
|
||||
from .text import clean_text
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("reels")
|
||||
|
||||
if not config.API_KEY:
|
||||
raise RuntimeError("API_KEY doit être défini : le service refuse de démarrer sans clé.")
|
||||
|
||||
# Un seul encodage à la fois : ils saturent le CPU, les suivants attendent
|
||||
render_slot = asyncio.Semaphore(1)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
removed = jobs.purge_expired()
|
||||
log.info("Service prêt (%d dossier(s) expiré(s) purgé(s))", removed)
|
||||
|
||||
async def purge_loop():
|
||||
while True:
|
||||
await asyncio.sleep(600)
|
||||
jobs.purge_expired()
|
||||
|
||||
task = asyncio.create_task(purge_loop())
|
||||
yield
|
||||
task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="SocialFlow Reels", lifespan=lifespan)
|
||||
|
||||
|
||||
def require_key(x_api_key: str | None = Header(None)) -> None:
|
||||
if x_api_key != config.API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||
|
||||
|
||||
class TtsRequest(BaseModel):
|
||||
text: str
|
||||
tts_voice: str | None = None
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
|
||||
|
||||
class SrtCue(BaseModel):
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
|
||||
|
||||
class ReelRequest(BaseModel):
|
||||
video_base64: str | None = None
|
||||
video_url: str | None = None
|
||||
text: str | None = None
|
||||
# Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant
|
||||
srt_cues: list[SrtCue] | None = None
|
||||
music_url: str | None = None
|
||||
watermark_url: str | None = None
|
||||
store_name: str | None = None
|
||||
font_size: int = 64
|
||||
music_volume: float = 0.25
|
||||
tts_enabled: bool = False
|
||||
tts_voice: str | None = None
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
draw_text: bool = True
|
||||
stabilize: bool = False
|
||||
enable_ending_effect: bool = True
|
||||
# Préparation Remotion : le logo est composé par Remotion, mais sa présence
|
||||
# allonge la vidéo pour laisser place à l'effet de fin.
|
||||
has_logo: bool = False
|
||||
# Petit logo en coin pendant la vidéo (le grand logo de fin dépend de enable_ending_effect)
|
||||
show_watermark: bool = True
|
||||
# Champs d'anciennes versions, acceptés et ignorés
|
||||
music_id: str | None = None
|
||||
word_duration: float | None = None
|
||||
|
||||
|
||||
@app.get("/health", dependencies=[Depends(require_key)])
|
||||
async def health():
|
||||
return {"status": "ok", "version": 2}
|
||||
|
||||
|
||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||
async def list_voices():
|
||||
return voices.catalog()
|
||||
|
||||
|
||||
@app.post("/preview-tts", dependencies=[Depends(require_key)])
|
||||
async def preview_tts(request: TtsRequest):
|
||||
text = clean_text(request.text)
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Texte vide après nettoyage (emojis et hashtags retirés)")
|
||||
|
||||
job_id, workdir = jobs.new_job()
|
||||
try:
|
||||
track = await _synthesize(text, request.text, request, workdir)
|
||||
preview = workdir / "preview.mp3"
|
||||
await encode_preview(track.path, preview)
|
||||
return {
|
||||
"success": True,
|
||||
"audio_base64": base64.b64encode(preview.read_bytes()).decode(),
|
||||
"duration": track.duration,
|
||||
"words": [w.to_dict() for w in track.words],
|
||||
"engine": track.engine,
|
||||
"voice": track.voice,
|
||||
"warnings": track.warnings,
|
||||
}
|
||||
finally:
|
||||
jobs.remove_job(job_id)
|
||||
|
||||
|
||||
@app.post("/process-reel", dependencies=[Depends(require_key)])
|
||||
async def process_reel(request: ReelRequest):
|
||||
"""Rendu complet par FFmpeg ; le MP4 se récupère via GET /files/{job_id}/output.mp4."""
|
||||
return await _guarded(_process, request)
|
||||
|
||||
|
||||
@app.post("/prepare-reel", dependencies=[Depends(require_key)])
|
||||
async def prepare_reel(request: ReelRequest):
|
||||
"""Prépare un rendu Remotion : image recadrée à la durée finale, piste son
|
||||
finale et minutage des mots. Rien n'est incrusté dans l'image."""
|
||||
return await _guarded(_prepare, request)
|
||||
|
||||
|
||||
async def _guarded(handler, request: ReelRequest) -> dict:
|
||||
job_id, workdir = jobs.new_job()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with render_slot:
|
||||
return await handler(request, job_id, workdir, started)
|
||||
except HTTPException:
|
||||
jobs.remove_job(job_id)
|
||||
raise
|
||||
except proc.CommandError as error:
|
||||
jobs.remove_job(job_id)
|
||||
log.error("Rendu %s en échec : %s", job_id, error)
|
||||
raise HTTPException(status_code=500, detail=str(error)) from error
|
||||
except Exception as error:
|
||||
jobs.remove_job(job_id)
|
||||
log.exception("Rendu %s en échec", job_id)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur de rendu : {error}") from error
|
||||
|
||||
|
||||
@app.get("/files/{job_id}/{name}", dependencies=[Depends(require_key)])
|
||||
async def get_file(job_id: str, name: str):
|
||||
path = jobs.job_file(job_id, name)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré")
|
||||
media_type = {"mp4": "video/mp4", "wav": "audio/wav"}.get(name.rsplit(".", 1)[-1])
|
||||
return FileResponse(path, media_type=media_type)
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}", dependencies=[Depends(require_key)])
|
||||
async def delete_job(job_id: str):
|
||||
jobs.remove_job(job_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# --- Orchestration --------------------------------------------------------
|
||||
|
||||
|
||||
async def _synthesize(text: str, display_source: str | None, request, workdir: Path) -> tts.VoiceTrack:
|
||||
try:
|
||||
return await tts.synthesize(
|
||||
text=text,
|
||||
display_text=clean_text(display_source) or text,
|
||||
engine=request.tts_engine,
|
||||
voice=request.tts_voice,
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix impossible à générer")
|
||||
raise HTTPException(status_code=502, detail=f"La voix n'a pas pu être générée : {error}") from error
|
||||
|
||||
|
||||
def _srt_cues(request: ReelRequest) -> list[srt_voice.Cue]:
|
||||
"""Sous-titres lisibles du SRT, dans l'ordre (vide sans SRT)."""
|
||||
cues = [
|
||||
srt_voice.Cue(c.start, c.end, c.text)
|
||||
for c in request.srt_cues or []
|
||||
if c.end > c.start and clean_text(c.text)
|
||||
]
|
||||
return sorted(cues, key=lambda c: c.start)
|
||||
|
||||
|
||||
async def _synthesize_srt(cues: list[srt_voice.Cue], request, workdir: Path) -> tts.VoiceTrack:
|
||||
try:
|
||||
return await srt_voice.synthesize_cues(
|
||||
cues=cues,
|
||||
engine=request.tts_engine,
|
||||
voice=request.tts_voice,
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix SRT impossible à générer")
|
||||
raise HTTPException(status_code=502, detail=f"La voix n'a pas pu être générée : {error}") from error
|
||||
|
||||
|
||||
async def _download(url: str, target: Path, what: str, required: bool) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=15), follow_redirects=True) as client:
|
||||
async with client.stream("GET", url, headers={"User-Agent": "Mozilla/5.0"}) as response:
|
||||
response.raise_for_status()
|
||||
with target.open("wb") as out:
|
||||
async for chunk in response.aiter_bytes(1 << 20):
|
||||
out.write(chunk)
|
||||
return True
|
||||
except Exception as error:
|
||||
if required:
|
||||
detail = f"Téléchargement de {what} impossible : {error}"
|
||||
raise HTTPException(status_code=400, detail=detail) from error
|
||||
log.warning("Téléchargement de %s impossible, on continue sans : %s", what, error)
|
||||
return False
|
||||
|
||||
|
||||
class Stopwatch:
|
||||
def __init__(self, started: float):
|
||||
self.started = started
|
||||
self.last = time.monotonic()
|
||||
self.stats: dict[str, float] = {}
|
||||
|
||||
def lap(self, name: str) -> None:
|
||||
now = time.monotonic()
|
||||
self.stats[name] = round(now - self.last, 2)
|
||||
self.last = now
|
||||
|
||||
def summary(self) -> dict[str, float]:
|
||||
return {**self.stats, "total": round(time.monotonic() - self.started, 2)}
|
||||
|
||||
|
||||
async def _gather(request: ReelRequest, workdir: Path, clock: Stopwatch, *, fetch_logo: bool):
|
||||
"""Téléchargements, analyse, voix et stabilisation : communs aux deux modes."""
|
||||
video = workdir / "input.mp4"
|
||||
if request.video_base64:
|
||||
video.write_bytes(base64.b64decode(request.video_base64))
|
||||
elif request.video_url:
|
||||
await _download(request.video_url, video, "la vidéo", required=True)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Aucune vidéo fournie")
|
||||
|
||||
music = workdir / "music.audio"
|
||||
has_music = bool(request.music_url) and await _download(
|
||||
request.music_url, music, "la musique", required=False
|
||||
)
|
||||
watermark = workdir / "watermark.png"
|
||||
has_watermark = (
|
||||
fetch_logo
|
||||
and bool(request.watermark_url)
|
||||
and await _download(request.watermark_url, watermark, "le logo", required=False)
|
||||
)
|
||||
info = await proc.probe(video)
|
||||
if info.duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)")
|
||||
clock.lap("download")
|
||||
|
||||
track = None
|
||||
cues = _srt_cues(request)
|
||||
spoken_text = clean_text(request.text)
|
||||
if request.tts_enabled and cues:
|
||||
track = await _synthesize_srt(cues, request, workdir)
|
||||
elif request.tts_enabled and spoken_text:
|
||||
track = await _synthesize(spoken_text, request.text, request, workdir)
|
||||
clock.lap("tts")
|
||||
|
||||
plan = render.RenderPlan(
|
||||
video=video,
|
||||
video_duration=info.duration,
|
||||
output=workdir / "output.mp4",
|
||||
is_hdr=info.is_hdr,
|
||||
music=music if has_music else None,
|
||||
music_volume=request.music_volume,
|
||||
voice=track.path if track else None,
|
||||
voice_duration=track.duration if track else 0.0,
|
||||
# Avec un SRT, la voix est déjà posée aux instants du fichier
|
||||
voice_delay=0.0 if cues else config.VOICE_DELAY,
|
||||
watermark=watermark if has_watermark else None,
|
||||
outro_expected=not fetch_logo and request.has_logo,
|
||||
ending_effect=request.enable_ending_effect,
|
||||
show_watermark=request.show_watermark,
|
||||
keep_original_audio=info.has_audio,
|
||||
)
|
||||
|
||||
if request.stabilize:
|
||||
transforms = workdir / "transforms.trf"
|
||||
try:
|
||||
await proc.run(render.stabilize_detect_command(video, transforms), timeout=600)
|
||||
plan.stabilize_transforms = transforms
|
||||
except proc.CommandError as error:
|
||||
log.warning("Stabilisation ignorée : %s", error)
|
||||
clock.lap("stabilize")
|
||||
return plan, track, info
|
||||
|
||||
|
||||
async def _caption_words(request: ReelRequest, plan: render.RenderPlan, track, info) -> list[align.Word]:
|
||||
"""Mots à afficher, en secondes depuis le début de la vidéo."""
|
||||
cues = _srt_cues(request)
|
||||
if cues:
|
||||
if not request.draw_text:
|
||||
return []
|
||||
if track:
|
||||
return track.words
|
||||
# Sans voix : chaque sous-titre s'affiche sur son propre intervalle
|
||||
return [w for c in cues for w in align.spread_words(clean_text(c.text), c.start, c.end)]
|
||||
display = clean_text(request.text)
|
||||
if not request.draw_text or not display:
|
||||
return []
|
||||
if track:
|
||||
return [align.Word(w.text, w.start + plan.voice_delay, w.end + plan.voice_delay) for w in track.words]
|
||||
return await _caption_words_without_voice(display, plan.video, info, plan)
|
||||
|
||||
|
||||
def _keep_only(workdir: Path, keep: set[Path]) -> None:
|
||||
"""Seuls les fichiers à télécharger restent jusqu'à la récupération."""
|
||||
for entry in workdir.iterdir():
|
||||
if entry in keep:
|
||||
continue
|
||||
if entry.is_dir():
|
||||
shutil.rmtree(entry, ignore_errors=True) # voix SRT : un dossier par sous-titre
|
||||
else:
|
||||
entry.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _voice_info(track) -> dict:
|
||||
return {
|
||||
"tts_engine": track.engine if track else None,
|
||||
"tts_voice": track.voice if track else None,
|
||||
"warnings": track.warnings if track else [],
|
||||
}
|
||||
|
||||
|
||||
async def _process(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
|
||||
clock = Stopwatch(started)
|
||||
plan, track, info = await _gather(request, workdir, clock, fetch_logo=True)
|
||||
|
||||
words = await _caption_words(request, plan, track, info)
|
||||
if words:
|
||||
captions = workdir / "captions.ass"
|
||||
font_size = max(48, round(request.font_size * 1.4))
|
||||
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
|
||||
plan.captions = captions
|
||||
if request.store_name and plan.has_outro and plan.watermark:
|
||||
outro = workdir / "outro.ass"
|
||||
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
|
||||
plan.outro = outro
|
||||
clock.lap("subtitles")
|
||||
|
||||
await proc.run(render.build_command(plan), timeout=1200)
|
||||
clock.lap("encode")
|
||||
duration = (await proc.probe(plan.output)).duration
|
||||
log.info("Rendu %s terminé : %.1f s de vidéo, étapes %s", job_id, duration, clock.summary())
|
||||
|
||||
_keep_only(workdir, {plan.output})
|
||||
return {
|
||||
"success": True,
|
||||
"job_id": job_id,
|
||||
"output_path": f"/files/{job_id}/output.mp4",
|
||||
"duration": duration,
|
||||
**_voice_info(track),
|
||||
"processing_stats": clock.summary(),
|
||||
}
|
||||
|
||||
|
||||
async def _prepare(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
|
||||
clock = Stopwatch(started)
|
||||
plan, track, info = await _gather(request, workdir, clock, fetch_logo=False)
|
||||
words = await _caption_words(request, plan, track, info)
|
||||
clock.lap("subtitles")
|
||||
|
||||
video_out = workdir / "video.mp4"
|
||||
audio_out = workdir / "audio.wav"
|
||||
await proc.run(render.build_prepared_video_command(plan, video_out), timeout=1200)
|
||||
clock.lap("video")
|
||||
mix = render.build_audio_mix_command(plan, audio_out)
|
||||
if mix:
|
||||
await proc.run(mix, timeout=600)
|
||||
clock.lap("audio")
|
||||
log.info("Préparation %s terminée : %.1f s, étapes %s", job_id, plan.total_duration, clock.summary())
|
||||
|
||||
_keep_only(workdir, {video_out, audio_out})
|
||||
return {
|
||||
"success": True,
|
||||
"job_id": job_id,
|
||||
"video_path": f"/files/{job_id}/video.mp4",
|
||||
"audio_path": f"/files/{job_id}/audio.wav" if mix else None,
|
||||
"total_duration": plan.total_duration,
|
||||
"video_duration": info.duration,
|
||||
"logo_start": plan.logo_start if plan.has_outro else None,
|
||||
"fade_start": plan.fade_start if plan.ending_effect else None,
|
||||
"fade_duration": plan.fade_duration if plan.ending_effect else None,
|
||||
"words": [w.to_dict() for w in words],
|
||||
**_voice_info(track),
|
||||
"processing_stats": clock.summary(),
|
||||
}
|
||||
|
||||
|
||||
async def _caption_words_without_voice(display: str, video: Path, info, plan: render.RenderPlan):
|
||||
"""Sans voix de synthèse : calage sur la parole de la vidéo si elle en contient,
|
||||
sinon texte réparti sur la durée (hors effet de fin)."""
|
||||
if info.has_audio:
|
||||
try:
|
||||
spoken = await align.transcribe(video)
|
||||
if len(spoken) >= max(2, len(display.split()) // 3):
|
||||
return align.align_words(display, spoken, info.duration)
|
||||
except Exception as error: # noqa: BLE001 — on retombe sur la répartition
|
||||
log.warning("Transcription de la vidéo impossible : %s", error)
|
||||
end = plan.logo_start if plan.has_outro else plan.total_duration
|
||||
return align.align_words(display, [], max(1.0, end - 0.5))
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Traitement de la voix : filtrage, compression et niveau sonore constant."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from . import proc
|
||||
|
||||
# Nettoyage de la voix : coupe les basses inutiles, retire le souffle de fond
|
||||
# (réduction douce, sans gate qui hacherait les fins de phrases) et arrondit
|
||||
# les pics. Pas de gain ajouté : c'est la normalisation qui règle le niveau.
|
||||
CLEAN_CHAIN = (
|
||||
"highpass=f=80,"
|
||||
"afftdn=nr=10:nf=-50:tn=1,"
|
||||
"acompressor=threshold=0.125:ratio=2.5:attack=5:release=150:makeup=1"
|
||||
)
|
||||
# Niveau cible de la voix : intelligible par-dessus la musique sans saturer
|
||||
TARGET = "I=-16:TP=-1.5:LRA=11"
|
||||
# Fondu de 5 ms aux extrémités : pas de clic au début ni à la fin
|
||||
EDGE_FADE = 0.005
|
||||
|
||||
|
||||
async def process_voice(source: Path, target: Path) -> None:
|
||||
"""Produit un WAV 48 kHz mono prêt à mixer.
|
||||
|
||||
Normalisation en deux passes, en mode linéaire : un seul gain appliqué à
|
||||
toute la voix. Le mode dynamique (une passe) relevait les passages calmes
|
||||
et amplifiait souffles et bruits entre les phrases."""
|
||||
measure = await proc.run(
|
||||
[
|
||||
"ffmpeg", "-hide_banner", "-nostats", "-i", str(source),
|
||||
"-af", f"{CLEAN_CHAIN},loudnorm={TARGET}:print_format=json", "-f", "null", "-",
|
||||
],
|
||||
timeout=120,
|
||||
stderr_output=True,
|
||||
) # fmt: skip
|
||||
stats = _loudnorm_stats(measure)
|
||||
if stats:
|
||||
loudnorm = (
|
||||
f"loudnorm={TARGET}:linear=true:"
|
||||
f"measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:"
|
||||
f"measured_LRA={stats['input_lra']}:measured_thresh={stats['input_thresh']}:"
|
||||
f"offset={stats['target_offset']}"
|
||||
)
|
||||
else:
|
||||
loudnorm = f"loudnorm={TARGET}"
|
||||
duration = (await proc.probe(source)).duration
|
||||
fades = f"afade=t=in:d={EDGE_FADE},afade=t=out:st={max(0.0, duration - EDGE_FADE):.3f}:d={EDGE_FADE}"
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
|
||||
"-af", f"{CLEAN_CHAIN},{loudnorm},aresample=48000,{fades}",
|
||||
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(target),
|
||||
],
|
||||
timeout=120,
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def _loudnorm_stats(ffmpeg_log: str) -> dict[str, str] | None:
|
||||
"""Mesures de la première passe (bloc JSON écrit par loudnorm)."""
|
||||
start, end = ffmpeg_log.rfind("{"), ffmpeg_log.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
return None
|
||||
try:
|
||||
stats = json.loads(ffmpeg_log[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
keys = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
|
||||
if not all(k in stats for k in keys):
|
||||
return None
|
||||
# Voix quasi muette : les mesures valent -inf, la normalisation simple suffit
|
||||
if any("inf" in str(stats[k]) for k in keys):
|
||||
return None
|
||||
return {k: stats[k] for k in keys}
|
||||
|
||||
|
||||
async def encode_preview(source: Path, target: Path) -> None:
|
||||
"""MP3 de bonne qualité pour l'écoute dans le navigateur."""
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(source),
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"160k",
|
||||
str(target),
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Configuration lue dans l'environnement."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Pas de clé par défaut : un service exposé avec une clé connue de tous
|
||||
# n'est pas protégé. Le démarrage échoue si elle manque.
|
||||
API_KEY = os.environ.get("API_KEY", "")
|
||||
|
||||
TEMP_DIR = Path(os.environ.get("TEMP_DIR", "/tmp/ffmpeg_processing"))
|
||||
|
||||
# Durée de conservation des fichiers produits (téléchargés par l'application
|
||||
# puis supprimés ; la purge ne rattrape que les oublis).
|
||||
FILE_TTL_SECONDS = int(os.environ.get("FILE_TTL_SECONDS", "3600"))
|
||||
|
||||
GEMINI_TTS_MODEL = os.environ.get("GEMINI_TTS_MODEL", "gemini-2.5-flash-preview-tts")
|
||||
# Gemini tire sa lecture au hasard : une température basse et une graine fixe
|
||||
# donnent une intonation stable d'une génération à l'autre.
|
||||
GEMINI_TTS_TEMPERATURE = float(os.environ.get("GEMINI_TTS_TEMPERATURE", "0.3"))
|
||||
GEMINI_TTS_SEED = int(os.environ.get("GEMINI_TTS_SEED", "20260928"))
|
||||
# Générations tentées quand la lecture ne correspond pas au texte
|
||||
GEMINI_TTS_ATTEMPTS = int(os.environ.get("GEMINI_TTS_ATTEMPTS", "3"))
|
||||
|
||||
# Modèle Whisper utilisé pour caler les sous-titres sur la voix.
|
||||
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")
|
||||
WHISPER_COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "int8")
|
||||
|
||||
# Proxy sortant éventuel pour Edge TTS (aiohttp ne lit pas HTTPS_PROXY seul).
|
||||
OUTBOUND_PROXY = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or None
|
||||
|
||||
# Rendu
|
||||
WIDTH = 1080
|
||||
HEIGHT = 1920
|
||||
FPS = 30
|
||||
# La voix démarre après ce délai (le temps de capter l'attention).
|
||||
VOICE_DELAY = 2.0
|
||||
|
||||
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
|
||||
@@ -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,79 @@
|
||||
"""Exécution asynchrone de FFmpeg/ffprobe : le serveur reste disponible
|
||||
pendant un encodage (l'ancien subprocess.run bloquait toute l'API)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
"""Commande externe en échec, avec la fin de sa sortie d'erreur."""
|
||||
|
||||
def __init__(self, cmd: list[str], returncode: int, stderr: str):
|
||||
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
||||
super().__init__(f"{cmd[0]} a échoué (code {returncode}) :\n{tail}")
|
||||
self.returncode = returncode
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
async def run(cmd: list[str], timeout: float = 900, *, stderr_output: bool = False) -> str:
|
||||
"""Lance une commande et renvoie sa sortie standard (ou d'erreur, où FFmpeg
|
||||
écrit les mesures de ses filtres d'analyse)."""
|
||||
log.debug("exec: %s", " ".join(cmd))
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
|
||||
except TimeoutError as error:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise CommandError(cmd, -1, f"délai de {timeout:.0f} s dépassé") from error
|
||||
if process.returncode != 0:
|
||||
raise CommandError(cmd, process.returncode, stderr.decode(errors="replace"))
|
||||
return (stderr if stderr_output else stdout).decode(errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaInfo:
|
||||
duration: float
|
||||
has_audio: bool
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
color_transfer: str = ""
|
||||
|
||||
@property
|
||||
def is_hdr(self) -> bool:
|
||||
# HLG (iPhone) ou PQ (HDR10)
|
||||
return self.color_transfer in ("arib-std-b67", "smpte2084")
|
||||
|
||||
|
||||
async def probe(path: Path) -> MediaInfo:
|
||||
out = await run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
str(path),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
data = json.loads(out)
|
||||
streams = data.get("streams", [])
|
||||
video = next((s for s in streams if s.get("codec_type") == "video"), {})
|
||||
duration = float(data.get("format", {}).get("duration") or video.get("duration") or 0)
|
||||
return MediaInfo(
|
||||
duration=duration,
|
||||
has_audio=any(s.get("codec_type") == "audio" for s in streams),
|
||||
width=int(video.get("width") or 0),
|
||||
height=int(video.get("height") or 0),
|
||||
color_transfer=video.get("color_transfer") or "",
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Contrôles de la voix générée : texte réellement lu et silences.
|
||||
|
||||
Gemini lit parfois mal : mots sautés, fin tronquée, consigne de style lue à
|
||||
voix haute. On compare donc ce que Whisper entend au texte attendu, et on
|
||||
découpe la voix d'après son niveau sonore réel (mesuré toutes les 10 ms)
|
||||
plutôt qu'aux bornes des mots estimées par Whisper, trop imprécises pour ne
|
||||
pas rogner une syllabe.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import proc
|
||||
from .align import Word, normalize
|
||||
|
||||
MIN_COVERAGE = 0.92
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadingCheck:
|
||||
coverage: float # part des mots attendus effectivement entendus
|
||||
extra: float # mots entendus en trop (consigne lue, ajouts), rapportés au texte
|
||||
ending_ok: bool # la fin du texte est bien lue
|
||||
missing: list[str] = field(default_factory=list) # mots attendus non entendus (diagnostic)
|
||||
|
||||
@property
|
||||
def acceptable(self) -> bool:
|
||||
# Comparaison lettre à lettre : une lecture complète atteint ~97 %
|
||||
# (petites erreurs de Whisper) ; sous 92 %, des mots ont été sautés.
|
||||
return self.coverage >= MIN_COVERAGE and self.extra <= 0.25 and self.ending_ok
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = [f"{self.coverage:.0%} des mots lus"]
|
||||
if not self.ending_ok:
|
||||
parts.append("fin tronquée")
|
||||
if self.extra > 0.25:
|
||||
parts.append("mots ajoutés")
|
||||
if self.missing and self.coverage < MIN_COVERAGE:
|
||||
parts.append("non entendus : " + " ".join(self.missing[:12]))
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
|
||||
"""Compare le texte attendu à ce qui a été entendu (Whisper), lettre à
|
||||
lettre : les petites erreurs de reconnaissance (« petit » pour « petits »,
|
||||
« week -end » découpé) ne comptent pas comme des mots manquants, alors
|
||||
qu'un mot sauté, une fin tronquée ou une phrase ajoutée se voient."""
|
||||
expected = "".join(comparable_tokens(expected_text.split()))
|
||||
heard = "".join(comparable_tokens([w.text for w in spoken]))
|
||||
if not expected:
|
||||
return ReadingCheck(1.0, 0.0, True)
|
||||
if not heard:
|
||||
return ReadingCheck(0.0, 0.0, False)
|
||||
|
||||
matcher = difflib.SequenceMatcher(a=expected, b=heard, autojunk=False)
|
||||
blocks = [b for b in matcher.get_matching_blocks() if b.size]
|
||||
matched = sum(b.size for b in blocks)
|
||||
# Fin lue : une bonne partie des dernières lettres du texte est reconnue
|
||||
tail_start = max(0, len(expected) - 8)
|
||||
tail_matched = sum(max(0, min(b.a + b.size, len(expected)) - max(b.a, tail_start)) for b in blocks)
|
||||
return ReadingCheck(
|
||||
coverage=matched / len(expected),
|
||||
extra=max(0, len(heard) - matched) / len(expected),
|
||||
ending_ok=tail_matched >= min(4, len(expected) - tail_start),
|
||||
missing=_missing_words(expected_text, spoken),
|
||||
)
|
||||
|
||||
|
||||
# Unités écrites de plusieurs façons par Whisper (« 40 cm » pour « 40 centimètres »)
|
||||
UNITS = {
|
||||
"e", "eur", "euro", "euros", "centime", "centimes", "cm", "centimetre", "centimetres",
|
||||
"mm", "millimetre", "millimetres", "m", "metre", "metres", "km", "kilometre", "kilometres",
|
||||
"g", "gramme", "grammes", "kg", "kilo", "kilos", "kilogramme", "kilogrammes",
|
||||
"l", "litre", "litres", "cl", "centilitre", "centilitres", "ml", "millilitre", "millilitres",
|
||||
"pourcent", "pourcents", "x", "fois",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def comparable_tokens(words: list[str]) -> list[str]:
|
||||
"""Mots normalisés, sans les nombres ni l'unité qui les suit : Whisper écrit
|
||||
un prix ou une mesure à sa façon (« 11 € 99 », « 11,99 euros », « 40 cm »),
|
||||
ce qui passait pour des mots sautés et relançait la génération."""
|
||||
tokens: list[str] = []
|
||||
after_number = False
|
||||
for word in words:
|
||||
token = normalize(word)
|
||||
if not token:
|
||||
continue
|
||||
if any(c.isdigit() for c in token):
|
||||
after_number = True
|
||||
continue
|
||||
if after_number and token in UNITS:
|
||||
continue
|
||||
after_number = False
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _missing_words(expected_text: str, spoken: list[Word]) -> list[str]:
|
||||
"""Mots attendus sans équivalent proche dans ce qui a été entendu."""
|
||||
heard = set(comparable_tokens([w.text for w in spoken]))
|
||||
kept = set(comparable_tokens(expected_text.split()))
|
||||
missing = []
|
||||
for word in expected_text.split():
|
||||
token = normalize(word)
|
||||
if token in kept and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
|
||||
missing.append(word.strip(".,!?;:…"))
|
||||
return missing
|
||||
|
||||
|
||||
# Enveloppe sonore : niveau de la voix toutes les 10 ms
|
||||
FRAME = 0.01
|
||||
# Est « audible » ce qui dépasse le niveau de crête moins 38 dB : un seuil
|
||||
# relatif respecte les fins de phrases douces (« s » final, voix qui retombe),
|
||||
# qu'un seuil absolu prenait pour du silence.
|
||||
ACTIVE_RANGE_DB = 38.0
|
||||
# Marges gardées autour de la parole
|
||||
LEAD_PAD = 0.08
|
||||
TAIL_PAD = 0.30
|
||||
# Pause entre deux phrases : au moins 80 ms de silence, cherchée jusqu'à 1,2 s
|
||||
# de la borne estimée par Whisper
|
||||
MIN_PAUSE = 0.08
|
||||
PAUSE_SEARCH = 1.2
|
||||
|
||||
|
||||
@dataclass
|
||||
class Envelope:
|
||||
db: np.ndarray # niveau (dBFS) de chaque tranche de 10 ms
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return len(self.db) * FRAME
|
||||
|
||||
@property
|
||||
def threshold(self) -> float:
|
||||
if not len(self.db):
|
||||
return 0.0
|
||||
return float(np.percentile(self.db, 95)) - ACTIVE_RANGE_DB
|
||||
|
||||
def _frames(self, start: float, end: float) -> tuple[int, int]:
|
||||
first = max(0, int(start / FRAME))
|
||||
last = min(len(self.db), int(np.ceil(end / FRAME)))
|
||||
return first, max(first, last)
|
||||
|
||||
def quietest(self, start: float, end: float) -> float:
|
||||
"""Milieu de la zone la plus calme d'un intervalle : la coupe tombe au
|
||||
cœur de la pause, loin des deux phrases qu'elle sépare."""
|
||||
first, last = self._frames(start, end)
|
||||
if last - first < 1:
|
||||
return (start + end) / 2
|
||||
smooth = np.convolve(self.db, np.ones(5) / 5, mode="same")[first:last]
|
||||
quiet = smooth <= smooth.min() + 3.0 # à 3 dB du minimum
|
||||
best_start, best_length, run_start = 0, 0, None
|
||||
for i, is_quiet in enumerate([*quiet, False]):
|
||||
if is_quiet and run_start is None:
|
||||
run_start = i
|
||||
elif not is_quiet and run_start is not None:
|
||||
if i - run_start > best_length:
|
||||
best_start, best_length = run_start, i - run_start
|
||||
run_start = None
|
||||
return (first + best_start + best_length / 2) * FRAME
|
||||
|
||||
def first_sound(self, start: float, end: float) -> float | None:
|
||||
first, last = self._frames(start, end)
|
||||
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
||||
return (first + int(active[0])) * FRAME if len(active) else None
|
||||
|
||||
def last_sound(self, start: float, end: float) -> float | None:
|
||||
first, last = self._frames(start, end)
|
||||
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
||||
return (first + int(active[-1]) + 1) * FRAME if len(active) else None
|
||||
|
||||
def pauses(self, min_length: float = MIN_PAUSE) -> list[tuple[float, float]]:
|
||||
"""Silences (sous le seuil d'audibilité) d'au moins min_length secondes."""
|
||||
quiet = self.db <= self.threshold
|
||||
runs, run_start = [], None
|
||||
for i, is_quiet in enumerate([*quiet, False]):
|
||||
if is_quiet and run_start is None:
|
||||
run_start = i
|
||||
elif not is_quiet and run_start is not None:
|
||||
if (i - run_start) * FRAME >= min_length:
|
||||
runs.append((run_start * FRAME, i * FRAME))
|
||||
run_start = None
|
||||
return runs
|
||||
|
||||
def speech_span(self, start: float, end: float) -> tuple[float, float]:
|
||||
"""Parole contenue dans [start, end], avec ses marges, sans en sortir."""
|
||||
onset, offset = self.first_sound(start, end), self.last_sound(start, end)
|
||||
if onset is None or offset is None:
|
||||
return start, end
|
||||
return max(start, onset - LEAD_PAD), min(end, offset + TAIL_PAD)
|
||||
|
||||
|
||||
def envelope_from_samples(samples: np.ndarray, rate: int) -> Envelope:
|
||||
per_frame = int(rate * FRAME)
|
||||
count = len(samples) // per_frame
|
||||
if count == 0:
|
||||
return Envelope(np.array([-120.0]))
|
||||
frames = samples[: count * per_frame].reshape(count, per_frame).astype(np.float64)
|
||||
rms = np.sqrt(np.mean(frames**2, axis=1))
|
||||
return Envelope(20 * np.log10(np.maximum(rms, 1e-6)))
|
||||
|
||||
|
||||
async def load_envelope(path: Path) -> Envelope:
|
||||
"""Décode l'audio (mono 16 kHz) et mesure son niveau toutes les 10 ms."""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(path),
|
||||
"-ac", "1", "-ar", "16000", "-f", "f32le", "-",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
) # fmt: skip
|
||||
raw, err = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
raise proc.CommandError(["ffmpeg"], process.returncode, err.decode(errors="replace"))
|
||||
return envelope_from_samples(np.frombuffer(raw, dtype=np.float32), 16000)
|
||||
|
||||
|
||||
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float, after: float = 0.0) -> float:
|
||||
"""Coupe entre deux phrases, au cœur d'une vraie pause.
|
||||
|
||||
Les bornes Whisper sont imprécises, et fausses quand un mot n'a pas été
|
||||
reconnu (un prix en fin de phrase : sa place est alors estimée) ; couper
|
||||
à la borne estimée tombait parfois au milieu d'une phrase. On choisit donc
|
||||
la pause mesurée la plus proche de la borne estimée, les longues pauses
|
||||
(fin de phrase) étant préférées aux courtes (virgule)."""
|
||||
low = max(after, min(previous_end, next_start) - 0.1)
|
||||
high = max(low + 0.05, max(previous_end, next_start) + 0.2)
|
||||
best: tuple[float, float, float] | None = None
|
||||
for start, end in envelope.pauses():
|
||||
if end <= after + 0.05 or start <= 0.0 or end >= envelope.duration:
|
||||
continue # silences avant la première et après la dernière phrase exclus
|
||||
distance = max(0.0, low - end, start - high)
|
||||
if distance > PAUSE_SEARCH:
|
||||
continue
|
||||
score = distance - (end - start)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, max(start, after), end)
|
||||
if best:
|
||||
return envelope.quietest(best[1], best[2])
|
||||
return envelope.quietest(low, high)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Construction des commandes FFmpeg d'un Reel (fonctions pures, testables).
|
||||
|
||||
Deux usages :
|
||||
- rendu complet par FFmpeg (sous-titres ASS incrustés) : `build_command` ;
|
||||
- préparation pour Remotion, qui compose ensuite les sous-titres animés, le
|
||||
logo et l'effet de fin : `build_prepared_video_command` (image seule, déjà
|
||||
recadrée et allongée) et `build_audio_mix_command` (piste son finale).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import config
|
||||
from .subtitles import filter_path
|
||||
|
||||
FADE_SECONDS = 2.0
|
||||
# Fondu raccourci quand la voix finit tard, mais jamais sec
|
||||
MIN_FADE = 0.3
|
||||
LOGO_SECONDS = 5.0
|
||||
# Silence laissé après la dernière phrase avant la fin de la vidéo
|
||||
VOICE_TAIL = 0.8
|
||||
# Durée minimale de l'effet de fin (grand logo + nom du magasin) après la voix
|
||||
OUTRO_MIN = 2.5
|
||||
|
||||
# HDR (HLG/PQ) → SDR BT.709 : sans cela, les vidéos iPhone sortent ternes
|
||||
TONEMAP = (
|
||||
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,"
|
||||
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||||
)
|
||||
|
||||
COLOR_TAGS = ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderPlan:
|
||||
video: Path
|
||||
video_duration: float
|
||||
output: Path
|
||||
is_hdr: bool = False
|
||||
stabilize_transforms: Path | None = None
|
||||
music: Path | None = None
|
||||
music_volume: float = 0.25
|
||||
voice: Path | None = None
|
||||
voice_duration: float = 0.0
|
||||
voice_delay: float = config.VOICE_DELAY
|
||||
captions: Path | None = None
|
||||
watermark: Path | None = None
|
||||
outro: Path | None = None # nom du magasin (effet de fin)
|
||||
# Effet de fin prévu sans que le logo passe par FFmpeg (rendu Remotion)
|
||||
outro_expected: bool = False
|
||||
ending_effect: bool = True
|
||||
# Petit logo en coin pendant la vidéo (le grand logo de fin dépend de ending_effect)
|
||||
show_watermark: bool = True
|
||||
keep_original_audio: bool = False
|
||||
|
||||
@property
|
||||
def has_outro(self) -> bool:
|
||||
return self.ending_effect and (self.watermark is not None or self.outro_expected)
|
||||
|
||||
@property
|
||||
def speech_end(self) -> float:
|
||||
return self.voice_delay + self.voice_duration if self.voice else 0.0
|
||||
|
||||
@property
|
||||
def total_duration(self) -> float:
|
||||
"""La vidéo s'allonge (dernière image figée) si la voix dure plus longtemps."""
|
||||
duration = self.video_duration
|
||||
if self.voice:
|
||||
duration = max(duration, self.speech_end + VOICE_TAIL)
|
||||
if self.has_outro:
|
||||
duration = max(duration, self.speech_end + OUTRO_MIN)
|
||||
return round(duration, 3)
|
||||
|
||||
@property
|
||||
def logo_start(self) -> float:
|
||||
"""Le grand logo n'arrive qu'une fois la voix terminée."""
|
||||
return round(max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end), 3)
|
||||
|
||||
@property
|
||||
def fade_start(self) -> float:
|
||||
"""Le fondu final ne commence jamais avant la fin de la voix : il
|
||||
rognait la dernière phrase quand la vidéo finissait peu après."""
|
||||
start = max(0.0, self.total_duration - FADE_SECONDS)
|
||||
if self.voice:
|
||||
start = max(start, min(self.speech_end + 0.15, self.total_duration - MIN_FADE))
|
||||
return round(start, 3)
|
||||
|
||||
@property
|
||||
def fade_duration(self) -> float:
|
||||
return round(max(MIN_FADE, self.total_duration - self.fade_start), 3)
|
||||
|
||||
@property
|
||||
def freeze_duration(self) -> float:
|
||||
return max(0.0, self.total_duration - self.video_duration)
|
||||
|
||||
|
||||
def video_filters(plan: RenderPlan) -> list[str]:
|
||||
"""Stabilisation, HDR, recadrage 9:16, cadence fixe, dernière image figée."""
|
||||
chain = []
|
||||
if plan.stabilize_transforms:
|
||||
chain.append(
|
||||
f"vidstabtransform=input={filter_path(plan.stabilize_transforms)}:smoothing=30:relative=1:zoom=5,"
|
||||
"unsharp=5:5:0.6:5:5:0.0"
|
||||
)
|
||||
if plan.is_hdr:
|
||||
chain.append(TONEMAP)
|
||||
chain.append(
|
||||
f"scale={config.WIDTH}:{config.HEIGHT}:force_original_aspect_ratio=increase:flags=lanczos,"
|
||||
f"crop={config.WIDTH}:{config.HEIGHT},setsar=1,fps={config.FPS}"
|
||||
)
|
||||
if plan.freeze_duration > 0:
|
||||
chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}")
|
||||
return chain
|
||||
|
||||
|
||||
def uses_original_audio(plan: RenderPlan) -> bool:
|
||||
"""Sans musique choisie, la bande son d'origine de la vidéo sert de fond sonore."""
|
||||
return plan.music is None and plan.keep_original_audio
|
||||
|
||||
|
||||
def audio_graph(
|
||||
plan: RenderPlan, music_idx: int | None, voice_idx: int | None, original_idx: int | None = None
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Graphe audio : voix décalée sur un fond sonore (musique bouclée, ou à
|
||||
défaut bande son d'origine) baissé sous la voix, niveau final ~ -14 LUFS.
|
||||
Renvoie les filtres et l'étiquette de sortie."""
|
||||
graph: list[str] = []
|
||||
if voice_idx is not None:
|
||||
delay_ms = int(plan.voice_delay * 1000)
|
||||
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
|
||||
|
||||
bed = None
|
||||
if music_idx is not None:
|
||||
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[bed]")
|
||||
bed = "bed"
|
||||
elif original_idx is not None:
|
||||
# Son d'origine conservé à son niveau ; il se tait après la fin de la vidéo
|
||||
graph.append(f"[{original_idx}:a:0]aresample=48000,apad[bed]")
|
||||
bed = "bed"
|
||||
|
||||
if voice_idx is not None and bed:
|
||||
# Le fond sonore baisse automatiquement quand la voix parle (ducking)
|
||||
graph.append("[voice]asplit=2[vmix][vkey]")
|
||||
graph.append(f"[{bed}][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[ducked]")
|
||||
graph.append("[ducked][vmix]amix=inputs=2:duration=longest:normalize=0[mix]")
|
||||
mix = "mix"
|
||||
elif voice_idx is not None:
|
||||
mix = "voice"
|
||||
elif bed:
|
||||
mix = bed
|
||||
else:
|
||||
return graph, None
|
||||
|
||||
tail = ["loudnorm=I=-14:TP=-1.5:LRA=20", "aresample=48000"]
|
||||
if plan.ending_effect:
|
||||
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={plan.fade_duration}")
|
||||
graph.append(f"[{mix}]{','.join(tail)}[aout]")
|
||||
return graph, "aout"
|
||||
|
||||
|
||||
def _audio_inputs(plan: RenderPlan, first_index: int) -> tuple[list[str], int | None, int | None, int]:
|
||||
args: list[str] = []
|
||||
index = first_index
|
||||
music_idx = voice_idx = None
|
||||
if plan.music:
|
||||
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
|
||||
args += ["-stream_loop", "-1", "-i", str(plan.music)]
|
||||
music_idx, index = index, index + 1
|
||||
if plan.voice:
|
||||
args += ["-i", str(plan.voice)]
|
||||
voice_idx, index = index, index + 1
|
||||
return args, music_idx, voice_idx, index
|
||||
|
||||
|
||||
def _video_encoding(crf: int, preset: str) -> list[str]:
|
||||
return [
|
||||
"-c:v", "libx264", "-preset", preset, "-crf", str(crf),
|
||||
"-maxrate", "10M", "-bufsize", "20M",
|
||||
"-profile:v", "high", "-level", "4.2",
|
||||
"-g", str(config.FPS * 2), "-keyint_min", str(config.FPS),
|
||||
"-pix_fmt", "yuv420p", *COLOR_TAGS,
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def build_command(plan: RenderPlan) -> list[str]:
|
||||
"""Rendu complet par FFmpeg, sous-titres ASS incrustés."""
|
||||
total = plan.total_duration
|
||||
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
|
||||
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 1)
|
||||
cmd += audio_args
|
||||
wm_idx = None
|
||||
if plan.watermark:
|
||||
cmd += ["-i", str(plan.watermark)]
|
||||
wm_idx = index
|
||||
|
||||
chain = video_filters(plan)
|
||||
if plan.captions:
|
||||
chain.append(f"subtitles='{filter_path(plan.captions)}'")
|
||||
graph = [f"[0:v]{','.join(chain)}[vbase]"]
|
||||
|
||||
current = "vbase"
|
||||
if wm_idx is not None:
|
||||
corner = "W-w-30:H-h-30"
|
||||
big_logo = bool(plan.outro and plan.ending_effect)
|
||||
logo_start = plan.logo_start
|
||||
if big_logo and plan.show_watermark:
|
||||
graph.append(f"[{wm_idx}:v]split=2[wm_small0][wm_big0]")
|
||||
graph.append("[wm_small0]scale=200:-1[wm_small]")
|
||||
elif plan.show_watermark:
|
||||
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
|
||||
elif big_logo:
|
||||
graph.append(f"[{wm_idx}:v]null[wm_big0]")
|
||||
if plan.show_watermark:
|
||||
until = f":enable='lt(t,{logo_start:.3f})'" if big_logo else ""
|
||||
graph.append(f"[{current}][wm_small]overlay={corner}{until}[vwm1]")
|
||||
current = "vwm1"
|
||||
if big_logo:
|
||||
graph.append("[wm_big0]scale=-1:300[wm_big]")
|
||||
graph.append(
|
||||
f"[{current}][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]"
|
||||
)
|
||||
current = "vwm2"
|
||||
if plan.outro and plan.ending_effect:
|
||||
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
|
||||
current = "vout0"
|
||||
|
||||
tail = []
|
||||
if plan.ending_effect:
|
||||
tail.append(f"fade=t=out:st={plan.fade_start:.3f}:d={plan.fade_duration}")
|
||||
tail.append("format=yuv420p")
|
||||
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
||||
|
||||
# La vidéo (entrée 0) fournit la bande son d'origine si aucune musique n'est choisie
|
||||
original_idx = 0 if uses_original_audio(plan) else None
|
||||
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
||||
graph += audio_filters
|
||||
audio_map = ["-map", f"[{audio_out}]"] if audio_out else []
|
||||
|
||||
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
|
||||
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-movflags", "+faststart"]
|
||||
cmd.append(str(plan.output))
|
||||
return cmd
|
||||
|
||||
|
||||
def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
|
||||
"""Image seule, prête pour Remotion : recadrée, en cadence fixe, à la durée finale.
|
||||
Qualité élevée (CRF 16) : elle sera réencodée une fois par Remotion."""
|
||||
chain = [*video_filters(plan), "format=yuv420p"]
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
|
||||
"-vf", ",".join(chain), "-an", "-t", f"{plan.total_duration:.3f}",
|
||||
*_video_encoding(16, "veryfast"), "-movflags", "+faststart", str(output),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
|
||||
"""Piste son finale (WAV 48 kHz stéréo), ou None s'il n'y a aucun son à produire."""
|
||||
total = f"{plan.total_duration:.3f}"
|
||||
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 0)
|
||||
original_idx = None
|
||||
if uses_original_audio(plan):
|
||||
audio_args += ["-i", str(plan.video)]
|
||||
original_idx = index
|
||||
graph, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
||||
if not audio_out:
|
||||
return None
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
|
||||
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
|
||||
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def stabilize_detect_command(video: Path, transforms: Path) -> list[str]:
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(video),
|
||||
"-vf", f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
|
||||
"-f", "null", "-",
|
||||
] # fmt: skip
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Voix calée sur un fichier SRT.
|
||||
|
||||
Tout le texte est lu d'une seule traite, puis la voix est découpée phrase par
|
||||
phrase au point le plus calme de chaque pause, et chaque morceau est posé à l'instant de son
|
||||
sous-titre. Une lecture unique garde la même intonation, le même rythme et le
|
||||
même niveau d'un bout à l'autre ; lire chaque sous-titre séparément donnait
|
||||
une voix différente à chaque phrase, et le découpage aux bornes estimées par
|
||||
Whisper rognait des syllabes.
|
||||
|
||||
Si la lecture unique ne peut pas être répartie entre les sous-titres, on lit
|
||||
chaque sous-titre séparément (même voix, même graine), en découpant là aussi
|
||||
au niveau sonore mesuré.
|
||||
|
||||
Quand un morceau est plus long que son sous-titre, il est accéléré (sans
|
||||
changer la hauteur de la voix) pour finir à temps.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import proc, quality, tts
|
||||
from .align import Word, display_tokens
|
||||
from .text import clean_text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Débordement toléré sur le silence qui suit un sous-titre avant d'accélérer
|
||||
OVERFLOW_TOLERANCE = 0.25
|
||||
# Au-delà, l'accélération s'entend nettement : l'utilisateur est prévenu
|
||||
AUDIBLE_SPEEDUP = 1.35
|
||||
|
||||
_SENTENCE_END = re.compile(r"[.!?…]$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cue:
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
"""Morceau de la voix lue, à poser à l'instant d'un sous-titre."""
|
||||
|
||||
source_start: float
|
||||
source_end: float
|
||||
words: list[Word]
|
||||
|
||||
|
||||
def cue_windows(cues: list[Cue]) -> list[float]:
|
||||
"""Durée disponible pour lire chaque sous-titre sans empiéter sur le suivant."""
|
||||
windows = []
|
||||
for i, cue in enumerate(cues):
|
||||
limit = cue.end + OVERFLOW_TOLERANCE
|
||||
if i + 1 < len(cues):
|
||||
limit = min(limit, max(cue.end, cues[i + 1].start))
|
||||
windows.append(max(0.1, limit - cue.start))
|
||||
return windows
|
||||
|
||||
|
||||
def speed_factor(duration: float, window: float) -> float:
|
||||
"""Accélération nécessaire pour tenir dans la fenêtre (1 = vitesse normale)."""
|
||||
return max(1.0, duration / window) if window > 0 else 1.0
|
||||
|
||||
|
||||
def atempo_chain(factor: float) -> str:
|
||||
"""Filtre atempo ; découpé en étapes ≤ 2 pour les anciennes versions de FFmpeg."""
|
||||
steps = []
|
||||
remaining = factor
|
||||
while remaining > 2.0:
|
||||
steps.append(2.0)
|
||||
remaining /= 2.0
|
||||
steps.append(remaining)
|
||||
return ",".join(f"atempo={s:.4f}" for s in steps)
|
||||
|
||||
|
||||
def as_sentence(text: str) -> str:
|
||||
"""Chaque sous-titre finit par une ponctuation : la voix y marque une pause,
|
||||
ce qui laisse un silence où couper."""
|
||||
text = clean_text(text)
|
||||
return text if _SENTENCE_END.search(text) else f"{text}."
|
||||
|
||||
|
||||
def split_by_cue(words: list[Word], cue_texts: list[str]) -> list[list[Word]] | None:
|
||||
"""Répartit les mots de la lecture complète entre les sous-titres
|
||||
(les mots affichés suivent l'ordre du texte). None si les comptes divergent."""
|
||||
counts = [len(display_tokens(t)) for t in cue_texts]
|
||||
if sum(counts) != len(words) or 0 in counts:
|
||||
return None
|
||||
groups, index = [], 0
|
||||
for count in counts:
|
||||
groups.append(words[index : index + count])
|
||||
index += count
|
||||
return groups
|
||||
|
||||
|
||||
def segments_from_reading(groups: list[list[Word]], envelope: quality.Envelope) -> list[Segment] | None:
|
||||
"""Découpe la lecture complète entre les phrases, au point le plus calme de
|
||||
chaque pause, puis retire le silence autour de chaque phrase en gardant une
|
||||
marge (300 ms après le dernier son : les fins de phrases ne sont pas rognées)."""
|
||||
cuts = [0.0]
|
||||
for previous, following in zip(groups, groups[1:]):
|
||||
cuts.append(quality.sentence_cut(envelope, previous[-1].end, following[0].start, after=cuts[-1]))
|
||||
cuts.append(envelope.duration)
|
||||
if any(b <= a for a, b in zip(cuts, cuts[1:])):
|
||||
return None
|
||||
|
||||
segments = []
|
||||
for i, group in enumerate(groups):
|
||||
seg_start, seg_end = envelope.speech_span(cuts[i], cuts[i + 1])
|
||||
segments.append(Segment(seg_start, seg_end, group))
|
||||
return segments
|
||||
|
||||
|
||||
async def synthesize_cues(
|
||||
*,
|
||||
cues: list[Cue],
|
||||
engine: str | None,
|
||||
voice: str | None,
|
||||
style: str | None,
|
||||
gemini_api_key: str | None,
|
||||
workdir: Path,
|
||||
) -> tts.VoiceTrack:
|
||||
"""Voix complète : mots minutés en secondes depuis le début de la vidéo."""
|
||||
cues = sorted((c for c in cues if clean_text(c.text)), key=lambda c: c.start)
|
||||
if not cues:
|
||||
raise ValueError("Aucun sous-titre lisible dans le fichier SRT")
|
||||
|
||||
options = dict(engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key)
|
||||
texts = [as_sentence(c.text) for c in cues]
|
||||
|
||||
# 1. Lecture d'une seule traite, découpée dans les silences
|
||||
script = " ".join(texts)
|
||||
reading = await tts.synthesize(text=script, display_text=script, workdir=workdir, **options)
|
||||
envelope = await quality.load_envelope(reading.path)
|
||||
groups = split_by_cue(reading.words, texts)
|
||||
segments = segments_from_reading(groups, envelope) if groups else None
|
||||
|
||||
if segments:
|
||||
sources = [(reading.path, seg) for seg in segments]
|
||||
warnings = list(reading.warnings)
|
||||
used_engine, used_voice = reading.engine, reading.voice
|
||||
else:
|
||||
# 2. Repli : chaque sous-titre lu séparément, avec la voix de la lecture complète
|
||||
log.warning("Lecture SRT non répartie entre les sous-titres : lecture phrase par phrase")
|
||||
sources, warnings = [], list(reading.warnings)
|
||||
used_engine, used_voice = reading.engine, reading.voice
|
||||
for index, text in enumerate(texts):
|
||||
cue_dir = workdir / f"cue_{index:03d}"
|
||||
cue_dir.mkdir(exist_ok=True)
|
||||
track = await tts.synthesize(
|
||||
text=text,
|
||||
display_text=text,
|
||||
workdir=cue_dir,
|
||||
**{**options, "engine": used_engine, "voice": used_voice or voice},
|
||||
)
|
||||
warnings += [w for w in track.warnings if w not in warnings]
|
||||
cue_envelope = await quality.load_envelope(track.path)
|
||||
span = cue_envelope.speech_span(0.0, cue_envelope.duration)
|
||||
sources.append((track.path, Segment(span[0], span[1], track.words)))
|
||||
|
||||
placed: list[tuple[Path, float]] = []
|
||||
words: list[Word] = []
|
||||
for index, (cue, window, (source, segment)) in enumerate(zip(cues, cue_windows(cues), sources)):
|
||||
factor = speed_factor(segment.source_end - segment.source_start, window)
|
||||
if factor > AUDIBLE_SPEEDUP:
|
||||
warnings.append(
|
||||
f"Sous-titre {index + 1} (« {cue.text[:40]} ») lu {factor:.1f}× plus vite "
|
||||
"pour tenir dans son minutage : raccourcissez-le ou allongez sa durée."
|
||||
)
|
||||
fitted = workdir / f"segment_{index:03d}.wav"
|
||||
await proc.run(extract_command(source, segment, factor, fitted), timeout=120)
|
||||
placed.append((fitted, cue.start))
|
||||
added_period = not _SENTENCE_END.search(clean_text(cue.text))
|
||||
for position, w in enumerate(segment.words):
|
||||
text = w.text
|
||||
if added_period and position == len(segment.words) - 1:
|
||||
text = text.rstrip(".") # point ajouté pour la lecture, pas à afficher
|
||||
start = cue.start + max(0.0, w.start - segment.source_start) / factor
|
||||
end = cue.start + max(0.0, w.end - segment.source_start) / factor
|
||||
words.append(Word(text or w.text, start, min(max(end, start + 0.05), cue.start + window)))
|
||||
|
||||
output = workdir / "voice_srt.wav"
|
||||
await proc.run(mix_command(placed, output), timeout=300)
|
||||
duration = (await proc.probe(output)).duration
|
||||
log.info(
|
||||
"Voix SRT prête : %s/%s, %d sous-titres, %.1f s (%s)",
|
||||
used_engine,
|
||||
used_voice,
|
||||
len(cues),
|
||||
duration,
|
||||
"lecture unique" if segments else "phrase par phrase",
|
||||
)
|
||||
return tts.VoiceTrack(output, duration, _monotonic(words), used_engine, used_voice, warnings)
|
||||
|
||||
|
||||
def extract_command(source: Path, segment: Segment, factor: float, output: Path) -> list[str]:
|
||||
"""Extrait un morceau de voix, avec un fondu de 10 ms aux bords (pas de clic)
|
||||
et l'accélération éventuelle."""
|
||||
length = segment.source_end - segment.source_start
|
||||
fade_out = max(0.0, length - 0.01)
|
||||
filters = (
|
||||
f"atrim=start={segment.source_start:.3f}:end={segment.source_end:.3f},asetpts=PTS-STARTPTS,"
|
||||
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01,{atempo_chain(factor)}"
|
||||
)
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
|
||||
"-af", filters, "-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def mix_command(segments: list[tuple[Path, float]], output: Path) -> list[str]:
|
||||
"""Pose chaque segment de voix à son instant, sur une seule piste."""
|
||||
command = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
|
||||
for path, _ in segments:
|
||||
command += ["-i", str(path)]
|
||||
graph = [f"[{i}:a]adelay={int(round(start * 1000))}:all=1[s{i}]" for i, (_, start) in enumerate(segments)]
|
||||
labels = "".join(f"[s{i}]" for i in range(len(segments)))
|
||||
graph.append(
|
||||
f"{labels}amix=inputs={len(segments)}:duration=longest:dropout_transition=0:normalize=0[voice]"
|
||||
)
|
||||
return command + [
|
||||
"-filter_complex", ";".join(graph), "-map", "[voice]",
|
||||
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def _monotonic(words: list[Word]) -> list[Word]:
|
||||
for prev, cur in zip(words, words[1:]):
|
||||
cur.start = max(cur.start, prev.start + 0.01)
|
||||
cur.end = max(cur.end, cur.start + 0.05)
|
||||
return words
|
||||
@@ -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,141 @@
|
||||
"""Synthèse vocale : moteur au choix, repli sur Edge, voix traitée et mots minutés."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .. import align, audio, config, proc, quality
|
||||
from ..align import Word
|
||||
from . import edge, gemini
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceTrack:
|
||||
path: Path # WAV 48 kHz traité
|
||||
duration: float
|
||||
words: list[Word] # mots affichés, minutés depuis le début de la voix
|
||||
engine: str
|
||||
voice: str
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
async def synthesize(
|
||||
*,
|
||||
text: str,
|
||||
display_text: str,
|
||||
engine: str | None,
|
||||
voice: str | None,
|
||||
style: str | None,
|
||||
gemini_api_key: str | None,
|
||||
workdir: Path,
|
||||
) -> VoiceTrack:
|
||||
warnings: list[str] = []
|
||||
processed: Path | None = None
|
||||
spoken: list[Word] = []
|
||||
used_engine, used_voice = "edge", ""
|
||||
|
||||
if (engine or "gemini") == "gemini":
|
||||
if not gemini_api_key:
|
||||
warnings.append("Clé Gemini absente : voix Edge utilisée à la place.")
|
||||
else:
|
||||
result = await _gemini_checked(text, voice, style, gemini_api_key, workdir, warnings)
|
||||
if result:
|
||||
processed, spoken, used_voice, reading_ok = result
|
||||
used_engine = "gemini"
|
||||
if reading_ok:
|
||||
processed, spoken = await _trim_stray_sounds(processed, spoken, workdir)
|
||||
|
||||
if processed is None:
|
||||
raw, spoken, used_voice = await edge.synthesize(text, voice, style, workdir)
|
||||
processed = workdir / "voice.wav"
|
||||
await audio.process_voice(raw, processed)
|
||||
|
||||
duration = (await proc.probe(processed)).duration
|
||||
if not spoken:
|
||||
spoken = await align.transcribe(processed)
|
||||
|
||||
words = align.align_words(display_text, spoken, duration)
|
||||
log.info("Voix prête : %s/%s, %.1f s, %d mots", used_engine, used_voice, duration, len(words))
|
||||
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
|
||||
|
||||
|
||||
async def _gemini_checked(
|
||||
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, warnings: list[str]
|
||||
) -> tuple[Path, list[Word], str, bool] | None:
|
||||
"""Voix Gemini dont la lecture a été vérifiée : Whisper réécoute chaque
|
||||
génération ; mots sautés, fin tronquée ou consigne lue à voix haute
|
||||
déclenchent une nouvelle génération. Renvoie None pour se replier sur Edge."""
|
||||
best: tuple[quality.ReadingCheck, Path, list[Word], str] | None = None
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(max(1, config.GEMINI_TTS_ATTEMPTS)):
|
||||
try:
|
||||
raw, gemini_voice = await gemini.synthesize(text, voice, style, api_key, workdir, attempt)
|
||||
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis repli sur Edge
|
||||
log.warning("Gemini TTS en échec (tentative %d) : %s", attempt + 1, error)
|
||||
last_error = error
|
||||
continue
|
||||
|
||||
processed = workdir / f"voice_gemini_{attempt}.wav"
|
||||
await audio.process_voice(raw, processed)
|
||||
spoken = await align.transcribe(processed)
|
||||
check = quality.check_reading(text, spoken)
|
||||
log.info("Lecture Gemini (tentative %d) : %s", attempt + 1, check.describe())
|
||||
|
||||
if best is None or _score(check) > _score(best[0]):
|
||||
best = (check, processed, spoken, gemini_voice)
|
||||
if check.acceptable:
|
||||
break
|
||||
|
||||
if best is None:
|
||||
warnings.append(f"Gemini indisponible ({last_error}) : voix Edge utilisée à la place.")
|
||||
return None
|
||||
|
||||
check, processed, spoken, gemini_voice = best
|
||||
if not check.acceptable:
|
||||
if check.coverage < 0.7:
|
||||
warnings.append(f"Gemini a mal lu le texte ({check.describe()}) : voix Edge utilisée à la place.")
|
||||
return None
|
||||
warnings.append(f"Lecture Gemini imparfaite ({check.describe()}) : écoutez le résultat.")
|
||||
return processed, spoken, gemini_voice, check.acceptable
|
||||
|
||||
|
||||
# Au-delà de la fin du dernier mot, ce n'est plus de la parole
|
||||
STRAY_AFTER_LAST_WORD = 0.6
|
||||
STRAY_BEFORE_FIRST_WORD = 0.4
|
||||
|
||||
|
||||
async def _trim_stray_sounds(path: Path, spoken: list[Word], workdir: Path) -> tuple[Path, list[Word]]:
|
||||
"""Retire les bruits que Gemini ajoute parfois avant le premier ou après le
|
||||
dernier mot. Appelé seulement quand la lecture a été vérifiée : le dernier
|
||||
mot est bien reconnu, on ne coupe donc jamais de parole."""
|
||||
if not spoken:
|
||||
return path, spoken
|
||||
envelope = await quality.load_envelope(path)
|
||||
duration = envelope.duration
|
||||
window_start = max(0.0, spoken[0].start - STRAY_BEFORE_FIRST_WORD)
|
||||
window_end = min(duration, spoken[-1].end + STRAY_AFTER_LAST_WORD)
|
||||
start, end = envelope.speech_span(window_start, window_end)
|
||||
if start < 0.02 and end > duration - 0.02:
|
||||
return path, spoken
|
||||
|
||||
trimmed = workdir / "voice_trimmed.wav"
|
||||
fade_out = max(0.0, end - start - 0.01)
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(path),
|
||||
"-af", f"atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS,"
|
||||
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01",
|
||||
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(trimmed),
|
||||
],
|
||||
timeout=60,
|
||||
) # fmt: skip
|
||||
log.info("Voix recadrée sur la parole : %.2f–%.2f s (sur %.2f s)", start, end, duration)
|
||||
shifted = [Word(w.text, w.start - start, w.end - start) for w in spoken if w.start < end]
|
||||
return trimmed, shifted
|
||||
|
||||
|
||||
def _score(check: quality.ReadingCheck) -> float:
|
||||
return check.coverage - check.extra + (0.2 if check.ending_ok else 0.0)
|
||||
@@ -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,122 @@
|
||||
"""Synthèse vocale Gemini (API generateContent, modalité audio)."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import config, proc
|
||||
from ..voices import resolve_gemini_voice, style_instruction
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
API_ROOT = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
FALLBACK_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
|
||||
|
||||
class GeminiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def build_prompt(text: str, style: str | None) -> str:
|
||||
"""Texte envoyé au modèle : la consigne de style précède le texte à lire.
|
||||
La consigne demande une lecture intégrale, sans ajout : le modèle sautait
|
||||
parfois des mots ou tronquait la fin."""
|
||||
instruction = style_instruction(style)
|
||||
if not instruction:
|
||||
return text
|
||||
return f"{instruction}. Lis tout le texte, mot pour mot, sans rien ajouter :\n{text}"
|
||||
|
||||
|
||||
# Paramètres de tirage refusés par un modèle : on ne les renvoie plus
|
||||
_unsupported_sampling: set[str] = set()
|
||||
|
||||
|
||||
async def _request(model: str, prompt: str, voice: str, api_key: str, seed: int) -> dict:
|
||||
generation: dict = {
|
||||
"responseModalities": ["AUDIO"],
|
||||
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}},
|
||||
}
|
||||
if model not in _unsupported_sampling:
|
||||
# Intonation stable d'une génération à l'autre
|
||||
generation["temperature"] = config.GEMINI_TTS_TEMPERATURE
|
||||
generation["seed"] = seed
|
||||
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": generation}
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
|
||||
response = await client.post(
|
||||
f"{API_ROOT}/{model}:generateContent",
|
||||
json=payload,
|
||||
headers={"x-goog-api-key": api_key},
|
||||
)
|
||||
rejected_sampling = response.status_code == 400 and any(
|
||||
word in response.text.lower() for word in ("seed", "temperature", "unknown name", "invalid json")
|
||||
)
|
||||
if rejected_sampling and "temperature" in generation:
|
||||
# Modèle qui refuse température ou graine : même requête sans eux
|
||||
log.warning("Gemini %s refuse les paramètres de tirage : %s", model, response.text[:200])
|
||||
_unsupported_sampling.add(model)
|
||||
return await _request(model, prompt, voice, api_key, seed)
|
||||
if response.status_code != 200:
|
||||
raise GeminiError(f"Gemini {model} : HTTP {response.status_code} {response.text[:300]}")
|
||||
return response.json()
|
||||
|
||||
|
||||
async def synthesize(
|
||||
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, attempt: int = 0
|
||||
) -> tuple[Path, str]:
|
||||
"""Génère la voix ; renvoie un WAV brut et le nom de la voix utilisée.
|
||||
Chaque nouvelle tentative change de graine pour obtenir une autre lecture."""
|
||||
seed = config.GEMINI_TTS_SEED + attempt
|
||||
gemini_voice = resolve_gemini_voice(voice).id
|
||||
prompt = build_prompt(text, style)
|
||||
model = config.GEMINI_TTS_MODEL
|
||||
log.info("Gemini TTS : modèle=%s voix=%s style=%s (%d car.)", model, gemini_voice, style, len(text))
|
||||
|
||||
try:
|
||||
data = await _request(model, prompt, gemini_voice, api_key, seed)
|
||||
except GeminiError as error:
|
||||
if model == FALLBACK_MODEL or "HTTP 404" not in str(error):
|
||||
raise
|
||||
log.warning("Modèle %s indisponible, repli sur %s", model, FALLBACK_MODEL)
|
||||
data = await _request(FALLBACK_MODEL, prompt, gemini_voice, api_key, seed)
|
||||
|
||||
try:
|
||||
part = next(p for p in data["candidates"][0]["content"]["parts"] if "inlineData" in p)["inlineData"]
|
||||
except (KeyError, IndexError, StopIteration) as error:
|
||||
raise GeminiError(f"Réponse Gemini sans audio : {str(data)[:300]}") from error
|
||||
|
||||
audio = base64.b64decode(part["data"])
|
||||
mime = part.get("mimeType", "")
|
||||
raw = workdir / f"gemini_raw_{attempt}.wav"
|
||||
|
||||
if "wav" in mime:
|
||||
raw.write_bytes(audio)
|
||||
else:
|
||||
# PCM brut 16 bits (« audio/L16;codec=pcm;rate=24000 »)
|
||||
rate_match = re.search(r"rate=(\d+)", mime)
|
||||
pcm = workdir / f"gemini_{attempt}.pcm"
|
||||
pcm.write_bytes(audio)
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"s16le",
|
||||
"-ar",
|
||||
rate_match.group(1) if rate_match else "24000",
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
str(pcm),
|
||||
str(raw),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
pcm.unlink(missing_ok=True)
|
||||
return raw, gemini_voice
|
||||
@@ -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,8 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
python-multipart==0.0.6
|
||||
requests==2.31.0
|
||||
pydantic==2.6.0
|
||||
edge-tts==6.1.12
|
||||
emoji
|
||||
ffsubsync==0.4.26
|
||||
httpx>=0.25.0
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
pydantic==2.11.4
|
||||
httpx==0.28.1
|
||||
edge-tts==7.2.8
|
||||
emoji==2.14.1
|
||||
faster-whisper==1.2.1
|
||||
numpy>=1.26
|
||||
@@ -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,105 @@
|
||||
import numpy as np
|
||||
|
||||
from app.align import Word
|
||||
from app.quality import FRAME, Envelope, check_reading, envelope_from_samples, sentence_cut
|
||||
|
||||
|
||||
def _heard(text: str) -> list[Word]:
|
||||
return [Word(t, i * 0.3, i * 0.3 + 0.25) for i, t in enumerate(text.split())]
|
||||
|
||||
|
||||
def test_complete_reading_is_accepted():
|
||||
check = check_reading(
|
||||
"Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés en magasin")
|
||||
)
|
||||
assert check.acceptable and check.coverage == 1.0
|
||||
|
||||
|
||||
def test_truncated_ending_is_rejected():
|
||||
check = check_reading("Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés"))
|
||||
assert not check.ending_ok and not check.acceptable
|
||||
|
||||
|
||||
def test_instruction_read_aloud_is_rejected():
|
||||
heard = _heard("lis ce texte sur un ton dynamique découvrez nos nouveautés en magasin")
|
||||
assert not check_reading("Découvrez nos nouveautés en magasin", heard).acceptable
|
||||
|
||||
|
||||
def _envelope(levels):
|
||||
"""Enveloppe synthétique : liste de (durée en s, niveau en dB)."""
|
||||
db = []
|
||||
for seconds, level in levels:
|
||||
db += [level] * int(round(seconds / FRAME))
|
||||
return Envelope(np.array(db, dtype=float))
|
||||
|
||||
|
||||
def test_soft_sentence_endings_are_kept():
|
||||
# Parole à -20 dB, fin de phrase douce à -50 dB (relatif : -30 dB, audible),
|
||||
# puis vrai silence à -90 dB
|
||||
env = _envelope([(0.2, -90), (1.0, -20), (0.3, -50), (0.6, -90)])
|
||||
start, end = env.speech_span(0.0, env.duration)
|
||||
assert round(start, 2) == 0.12 # 80 ms avant la parole
|
||||
assert round(end, 2) == 1.8 # 300 ms après la fin douce (à 1,5 s)
|
||||
|
||||
|
||||
def test_cut_falls_in_the_quietest_part_of_the_pause():
|
||||
env = _envelope([(1.0, -20), (0.15, -60), (0.2, -85), (0.15, -60), (1.0, -20)])
|
||||
cut = sentence_cut(env, previous_end=0.95, next_start=1.55)
|
||||
assert 1.15 <= cut <= 1.35
|
||||
|
||||
|
||||
def test_envelope_from_samples_measures_level():
|
||||
rate = 16000
|
||||
samples = np.concatenate([np.zeros(rate // 2), 0.5 * np.ones(rate // 2)]).astype(np.float32)
|
||||
env = envelope_from_samples(samples, rate)
|
||||
assert env.db[0] < -100 and abs(env.db[-1] - 20 * np.log10(0.5)) < 0.1
|
||||
|
||||
|
||||
def test_small_recognition_errors_are_not_missing_words():
|
||||
expected = "Découvrez nos plantes de saison à petits prix. On vous attend ce week-end."
|
||||
heard = _heard("Découvrez nos plans de saison à petit prix. On vous attend ce week -end.")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
|
||||
|
||||
def test_skipped_words_are_detected_and_named():
|
||||
expected = (
|
||||
"Pour Halloween découvrez nos costumes nos bonbons et toute notre décoration effrayante en magasin."
|
||||
)
|
||||
heard = _heard("Pour Halloween découvrez nos costumes et toute notre décoration en magasin.")
|
||||
check = check_reading(expected, heard)
|
||||
assert not check.acceptable
|
||||
assert "bonbons" in check.missing and "effrayante" in check.missing
|
||||
assert "non entendus" in check.describe()
|
||||
|
||||
|
||||
def test_prices_and_units_written_differently_are_not_missing_words():
|
||||
expected = "Alerte léopard ! Le coussin de 40 centimètres à 11,99 €. Le cabas à 13,99 €."
|
||||
heard = _heard("Alerte léopard ! Le coussin de 40 cm à 11 € 99. Le cabas à 13,99 euros.")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
assert check.missing == []
|
||||
|
||||
|
||||
def test_skipped_word_next_to_a_price_is_still_detected():
|
||||
expected = "Le coussin léopard à 11,99 €. Le cabas doux et chaud à 13,99 €."
|
||||
heard = _heard("Le coussin à 11,99 €. Le cabas à 13,99 €.")
|
||||
check = check_reading(expected, heard)
|
||||
assert not check.acceptable
|
||||
assert "léopard" in check.missing
|
||||
|
||||
|
||||
def test_cut_uses_the_real_pause_when_word_timing_is_estimated():
|
||||
# Phrase 1 jusqu'à 2,0 s (dont un prix non reconnu), virgule à 1,0 s,
|
||||
# pause de fin de phrase 2,0–2,4 s. Whisper place la fin de la phrase
|
||||
# et le début de la suivante vers 2,9 s (mots interpolés).
|
||||
env = _envelope([(0.3, -90), (0.7, -20), (0.1, -90), (0.9, -20), (0.4, -90), (1.5, -20), (0.3, -90)])
|
||||
cut = sentence_cut(env, previous_end=2.9, next_start=2.95)
|
||||
assert 2.0 <= cut <= 2.4
|
||||
|
||||
|
||||
def test_cut_prefers_the_sentence_pause_over_a_comma():
|
||||
env = _envelope([(0.3, -90), (1.0, -20), (0.12, -90), (0.8, -20), (0.45, -90), (1.0, -20), (0.3, -90)])
|
||||
# Borne estimée entre la virgule (1,3 s) et la fin de phrase (2,22 s)
|
||||
cut = sentence_cut(env, previous_end=1.7, next_start=1.75)
|
||||
assert 2.22 <= cut <= 2.67
|
||||
@@ -0,0 +1,171 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.render import RenderPlan, build_command
|
||||
|
||||
|
||||
def _graph(cmd):
|
||||
return cmd[cmd.index("-filter_complex") + 1]
|
||||
|
||||
|
||||
def test_voice_longer_than_video_extends_with_frozen_frame():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=5.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=10.0,
|
||||
)
|
||||
assert plan.total_duration == 12.8
|
||||
cmd = build_command(plan)
|
||||
assert "tpad=stop_mode=clone:stop_duration=7.800" in _graph(cmd)
|
||||
assert cmd[cmd.index("-t") + 1] == "12.800"
|
||||
|
||||
|
||||
def test_music_is_looped_and_ducked_under_voice():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=20.0,
|
||||
output=Path("out.mp4"),
|
||||
music=Path("m.mp3"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=5.0,
|
||||
)
|
||||
cmd = build_command(plan)
|
||||
assert cmd[cmd.index("m.mp3") - 3 : cmd.index("m.mp3")] == ["-stream_loop", "-1", "-i"]
|
||||
graph = _graph(cmd)
|
||||
assert "sidechaincompress" in graph
|
||||
assert "amix=inputs=2:duration=longest" in graph
|
||||
assert "loudnorm=I=-14" in graph
|
||||
|
||||
|
||||
def test_no_brightness_hack_and_hdr_tonemapped():
|
||||
plan = RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), is_hdr=True)
|
||||
graph = _graph(build_command(plan))
|
||||
assert "eq=" not in graph
|
||||
assert "tonemap=tonemap=hable" in graph
|
||||
assert "flags=lanczos" in graph
|
||||
|
||||
|
||||
def test_original_audio_kept_when_nothing_added():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), keep_original_audio=True
|
||||
)
|
||||
cmd = build_command(plan)
|
||||
assert "[0:a:0]aresample=48000,apad[bed]" in _graph(cmd)
|
||||
assert cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "[aout]"
|
||||
|
||||
|
||||
def test_original_audio_stays_under_the_voice_when_no_music_is_chosen():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=10.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=4.0,
|
||||
keep_original_audio=True,
|
||||
)
|
||||
graph = _graph(build_command(plan))
|
||||
assert "[0:a:0]aresample=48000,apad[bed]" in graph
|
||||
assert "[bed][vkey]sidechaincompress" in graph
|
||||
|
||||
|
||||
def test_chosen_music_replaces_the_original_audio():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=10.0,
|
||||
output=Path("out.mp4"),
|
||||
music=Path("m.mp3"),
|
||||
keep_original_audio=True,
|
||||
)
|
||||
assert "0:a:0" not in _graph(build_command(plan))
|
||||
|
||||
|
||||
def test_prepared_audio_mix_keeps_original_audio_under_voice():
|
||||
from app.render import build_audio_mix_command
|
||||
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=10.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=4.0,
|
||||
keep_original_audio=True,
|
||||
)
|
||||
cmd = build_audio_mix_command(plan, Path("a.wav"))
|
||||
# Entrées : voix (0) puis vidéo d'origine (1)
|
||||
assert cmd[cmd.index("in.mp4") - 1] == "-i"
|
||||
assert "[1:a:0]aresample=48000,apad[bed]" in " ".join(cmd)
|
||||
|
||||
|
||||
def test_encoding_targets_social_networks():
|
||||
cmd = build_command(RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4")))
|
||||
joined = " ".join(cmd)
|
||||
assert "-crf 19" in joined and "-b:v" not in joined
|
||||
assert "-ar 48000" in joined and "-b:a 192k" in joined
|
||||
assert "-colorspace bt709" in joined
|
||||
|
||||
|
||||
def test_big_logo_waits_for_the_end_of_the_voice():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=6.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=4.8,
|
||||
watermark=Path("logo.png"),
|
||||
outro=Path("o.ass"),
|
||||
)
|
||||
assert plan.logo_start == 6.8
|
||||
assert plan.total_duration == 9.3
|
||||
assert "gte(t,6.800)" in _graph(build_command(plan))
|
||||
|
||||
|
||||
def test_corner_logo_can_be_hidden_while_keeping_the_ending_logo():
|
||||
base = dict(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"), watermark=Path("logo.png"))
|
||||
hidden = _graph(build_command(RenderPlan(**base, outro=Path("o.ass"), show_watermark=False)))
|
||||
assert "W-w-30:H-h-30" not in hidden
|
||||
assert "overlay=(W-w)/2" in hidden
|
||||
|
||||
nothing = _graph(build_command(RenderPlan(**base, show_watermark=False)))
|
||||
assert "overlay" not in nothing
|
||||
|
||||
|
||||
def test_prepared_video_has_no_audio_and_final_duration():
|
||||
from app.render import build_prepared_video_command
|
||||
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=6.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=5.0,
|
||||
outro_expected=True,
|
||||
)
|
||||
cmd = build_prepared_video_command(plan, Path("video.mp4"))
|
||||
assert "-an" in cmd
|
||||
assert cmd[cmd.index("-t") + 1] == "9.500" # 2 s + 5 s de voix + 2,5 s d'effet de fin
|
||||
assert plan.logo_start == 7.0
|
||||
|
||||
|
||||
def test_audio_mix_absent_without_any_sound():
|
||||
from app.render import build_audio_mix_command
|
||||
|
||||
plan = RenderPlan(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"))
|
||||
assert build_audio_mix_command(plan, Path("a.wav")) is None
|
||||
plan.keep_original_audio = True
|
||||
assert "loudnorm=I=-14" in " ".join(build_audio_mix_command(plan, Path("a.wav")))
|
||||
|
||||
|
||||
def test_final_fade_never_starts_before_the_voice_ends():
|
||||
# Vidéo courte sans logo : 2 s + 5 s de voix, la vidéo finit 0,8 s après.
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=4.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=5.0,
|
||||
)
|
||||
assert plan.total_duration == 7.8
|
||||
assert plan.fade_start >= plan.speech_end
|
||||
assert plan.fade_duration >= 0.3
|
||||
assert f"afade=t=out:st={plan.fade_start:.3f}" in _graph(build_command(plan))
|
||||
@@ -0,0 +1,81 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.align import Word, spread_words
|
||||
from app.quality import Envelope
|
||||
from app.render import RenderPlan
|
||||
from app.srt_voice import (
|
||||
Cue,
|
||||
as_sentence,
|
||||
atempo_chain,
|
||||
cue_windows,
|
||||
mix_command,
|
||||
segments_from_reading,
|
||||
speed_factor,
|
||||
split_by_cue,
|
||||
)
|
||||
|
||||
|
||||
def test_window_stops_at_next_cue_and_tolerates_a_short_overflow():
|
||||
cues = [Cue(0.0, 2.0, "a"), Cue(2.1, 4.0, "b"), Cue(6.0, 7.0, "c")]
|
||||
assert cue_windows(cues) == [2.1, 2.15, 1.25]
|
||||
|
||||
|
||||
def test_voice_is_sped_up_only_when_too_long():
|
||||
assert speed_factor(1.5, 2.0) == 1.0
|
||||
assert speed_factor(3.0, 2.0) == 1.5
|
||||
|
||||
|
||||
def test_atempo_is_split_into_supported_steps():
|
||||
assert atempo_chain(1.25) == "atempo=1.2500"
|
||||
assert atempo_chain(3.0) == "atempo=2.0000,atempo=1.5000"
|
||||
|
||||
|
||||
def test_each_cue_becomes_a_sentence_for_the_reading():
|
||||
assert as_sentence("Venez vite") == "Venez vite."
|
||||
assert as_sentence("Promo !") == "Promo !"
|
||||
|
||||
|
||||
def test_words_of_the_single_reading_are_split_between_cues():
|
||||
words = [Word(t, i, i + 0.5) for i, t in enumerate(["Bonjour.", "Venez", "vite."])]
|
||||
groups = split_by_cue(words, ["Bonjour.", "Venez vite."])
|
||||
assert [[w.text for w in g] for g in groups] == [["Bonjour."], ["Venez", "vite."]]
|
||||
assert split_by_cue(words, ["Bonjour."]) is None
|
||||
|
||||
|
||||
def test_reading_is_cut_in_the_pause_without_clipping_sentence_endings():
|
||||
# Whisper situe mal la fin de « Bonjour » (0,9 s) : la voix retombe
|
||||
# doucement jusqu'à 1,05 s, puis vraie pause jusqu'à 1,6 s.
|
||||
db = [-90.0] * 15 + [-20.0] * 75 + [-50.0] * 15 + [-90.0] * 55 + [-20.0] * 100 + [-90.0] * 40
|
||||
env = Envelope(np.array(db))
|
||||
groups = [[Word("Bonjour.", 0.2, 0.9)], [Word("Venez", 1.7, 2.1), Word("vite.", 2.1, 2.6)]]
|
||||
first, second = segments_from_reading(groups, env)
|
||||
assert first.source_end >= 1.05 + 0.25 # fin douce gardée, avec sa marge
|
||||
assert first.source_end <= second.source_start
|
||||
assert 1.5 <= second.source_start < 1.6
|
||||
|
||||
|
||||
def test_each_segment_is_placed_at_its_cue_start():
|
||||
cmd = mix_command([(Path("a.wav"), 0.0), (Path("b.wav"), 3.25)], Path("voice.wav"))
|
||||
graph = cmd[cmd.index("-filter_complex") + 1]
|
||||
assert "[1:a]adelay=3250:all=1[s1]" in graph
|
||||
assert "amix=inputs=2" in graph and "normalize=0" in graph
|
||||
|
||||
|
||||
def test_srt_voice_starts_without_delay():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=5.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=8.0,
|
||||
voice_delay=0.0,
|
||||
)
|
||||
assert plan.speech_end == 8.0
|
||||
assert plan.total_duration == 8.8
|
||||
|
||||
|
||||
def test_words_spread_over_their_cue():
|
||||
words = spread_words("Bonjour à tous", 4.0, 5.0)
|
||||
assert words[0].start == 4.0 and round(words[-1].end, 3) == 5.0
|
||||
@@ -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/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@neondatabase/serverless": "^0.10.4",
|
||||
@@ -128,7 +129,8 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "^5.4.20"
|
||||
"vite": "^5.4.20",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
@@ -2303,6 +2305,15 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||
"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": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
||||
@@ -6885,6 +6896,17 @@
|
||||
"@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": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
@@ -6969,6 +6991,13 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"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": {
|
||||
"version": "0.1.11",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
@@ -7612,6 +7729,16 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"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": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||
@@ -7887,6 +8014,16 @@
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||
@@ -7963,6 +8100,33 @@
|
||||
"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": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -8440,6 +8604,16 @@
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
@@ -8987,6 +9161,16 @@
|
||||
"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": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -9040,6 +9224,16 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"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": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
@@ -10540,6 +10734,13 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -11309,6 +11510,23 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
@@ -12614,6 +12832,13 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -12700,6 +12925,13 @@
|
||||
"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": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
@@ -12715,6 +12947,13 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
@@ -12843,6 +13082,26 @@
|
||||
"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": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||
@@ -13192,6 +13451,98 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -14022,6 +14403,119 @@
|
||||
"@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": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
@@ -14182,6 +14676,23 @@
|
||||
"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": {
|
||||
"version": "1.1.5",
|
||||
"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",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"check": "tsc",
|
||||
"db:push": "drizzle-kit push"
|
||||
"db:push": "drizzle-kit push",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1016.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@neondatabase/serverless": "^0.10.4",
|
||||
@@ -130,7 +132,8 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "^5.4.20"
|
||||
"vite": "^5.4.20",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
|
||||
|
Before Width: | Height: | Size: 179 KiB |
@@ -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);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { schedulerService } from "./services/scheduler";
|
||||
import { ensureAdminUserExists } from "./init-admin";
|
||||
import { startTokenCron } from "./cron";
|
||||
import { migrate } from "./migrate";
|
||||
import { startReelProcessing } from "./services/reels";
|
||||
import { jamendoService } from "./services/jamendo";
|
||||
import { ffmpegService } from "./services/ffmpeg";
|
||||
|
||||
@@ -230,5 +231,11 @@ app.use((req, res, next) => {
|
||||
|
||||
// Start Token Refresh Cron
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -213,6 +213,26 @@ export async function migrate() {
|
||||
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
|
||||
@@ -249,6 +269,7 @@ export async function migrate() {
|
||||
["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`],
|
||||
];
|
||||
|
||||
|
||||
@@ -3,19 +3,17 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { User, Media, SocialPage } from '@shared/schema';
|
||||
import type { User } from '@shared/schema';
|
||||
import { storage } from '../storage';
|
||||
import { ffmpegService } from '../services/ffmpeg';
|
||||
import { facebookService } from '../services/facebook';
|
||||
import { tiktokService } from '../services/tiktok';
|
||||
import { createVideoThumbnail } from '../services/thumbnail';
|
||||
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
||||
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||
import { srtText } from '@shared/srt';
|
||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveStoreName } from '../services/reels/assets';
|
||||
import { configuredRenderer } from '../services/reels/videoPipeline';
|
||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||
import { db } from '../db';
|
||||
import { cloudinaryConfig } from '@shared/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { ttsSyncService } from '../services/ttsSync';
|
||||
import { estimateVoiceTiming } from '../services/ttsSync';
|
||||
/** Piste musicale telle qu'attendue par le client. */
|
||||
interface MusicTrack {
|
||||
id: string;
|
||||
@@ -273,140 +271,37 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Prévisualiser un Reel (traitement sans publication)
|
||||
* POST /api/reels/preview
|
||||
*/
|
||||
reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const {
|
||||
videoMediaId,
|
||||
musicTrackId,
|
||||
musicUrl,
|
||||
overlayText,
|
||||
ttsEnabled,
|
||||
ttsVoice,
|
||||
ttsEngine,
|
||||
wordDuration = 0.6,
|
||||
fontSize = 64,
|
||||
musicVolume = 0.25,
|
||||
drawText = true,
|
||||
stabilize = false,
|
||||
enableEndingEffect = true,
|
||||
} = req.body;
|
||||
|
||||
// Récupérer le média vidéo
|
||||
const media = await storage.getMediaById(videoMediaId);
|
||||
if (!media) {
|
||||
return res.status(404).json({ error: 'Vidéo non trouvée' });
|
||||
}
|
||||
|
||||
if (media.type !== 'video') {
|
||||
return res.status(400).json({ error: 'Le média doit être une vidéo' });
|
||||
}
|
||||
|
||||
// Récupérer l'URL de la musique si trackId fourni
|
||||
let finalMusicUrl = musicUrl;
|
||||
if (musicTrackId && !musicUrl) {
|
||||
if (musicTrackId.startsWith('internal_')) {
|
||||
const internalId = musicTrackId.replace('internal_', '');
|
||||
const track = await storage.getAudioTrack(internalId);
|
||||
if (track) {
|
||||
// Utiliser INTERNAL_APP_URL pour que le container ffmpeg (réseau Docker interne)
|
||||
// puisse télécharger le fichier audio. APP_URL (HTTPS public) n'est pas accessible
|
||||
// depuis le réseau internal Docker.
|
||||
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
|
||||
finalMusicUrl = track.url.startsWith('http')
|
||||
? track.url
|
||||
: `${internalBaseUrl}${track.url}`;
|
||||
console.log(`🎵 Audio URL for ffmpeg: ${finalMusicUrl}`);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
let watermarkUrl: string | undefined = undefined;
|
||||
try {
|
||||
const config = await storage.getCloudinaryConfig();
|
||||
if (config && config.logoPublicId) {
|
||||
watermarkUrl = resolveInternalUrl(buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching watermark configuration', e);
|
||||
}
|
||||
|
||||
// Get Gemini API key if using Gemini TTS
|
||||
let geminiApiKey: string | undefined = undefined;
|
||||
if (ttsEngine === "gemini") {
|
||||
const appCfg = await storage.getAppConfig();
|
||||
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
|
||||
}
|
||||
|
||||
const finalWordDuration = wordDuration;
|
||||
|
||||
// Traiter la vidéo via FFmpeg
|
||||
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
|
||||
text: overlayText,
|
||||
musicUrl: finalMusicUrl,
|
||||
ttsEnabled,
|
||||
ttsVoice,
|
||||
ttsEngine,
|
||||
geminiApiKey,
|
||||
wordDuration: finalWordDuration,
|
||||
fontSize,
|
||||
musicVolume,
|
||||
drawText,
|
||||
stabilize,
|
||||
watermarkUrl,
|
||||
enableEndingEffect,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error || 'Erreur de traitement vidéo' });
|
||||
}
|
||||
|
||||
// Retourner la vidéo en base64 pour prévisualisation
|
||||
res.json({
|
||||
success: true,
|
||||
videoBase64: result.videoBase64,
|
||||
duration: result.duration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error previewing Reel:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la prévisualisation du Reel' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Prévisualiser la voix TTS
|
||||
* POST /api/reels/tts-preview
|
||||
*/
|
||||
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||
const parsed = ttsPreviewSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
|
||||
}
|
||||
const { text, ttsVoice, ttsEngine, ttsStyle } = parsed.data;
|
||||
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { text, ttsVoice, ttsEngine } = req.body;
|
||||
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: 'Texte requis' });
|
||||
}
|
||||
|
||||
let geminiApiKey: string | undefined = undefined;
|
||||
if (ttsEngine === "gemini") {
|
||||
const appCfg = await storage.getAppConfig();
|
||||
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
|
||||
}
|
||||
|
||||
const result = await ffmpegService.previewTTS(text, ttsVoice, ttsEngine, geminiApiKey);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
|
||||
}
|
||||
|
||||
res.json({ success: true, audioBase64: result.audioBase64 });
|
||||
const preview = await ffmpegService.previewVoice(text, {
|
||||
voice: ttsVoice,
|
||||
engine: ttsEngine,
|
||||
style: ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
audioBase64: preview.audio.toString('base64'),
|
||||
duration: preview.duration,
|
||||
words: preview.words,
|
||||
engine: preview.engine,
|
||||
voice: preview.voice,
|
||||
warnings: preview.warnings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating TTS preview:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la génération de la voix' });
|
||||
const message = error instanceof Error ? error.message : 'Erreur lors de la génération de la voix';
|
||||
res.status(error instanceof FFmpegServiceError && error.status === 400 ? 400 : 502).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -416,12 +311,12 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||
*/
|
||||
reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { text, ttsVoice, ttsEngine } = req.body;
|
||||
if (!text || !ttsVoice) {
|
||||
return res.status(400).json({ error: 'Texte et voix requis' });
|
||||
const { text } = req.body;
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: 'Texte requis' });
|
||||
}
|
||||
const sync = await ttsSyncService.calculateSyncTiming(text, ttsVoice, ttsEngine);
|
||||
res.json(sync);
|
||||
// Estimation locale : ne déclenche aucune synthèse (payante avec Gemini)
|
||||
res.json(estimateVoiceTiming(text));
|
||||
} catch (error) {
|
||||
console.error('❌ Error calculating sync:', error);
|
||||
res.status(500).json({ error: 'Erreur de calcul de synchronisation' });
|
||||
@@ -429,444 +324,55 @@ reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Traitement d'arrière-plan pour les Reels
|
||||
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vérifie la file d'attente et lance le prochain job si disponible
|
||||
*/
|
||||
async function checkQueueAndProcessNext() {
|
||||
try {
|
||||
const nextPost = await storage.getNextPendingReel();
|
||||
if (nextPost) {
|
||||
console.log(`📥 [Queue] Found pending job: ${nextPost.id}. Starting processing...`);
|
||||
|
||||
// Marquer comme processing immédiatement
|
||||
await storage.updatePostGenerationStatus(nextPost.id, 'processing', 0);
|
||||
|
||||
// Reconstruire le payload depuis les données stockées (si stockées) ou les defaults
|
||||
// Note: En mode "pending", on a perdu le body de la requête initiale car on ne stocke pas tout dans Post.
|
||||
// Pour une solution robuste, il faudrait stocker les paramètres de génération dans une table 'reel_jobs'.
|
||||
// ICI: HACK PROVISOIRE -> On suppose que les données sont stockées dans 'content' ou 'productInfo' mais ce n'est pas le cas.
|
||||
// SOLUTION: On ne peut pas relancer processReelBackground sans les arguments (ttsVoice, music, etc).
|
||||
//
|
||||
// FIX: Pour le MVP, comme on n'a pas de table 'jobs', on va devoir stocker les paramètres requis dans 'productInfo' (jsonb) du Post
|
||||
// lors de la création en mode 'pending'.
|
||||
|
||||
// Récupérer les paramètres stockés
|
||||
const jobData = nextPost.productInfo as any;
|
||||
|
||||
if (!jobData || !jobData.videoMediaId) {
|
||||
console.error(`❌ [Queue] Job ${nextPost.id} has no stored job data in productInfo.`);
|
||||
await storage.updatePostGenerationStatus(nextPost.id, 'failed', 0, "Données de job manquantes");
|
||||
return;
|
||||
}
|
||||
|
||||
// Lancer le traitement
|
||||
processReelBackground(nextPost.userId, nextPost.id, jobData)
|
||||
.catch(err => console.error('🔥 [Queue] Unhandled error starting queued job:', err));
|
||||
} else {
|
||||
console.log('🏁 [Queue] No more pending jobs.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [Queue] Error checking queue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traitement d'arrière-plan pour les Reels
|
||||
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
|
||||
*/
|
||||
async function processReelBackground(
|
||||
userId: string,
|
||||
postId: string,
|
||||
data: {
|
||||
videoMediaId: string;
|
||||
musicTrackId?: string;
|
||||
musicUrl?: string;
|
||||
overlayText?: string;
|
||||
description?: string;
|
||||
ttsEnabled?: boolean;
|
||||
ttsVoice?: string;
|
||||
ttsEngine?: string;
|
||||
pageIds: string[];
|
||||
scheduledFor?: string;
|
||||
wordDuration?: number;
|
||||
fontSize?: number;
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
stabilize?: boolean;
|
||||
enableEndingEffect?: boolean;
|
||||
},
|
||||
storeName?: string
|
||||
) {
|
||||
const {
|
||||
videoMediaId,
|
||||
musicTrackId,
|
||||
musicUrl,
|
||||
overlayText,
|
||||
description,
|
||||
ttsEnabled,
|
||||
ttsVoice,
|
||||
ttsEngine,
|
||||
pageIds,
|
||||
scheduledFor,
|
||||
wordDuration,
|
||||
fontSize,
|
||||
musicVolume,
|
||||
drawText,
|
||||
stabilize,
|
||||
enableEndingEffect,
|
||||
} = data;
|
||||
|
||||
console.log(`🔄 [Background] Starting processing for Post ${postId}`);
|
||||
|
||||
// Helper to update generation progress
|
||||
const updateProgress = async (progress: number, status: string = 'processing') => {
|
||||
try {
|
||||
await storage.updatePostGenerationStatus(postId, status, progress);
|
||||
} catch (e) {
|
||||
console.error(`⚠️ [Background] Failed to update progress for ${postId}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await updateProgress(5);
|
||||
// 1. Récupérer le média vidéo source
|
||||
const media = await storage.getMediaById(videoMediaId);
|
||||
if (!media || media.type !== 'video') {
|
||||
throw new Error('Vidéo source introuvable ou invalide');
|
||||
}
|
||||
|
||||
// 2. Récupérer l'URL de la musique
|
||||
let finalMusicUrl = musicUrl;
|
||||
if (musicTrackId && !musicUrl) {
|
||||
if (musicTrackId.startsWith('internal_')) {
|
||||
try {
|
||||
const internalId = musicTrackId.replace('internal_', '');
|
||||
const track = await storage.getAudioTrack(internalId);
|
||||
if (track) {
|
||||
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
|
||||
finalMusicUrl = track.url.startsWith('http')
|
||||
? track.url
|
||||
: `${internalBaseUrl}${track.url}`;
|
||||
console.log(`🎵 [Background] Audio URL for ffmpeg: ${finalMusicUrl}`);
|
||||
}
|
||||
} catch (e) { console.error('Error fetching internal track', e); }
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎬 [Background] FFmpeg Processing:', {
|
||||
postId,
|
||||
videoUrl: media.originalUrl,
|
||||
hasMusic: !!finalMusicUrl, // Log boolean to avoid long URL
|
||||
hasText: !!overlayText
|
||||
});
|
||||
|
||||
// 3. Traiter la vidéo via FFmpeg
|
||||
await updateProgress(15);
|
||||
const startTime = Date.now();
|
||||
|
||||
let watermarkUrl: string | undefined = undefined;
|
||||
try {
|
||||
const config = await storage.getCloudinaryConfig();
|
||||
if (config && config.logoPublicId) {
|
||||
watermarkUrl = resolveInternalUrl(buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching watermark configuration', e);
|
||||
}
|
||||
|
||||
// Get Gemini API key if using Gemini TTS
|
||||
let geminiApiKey: string | undefined = undefined;
|
||||
if (ttsEngine === "gemini") {
|
||||
const appCfg = await storage.getAppConfig();
|
||||
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
|
||||
}
|
||||
|
||||
const finalWordDuration = wordDuration ?? 0.6;
|
||||
|
||||
console.log('🔊 [Background] TTS config:', { ttsEnabled, ttsVoice, ttsEngine });
|
||||
|
||||
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
|
||||
text: overlayText,
|
||||
musicUrl: finalMusicUrl,
|
||||
ttsEnabled,
|
||||
ttsVoice,
|
||||
ttsEngine,
|
||||
geminiApiKey,
|
||||
wordDuration: finalWordDuration,
|
||||
fontSize,
|
||||
musicVolume,
|
||||
drawText,
|
||||
stabilize,
|
||||
watermarkUrl,
|
||||
storeName,
|
||||
enableEndingEffect,
|
||||
});
|
||||
|
||||
console.log(`⏱️ [Background] FFmpeg took ${(Date.now() - startTime) / 1000}s`);
|
||||
|
||||
if (ffmpegResult.ttsError) {
|
||||
console.error(`❌ [Background] TTS FAILED: ${ffmpegResult.ttsError}`);
|
||||
await storage.updatePost(postId, { generationError: `TTS échoué: ${ffmpegResult.ttsError}` });
|
||||
}
|
||||
|
||||
if (!ffmpegResult.success || !ffmpegResult.videoBase64) {
|
||||
throw new Error(ffmpegResult.error || 'Erreur de traitement vidéo FFmpeg');
|
||||
}
|
||||
|
||||
// 4. Upload sur Cloudinary
|
||||
await updateProgress(65);
|
||||
console.log('☁️ [Background] Uploading to Cloudinary...');
|
||||
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
|
||||
const cloudinaryResult = await cloudinaryService.uploadMedia(
|
||||
videoBuffer,
|
||||
`reel-${Date.now()}.mp4`,
|
||||
userId,
|
||||
'video/mp4'
|
||||
);
|
||||
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
|
||||
|
||||
// 5. Créer l'enregistrement Media pour la vidéo traitée
|
||||
// La vignette est extraite maintenant : la vidéo sera supprimée du disque
|
||||
// après publication, mais l'historique doit rester illustré.
|
||||
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
|
||||
|
||||
const processedMedia = await storage.createMedia({
|
||||
userId: userId,
|
||||
type: 'video',
|
||||
cloudinaryPublicId: cloudinaryResult.publicId,
|
||||
originalUrl: cloudinaryResult.originalUrl,
|
||||
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
|
||||
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
|
||||
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
|
||||
thumbnailUrl,
|
||||
fileName: `reel-processed-${Date.now()}.mp4`,
|
||||
fileSize: videoBuffer.length,
|
||||
});
|
||||
|
||||
// 6. Lier le média traité au Post existant
|
||||
await storage.updatePostMedia(postId, [processedMedia.id]);
|
||||
await updateProgress(85);
|
||||
|
||||
// 7. Publier sur les pages
|
||||
await updateProgress(90);
|
||||
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
|
||||
|
||||
for (const pageId of pageIds) {
|
||||
try {
|
||||
const page = await storage.getSocialPage(pageId);
|
||||
if (!page) {
|
||||
results.push({ pageId, success: false, error: 'Page non trouvée' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
|
||||
results.push({
|
||||
pageId,
|
||||
success: false,
|
||||
error: `Plateforme non supportée pour les reels : ${page.platform}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Créer l'entrée scheduled_post (log de publication)
|
||||
const scheduledPost = await storage.createScheduledPost({
|
||||
postId: postId,
|
||||
pageId: page.id,
|
||||
postType: 'reel',
|
||||
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
|
||||
});
|
||||
|
||||
if (!scheduledFor) {
|
||||
// Publication immédiate — la même vidéo part sur chaque destination
|
||||
console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
|
||||
const finalDescription = description || overlayText || '';
|
||||
|
||||
if (page.platform === 'facebook') {
|
||||
const reelId = await facebookService.publishReel(
|
||||
page,
|
||||
cloudinaryResult.originalUrl,
|
||||
finalDescription
|
||||
);
|
||||
|
||||
// Mise à jour succès
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
externalPostId: reelId,
|
||||
});
|
||||
|
||||
results.push({ pageId, success: true, reelId });
|
||||
} else {
|
||||
const publishId = await tiktokService.publishVideoFromBuffer(
|
||||
page,
|
||||
videoBuffer,
|
||||
finalDescription
|
||||
);
|
||||
|
||||
// TikTok finalise la publication de son côté : on marque l'envoi
|
||||
// comme effectué pour ne pas republier, et le poller de statut
|
||||
// renseignera l'identifiant définitif du post.
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
publishId,
|
||||
publishStatus: 'PROCESSING_UPLOAD',
|
||||
});
|
||||
|
||||
results.push({ pageId, success: true, reelId: publishId });
|
||||
}
|
||||
} else {
|
||||
// Planifié
|
||||
results.push({ pageId, success: true, reelId: 'scheduled' });
|
||||
}
|
||||
|
||||
} catch (pageError: any) {
|
||||
console.error(`❌ [Background] Error publishing to page ${pageId}:`, pageError);
|
||||
results.push({
|
||||
pageId,
|
||||
success: false,
|
||||
error: pageError.message || 'Erreur inconnue',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Mettre à jour le statut global du Post
|
||||
const allSuccess = results.every(r => r.success);
|
||||
const anySuccess = results.some(r => r.success);
|
||||
|
||||
// Si au moins une réussite, on considère "published" (ou partial), sinon "failed"
|
||||
// Si planifié, reste "scheduled".
|
||||
let finalStatus: 'failed' | 'scheduled' | 'published' = 'failed';
|
||||
if (scheduledFor) {
|
||||
finalStatus = 'scheduled';
|
||||
} else if (allSuccess) {
|
||||
finalStatus = 'published';
|
||||
} else if (anySuccess) {
|
||||
finalStatus = 'published'; // Partiellement publié
|
||||
}
|
||||
|
||||
await storage.updatePost(postId, {
|
||||
status: finalStatus,
|
||||
});
|
||||
await updateProgress(100, 'completed');
|
||||
|
||||
console.log(`✅ [Background] Processing complete for Post ${postId}. Status: ${finalStatus}`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [Background] Critical error for Post ${postId}:`, error);
|
||||
await storage.updatePost(postId, {
|
||||
status: 'failed',
|
||||
});
|
||||
try {
|
||||
await storage.updatePostGenerationStatus(
|
||||
postId, 'failed', 0,
|
||||
error?.message || 'Erreur inconnue lors du traitement'
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`⚠️ [Background] Failed to update error status for ${postId}:`, e);
|
||||
}
|
||||
} finally {
|
||||
// IMPORTANT: Toujours vérifier la file d'attente à la fin (succès ou échec)
|
||||
await checkQueueAndProcessNext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer et publier un Reel (Asynchrone avec File d'Attente)
|
||||
* Créer et publier un Reel : le rendu part dans la file persistante.
|
||||
* POST /api/reels
|
||||
*/
|
||||
reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const {
|
||||
videoMediaId,
|
||||
musicTrackId,
|
||||
musicUrl,
|
||||
overlayText,
|
||||
description,
|
||||
ttsEnabled,
|
||||
ttsVoice,
|
||||
ttsEngine,
|
||||
pageIds,
|
||||
scheduledFor,
|
||||
wordDuration = 0.6,
|
||||
fontSize = 64,
|
||||
musicVolume = 0.25,
|
||||
drawText = true,
|
||||
stabilize = false,
|
||||
enableEndingEffect = true,
|
||||
} = req.body;
|
||||
|
||||
// Validation immédiate
|
||||
if (!videoMediaId) {
|
||||
return res.status(400).json({ error: 'Vidéo requise' });
|
||||
const parsed = videoReelParamsSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
|
||||
}
|
||||
|
||||
if (!pageIds || pageIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Au moins une page requise' });
|
||||
}
|
||||
const { srtCues } = parsed.data;
|
||||
const params: VideoReelParams = {
|
||||
...parsed.data,
|
||||
// Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres
|
||||
overlayText: srtCues ? undefined : parsed.data.overlayText,
|
||||
description: parsed.data.description || (srtCues ? srtText(srtCues) : undefined),
|
||||
storeName: await resolveStoreName(user.id, parsed.data.pageIds[0]),
|
||||
};
|
||||
|
||||
// Vérifier le nombre de jobs en cours
|
||||
const processingCount = await storage.countProcessingReels();
|
||||
// MAX_CONCURRENT = 1
|
||||
const isQueueBusy = processingCount >= 1;
|
||||
const waiting = await countActiveReelJobs();
|
||||
|
||||
const initialStatus = isQueueBusy ? 'pending' : 'processing';
|
||||
const initialMessage = isQueueBusy
|
||||
? "File d'attente pleine. Votre vidéo sera traitée dès que possible."
|
||||
: "Traitement démarré en arrière-plan.";
|
||||
|
||||
// Récupérer le nom de la première page pour l'utiliser comme storeName
|
||||
let storeName: string | undefined = undefined;
|
||||
try {
|
||||
const page = await storage.getSocialPage(pageIds[0]);
|
||||
if (page) {
|
||||
storeName = page.pageName;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erreur récupération nom de page', e);
|
||||
}
|
||||
|
||||
// Créer immédiatement le Post en base
|
||||
// ON STOCKE LES PARAMS DU JOB DANS productInfo POUR POUVOIR LE REPRENDRE PLUS TARD
|
||||
// C'est un hack car on n'a pas de table params_job, mais ça marche car productInfo est jsonb
|
||||
const post = await storage.createPost({
|
||||
userId: user.id,
|
||||
content: description || overlayText || '',
|
||||
content: params.description || params.overlayText || '',
|
||||
aiGenerated: 'false',
|
||||
status: scheduledFor ? 'scheduled' : 'draft',
|
||||
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
||||
generationStatus: initialStatus,
|
||||
status: params.scheduledFor ? 'scheduled' : 'draft',
|
||||
scheduledFor: params.scheduledFor ? new Date(params.scheduledFor) : undefined,
|
||||
generationStatus: 'pending',
|
||||
generationProgress: 0,
|
||||
productInfo: req.body, // Stockage complet des paramètres
|
||||
});
|
||||
|
||||
console.log(`✨ Reel Request accepted. Post ID: ${post.id}. Status: ${initialStatus}`);
|
||||
await enqueueReelJob({ kind: 'video', userId: user.id, postId: post.id, params });
|
||||
console.log(`✨ Reel en file. Post ${post.id}, ${waiting} job(s) avant lui.`);
|
||||
|
||||
if (!isQueueBusy) {
|
||||
// Démarrer le traitement en arrière-plan (Fire & Forget)
|
||||
processReelBackground(user.id, post.id, req.body, storeName).catch(err => {
|
||||
console.error('🔥 Unhandled background error:', err);
|
||||
});
|
||||
} else {
|
||||
console.log(`⏳ [Queue] Worker busy (count=${processingCount}). Job ${post.id} is queued.`);
|
||||
}
|
||||
|
||||
// Réponse immédiate au client
|
||||
res.json({
|
||||
success: true,
|
||||
postId: post.id,
|
||||
message: initialMessage,
|
||||
queued: isQueueBusy,
|
||||
queued: waiting > 0,
|
||||
message: waiting > 0
|
||||
? "File d'attente occupée. Votre vidéo sera traitée dès que possible."
|
||||
: 'Traitement démarré en arrière-plan.',
|
||||
results: [],
|
||||
videoUrl: ""
|
||||
videoUrl: '',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error initiating Reel:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur lors de l\'initialisation du Reel',
|
||||
});
|
||||
}
|
||||
@@ -878,12 +384,18 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
||||
*/
|
||||
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
|
||||
const [ffmpegConfigured, logoUrl] = await Promise.all([
|
||||
ffmpegService.healthCheck().catch(() => false),
|
||||
resolveLogoPath(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
jamendo: { configured: false },
|
||||
freesound: { configured: false },
|
||||
ffmpeg: { configured: ffmpegConfigured },
|
||||
// Aperçu : logo affiché par le lecteur, moteur de rendu utilisé
|
||||
logoUrl: logoUrl ?? null,
|
||||
renderer: configuredRenderer(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching Reels config:', error);
|
||||
|
||||
@@ -2,358 +2,108 @@ import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { bundle } from "@remotion/bundler";
|
||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||
import { ffmpegService } from "../services/ffmpeg";
|
||||
import type { User } from "@shared/schema";
|
||||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||
import { storage as dbStorage } from "../storage";
|
||||
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
|
||||
import { facebookService } from "../services/facebook";
|
||||
import { tiktokService } from "../services/tiktok";
|
||||
import { generateVideoThumbnail, createVideoThumbnail } from "../services/thumbnail";
|
||||
import * as musicMetadata from "music-metadata";
|
||||
|
||||
import { enqueueReelJob, getReelJob } from "../services/reels/queue";
|
||||
import { REMOTION_TEMP_DIR } from "../services/reels/imagesPipeline";
|
||||
import { resolveStoreName } from "../services/reels/assets";
|
||||
import { publishReelToPages, storeRenderedVideo } from "../services/reels/publish";
|
||||
|
||||
export const remotionRouter = Router();
|
||||
|
||||
interface RenderJob {
|
||||
status: 'processing' | 'done' | 'error';
|
||||
url?: string;
|
||||
thumbnailUrl?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
const renderJobs = new Map<string, RenderJob>();
|
||||
|
||||
remotionRouter.get("/render/status/:jobId", (req, res) => {
|
||||
const job = renderJobs.get(req.params.jobId);
|
||||
if (!job) return res.status(404).json({ error: "Job introuvable" });
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "uploads", "temp");
|
||||
const uploadDir = REMOTION_TEMP_DIR;
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
||||
cb(null, `remotion-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
},
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
||||
cb(null, `remotion-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const upload = multer({ storage });
|
||||
|
||||
/** Cached Remotion bundle URL — only bundle once per process lifecycle */
|
||||
let remotionBundleCache: string | null = null;
|
||||
|
||||
async function getBundle(): Promise<string> {
|
||||
if (remotionBundleCache) return remotionBundleCache;
|
||||
// Use process.cwd() (= /app in Docker, project root in dev) — works in both CJS and ESM
|
||||
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
|
||||
console.log("📦 Bundling Remotion from:", entryPoint);
|
||||
remotionBundleCache = await bundle({ entryPoint });
|
||||
console.log("📦 Bundle cached at:", remotionBundleCache);
|
||||
return remotionBundleCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips hashtags and emojis from text for TTS synthesis.
|
||||
* Uses Unicode-aware regex to handle French accented chars in hashtags
|
||||
* (e.g. #AménagementExtérieur must be fully removed, not just #Am).
|
||||
* GET /api/remotion/render/status/:jobId
|
||||
* État d'un rendu, lu dans la file persistante (survit à un redémarrage).
|
||||
*/
|
||||
function stripForTTS(text: string): string {
|
||||
return text
|
||||
// Hashtags with Unicode letters (covers all French accented chars)
|
||||
.replace(/#[\w\u00C0-\u024F\u1E00-\u1EFF]*/g, ' ')
|
||||
// Emoji: surrogate pairs (covers virtually all emoji in UTF-16 strings)
|
||||
.replace(/[\uD800-\uDFFF][\uDC00-\uDFFF]/g, ' ') // surrogate pairs (most emojis)
|
||||
.replace(/[\u2600-\u27BF]/g, ' ') // misc symbols & arrows
|
||||
.replace(/[\u2B00-\u2BFF]/g, ' ') // misc symbols extended
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Estimates syllable count for a French word (vowel-group method). */
|
||||
function countSyllablesFr(word: string): number {
|
||||
const clean = word.replace(/[^a-zàâéèêëîïôùûüç]/gi, '').toLowerCase();
|
||||
if (!clean) return 1;
|
||||
const groups = clean.match(/[aeiouyàâéèêëîïôùûü]+/gi);
|
||||
return Math.max(1, groups?.length ?? 1);
|
||||
}
|
||||
|
||||
/** Purely-punctuation token (should not appear in overlay). */
|
||||
const PUNCT_ONLY = /^[.,!?;:…\-—«»"''()\[\]]+$/;
|
||||
|
||||
/**
|
||||
* Calculates word timings (in frames) for SPOKEN words only.
|
||||
* Duration per word is weighted by syllable count → much tighter sync with TTS voice.
|
||||
*/
|
||||
function computeWordTimings(
|
||||
displayText: string,
|
||||
audioDurationSeconds: number,
|
||||
fps: number,
|
||||
startFrame: number
|
||||
): Array<{ word: string; startFrame: number; endFrame: number }> {
|
||||
const ttsText = stripForTTS(displayText);
|
||||
// Filter out standalone punctuation tokens
|
||||
const spokenWords = ttsText.split(/\s+/).filter(w => w && !PUNCT_ONLY.test(w));
|
||||
if (spokenWords.length === 0) return [];
|
||||
|
||||
// Strip leading/trailing punctuation from each word for clean display
|
||||
const cleanWords = spokenWords.map(w => w.replace(/^[.,!?;:…«»"''()\[\]]+|[.,!?;:…«»"''()\[\]]+$/g, '') || w);
|
||||
|
||||
// Syllable-weighted distribution: longer words get proportionally more screen time
|
||||
const syllables = cleanWords.map(countSyllablesFr);
|
||||
const totalSyllables = syllables.reduce((a, b) => a + b, 0);
|
||||
const totalFrames = audioDurationSeconds * fps;
|
||||
|
||||
let currentFrame = startFrame;
|
||||
return cleanWords.map((word, i) => {
|
||||
const wordStart = currentFrame;
|
||||
const wordFrames = Math.round((syllables[i] / totalSyllables) * totalFrames);
|
||||
currentFrame += wordFrames;
|
||||
return { word, startFrame: wordStart, endFrame: currentFrame };
|
||||
});
|
||||
}
|
||||
|
||||
// Duration constants
|
||||
const FPS = 30;
|
||||
const ENDING_SECONDS = 3; // ending slide (logo + store name)
|
||||
const MIN_CONTENT_SECONDS = 22; // so total >= 25s
|
||||
const MAX_CONTENT_SECONDS = 27; // so total <= 30s
|
||||
|
||||
/**
|
||||
* Converts a local /uploads/... relative path to a base64 data URL.
|
||||
* Chromium inside Docker cannot reliably fetch http://localhost via HTTP,
|
||||
* so we embed images directly — zero network dependency.
|
||||
*/
|
||||
async function toDataUrl(relativeUrl: string): Promise<string> {
|
||||
if (!relativeUrl || !relativeUrl.startsWith('/uploads/')) return relativeUrl;
|
||||
const filePath = path.join(process.cwd(), relativeUrl);
|
||||
try {
|
||||
const buffer = await fs.promises.readFile(filePath);
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase();
|
||||
const mime = ext === 'png' ? 'image/png'
|
||||
: ext === 'gif' ? 'image/gif'
|
||||
: ext === 'webp' ? 'image/webp'
|
||||
: 'image/jpeg';
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
console.warn(`⚠️ toDataUrl: file not found: ${filePath}`);
|
||||
return relativeUrl;
|
||||
remotionRouter.get("/render/status/:jobId", async (req, res) => {
|
||||
const user = req.user as User;
|
||||
const job = await getReelJob(req.params.jobId);
|
||||
if (!job || job.kind !== "images" || (job.userId !== user.id && user.role !== "admin")) {
|
||||
return res.status(404).json({ error: "Job introuvable" });
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === "completed") {
|
||||
const result = job.result as ImagesReelResult;
|
||||
return res.json({ status: "done", url: result.url, thumbnailUrl: result.thumbnailUrl });
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
return res.json({ status: "error", error: "Erreur lors du rendu: " + (job.error ?? "inconnue") });
|
||||
}
|
||||
res.json({ status: "processing", progress: job.progress, step: job.step, queued: job.status === "pending" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/remotion/render
|
||||
* Met en file le rendu d'un Reel à partir d'images (1 à 4).
|
||||
*/
|
||||
remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => {
|
||||
const fields = (req.files ?? {}) as Record<string, Express.Multer.File[]>;
|
||||
const uploaded = [...(fields["images"] ?? []), ...(fields["music"] ?? [])];
|
||||
|
||||
try {
|
||||
const fields = req.files as Record<string, Express.Multer.File[]>;
|
||||
const files = fields["images"] ?? [];
|
||||
let imageUrls: string[] = [];
|
||||
|
||||
// For audio/music we still need HTTP URLs (data URLs for audio are too large)
|
||||
const port = process.env.PORT || "5555";
|
||||
const host = `http://localhost:${port}`;
|
||||
|
||||
// Library images: resolve relative paths → data URLs (avoids Chromium HTTP issues)
|
||||
const user = req.user as User;
|
||||
const existing = req.body.existingImageUrls;
|
||||
if (existing) {
|
||||
const rawUrls = Array.isArray(existing) ? existing : [existing];
|
||||
const resolved = await Promise.all(rawUrls.map(toDataUrl));
|
||||
imageUrls.push(...resolved);
|
||||
}
|
||||
// Newly uploaded temp files: read from disk → data URL
|
||||
if (files && files.length > 0) {
|
||||
const resolved = await Promise.all(files.map(async f => {
|
||||
const buffer = await fs.promises.readFile(f.path);
|
||||
const ext = path.extname(f.originalname).slice(1).toLowerCase();
|
||||
const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`;
|
||||
}));
|
||||
imageUrls.push(...resolved);
|
||||
}
|
||||
const libraryUrls: string[] = existing ? (Array.isArray(existing) ? existing : [existing]) : [];
|
||||
const uploadedImageUrls = (fields["images"] ?? []).map((f) => `/uploads/temp/${path.basename(f.path)}`);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
return res.status(400).json({ error: "Aucune image fournie" });
|
||||
}
|
||||
|
||||
const overlayText: string | undefined = typeof req.body.overlayText === 'string' && req.body.overlayText.trim()
|
||||
? req.body.overlayText.trim()
|
||||
: undefined;
|
||||
|
||||
const musicVolume: number = parseFloat(req.body.musicVolume ?? "0.3");
|
||||
|
||||
// Music: HTTP URLs are fine for audio (Remotion uses Web Audio API, not <Img>)
|
||||
const musicFile = fields["music"]?.[0];
|
||||
const rawMusicTrackUrl = req.body.musicTrackUrl as string | undefined;
|
||||
const musicUrl: string | undefined = musicFile
|
||||
? `${host}/uploads/temp/${path.basename(musicFile.path)}`
|
||||
: rawMusicTrackUrl?.startsWith('/') ? `${host}${rawMusicTrackUrl}` : rawMusicTrackUrl || undefined;
|
||||
const musicVolume = parseFloat(req.body.musicVolume ?? "0.3");
|
||||
|
||||
// --- Fetch logo and store name from config ---
|
||||
let logoUrl: string | undefined;
|
||||
let storeName: string | undefined;
|
||||
try {
|
||||
const cloudinaryConfig = await dbStorage.getCloudinaryConfig();
|
||||
if (cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId) {
|
||||
const relLogo = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId, cloudinaryConfig.publicUrl);
|
||||
logoUrl = await toDataUrl(relLogo);
|
||||
console.log("🏢 Logo embedded as data URL:", logoUrl.slice(0, 40) + "...");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("⚠️ Could not fetch logo config:", e);
|
||||
}
|
||||
try {
|
||||
const user = req.user as any;
|
||||
if (user?.id) {
|
||||
const pages = await dbStorage.getSocialPages(user.id);
|
||||
if (pages.length > 0) {
|
||||
// Use the page selected by the user, or fall back to first page
|
||||
const selectedPageId = req.body.selectedPageId as string | undefined;
|
||||
const page = selectedPageId ? pages.find(p => p.id === selectedPageId) : pages[0];
|
||||
storeName = (page ?? pages[0]).pageName;
|
||||
console.log("🏪 Store name:", storeName);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("⚠️ Could not fetch store name:", e);
|
||||
}
|
||||
|
||||
// --- TTS Audio Generation ---
|
||||
let audioUrl: string | undefined;
|
||||
let wordTimings: Array<{ word: string; startFrame: number; endFrame: number }> | undefined;
|
||||
let estimatedAudioDuration = 0;
|
||||
|
||||
if (overlayText) {
|
||||
const ttsText = stripForTTS(overlayText);
|
||||
if (ttsText) {
|
||||
console.log("🎙️ Generating TTS for:", ttsText);
|
||||
try {
|
||||
const ttsResult = await ffmpegService.previewTTS(ttsText);
|
||||
if (ttsResult.success && ttsResult.audioBase64) {
|
||||
const audioFilename = `tts-${Date.now()}.mp3`;
|
||||
const audioPath = path.join(uploadDir, audioFilename);
|
||||
const audioBuffer = Buffer.from(ttsResult.audioBase64, "base64");
|
||||
fs.writeFileSync(audioPath, audioBuffer);
|
||||
audioUrl = `${host}/uploads/temp/${audioFilename}`;
|
||||
|
||||
// Get exact audio duration from MP3 metadata
|
||||
try {
|
||||
const meta = await musicMetadata.parseFile(audioPath);
|
||||
estimatedAudioDuration = meta.format.duration ?? 0;
|
||||
} catch {
|
||||
// Fallback: rough estimate from byte size (128kbps = 16KB/s)
|
||||
estimatedAudioDuration = audioBuffer.length / 16000;
|
||||
}
|
||||
estimatedAudioDuration = Math.max(estimatedAudioDuration, ttsText.split(/\s+/).length * 0.35);
|
||||
|
||||
wordTimings = computeWordTimings(overlayText, estimatedAudioDuration, FPS, 0);
|
||||
console.log(`✅ TTS generated, ~${estimatedAudioDuration.toFixed(1)}s, ${wordTimings.length} words`);
|
||||
} else {
|
||||
console.warn("⚠️ TTS failed:", ttsResult.error);
|
||||
}
|
||||
} catch (ttsErr) {
|
||||
console.warn("⚠️ TTS error (rendering without audio):", ttsErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Duration: 25-30s total ---
|
||||
// content = max(tts duration, images*3s), clamped to 22-27s; ending = 3s
|
||||
const naturalContent = Math.max(estimatedAudioDuration, imageUrls.length * 3);
|
||||
const contentSeconds = Math.min(Math.max(naturalContent, MIN_CONTENT_SECONDS), MAX_CONTENT_SECONDS);
|
||||
const endingFrames = (logoUrl || storeName) ? ENDING_SECONDS * FPS : 0;
|
||||
const totalFrames = Math.round(contentSeconds * FPS) + endingFrames;
|
||||
|
||||
console.log(`🎬 Duration: ${contentSeconds.toFixed(1)}s content + ${ENDING_SECONDS}s ending = ${(totalFrames / FPS).toFixed(1)}s total`);
|
||||
|
||||
console.log("🎬 Getting Remotion bundle (cached)...");
|
||||
const bundleLocation = await getBundle();
|
||||
|
||||
const inputProps = {
|
||||
images: imageUrls,
|
||||
overlayText,
|
||||
audioUrl,
|
||||
wordTimings,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
logoUrl,
|
||||
storeName,
|
||||
endingFrames,
|
||||
};
|
||||
|
||||
console.log("🎬 Selecting composition...");
|
||||
const composition = await selectComposition({
|
||||
serveUrl: bundleLocation,
|
||||
id: "ImageVideo",
|
||||
inputProps,
|
||||
const parsed = imagesReelParamsSchema.safeParse({
|
||||
imageUrls: [...libraryUrls, ...uploadedImageUrls],
|
||||
overlayText: req.body.overlayText,
|
||||
musicUrl: musicFile ? `/uploads/temp/${path.basename(musicFile.path)}` : req.body.musicTrackUrl,
|
||||
musicVolume: Number.isFinite(musicVolume) ? musicVolume : undefined,
|
||||
ttsEnabled: req.body.ttsEnabled !== "false",
|
||||
ttsEngine: req.body.ttsEngine || undefined,
|
||||
ttsVoice: req.body.ttsVoice,
|
||||
ttsStyle: req.body.ttsStyle || undefined,
|
||||
captionStyle: req.body.captionStyle || undefined,
|
||||
showLogo: req.body.showLogo !== "false",
|
||||
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
|
||||
tempFiles: uploaded.map((f) => f.path),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
await removeFiles(uploaded);
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Paramètres invalides" });
|
||||
}
|
||||
|
||||
const jobId = Date.now().toString();
|
||||
renderJobs.set(jobId, { status: 'processing' });
|
||||
res.json({ jobId, message: "Rendu démarré en arrière-plan" });
|
||||
|
||||
// Run async to prevent 504 Gateway Timeout
|
||||
(async () => {
|
||||
try {
|
||||
const outputFilename = `out-${Date.now()}.mp4`;
|
||||
const outputLocation = path.join(uploadDir, outputFilename);
|
||||
|
||||
console.log("🎬 Rendering media...", totalFrames, "frames (Job:", jobId, ")");
|
||||
|
||||
await renderMedia({
|
||||
composition: { ...composition, durationInFrames: totalFrames },
|
||||
serveUrl: bundleLocation,
|
||||
codec: "h264",
|
||||
outputLocation,
|
||||
inputProps,
|
||||
// Ne pas y remettre "args" : Remotion ne lit jamais cette clé (elle est
|
||||
// absente de ChromiumOptions et de son code), les drapeaux passés là
|
||||
// n'atteignaient donc jamais le navigateur.
|
||||
chromiumOptions: {
|
||||
disableWebSecurity: true,
|
||||
ignoreCertificateErrors: true,
|
||||
},
|
||||
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
|
||||
});
|
||||
|
||||
console.log("✅ Render completed:", outputFilename);
|
||||
|
||||
// Generate thumbnail for the video
|
||||
const thumbnailFilename = outputFilename.replace('.mp4', '-thumb.jpg');
|
||||
const thumbnailPath = path.join(uploadDir, thumbnailFilename);
|
||||
const thumbnailGenerated = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
|
||||
|
||||
if (thumbnailGenerated) {
|
||||
console.log("🖼️ Thumbnail generated:", thumbnailFilename);
|
||||
}
|
||||
|
||||
renderJobs.set(jobId, {
|
||||
status: 'done',
|
||||
url: `/uploads/temp/${outputFilename}`,
|
||||
thumbnailUrl: thumbnailGenerated ? `/uploads/temp/${thumbnailFilename}` : null,
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("❌ Remotion render error for Job", jobId, ":", err);
|
||||
renderJobs.set(jobId, { status: 'error', error: "Erreur lors du rendu: " + err.message });
|
||||
}
|
||||
})();
|
||||
|
||||
} catch (err: any) {
|
||||
const job = await enqueueReelJob({ kind: "images", userId: user.id, params: parsed.data });
|
||||
res.json({ jobId: job.id, message: "Rendu démarré en arrière-plan" });
|
||||
} catch (err) {
|
||||
console.error("❌ Remotion pre-render error:", err);
|
||||
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + err.message });
|
||||
await removeFiles(uploaded);
|
||||
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + (err instanceof Error ? err.message : err) });
|
||||
}
|
||||
});
|
||||
|
||||
async function removeFiles(files: Express.Multer.File[]): Promise<void> {
|
||||
await Promise.all(files.map((f) => fs.promises.unlink(f.path).catch(() => { /* déjà absent */ })));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/remotion/publish
|
||||
* Uploads the rendered MP4 to Cloudinary and publishes (or schedules) it as a Reel.
|
||||
* Range la vidéo rendue dans la médiathèque puis la publie (ou la planifie).
|
||||
*/
|
||||
remotionRouter.post("/publish", async (req, res) => {
|
||||
try {
|
||||
const user = req.user as any;
|
||||
if (!user?.id) return res.status(401).json({ error: "Non authentifié" });
|
||||
const user = req.user as User;
|
||||
|
||||
const { videoUrl, pageIds, scheduledFor, description } = req.body as {
|
||||
videoUrl: string;
|
||||
@@ -365,106 +115,36 @@ remotionRouter.post("/publish", async (req, res) => {
|
||||
if (!videoUrl) return res.status(400).json({ error: "videoUrl requis" });
|
||||
if (!pageIds?.length) return res.status(400).json({ error: "Au moins une page requise" });
|
||||
|
||||
// Resolve the local file path from the temp URL
|
||||
// Chemin local du rendu à partir de son URL temporaire
|
||||
const filename = path.basename(videoUrl.split("?")[0]);
|
||||
const localPath = path.join(uploadDir, filename);
|
||||
if (!fs.existsSync(localPath)) {
|
||||
return res.status(400).json({ error: "Fichier vidéo introuvable (expiré ?)" });
|
||||
}
|
||||
|
||||
console.log("☁️ Uploading Remotion video to Cloudinary...");
|
||||
const cloudinaryResult = await cloudinaryService.uploadMedia(
|
||||
localPath,
|
||||
filename,
|
||||
user.id,
|
||||
"video/mp4"
|
||||
);
|
||||
console.log("✅ Cloudinary upload:", cloudinaryResult.originalUrl);
|
||||
const videoBuffer = await fs.promises.readFile(localPath);
|
||||
const mediaRecord = await storeRenderedVideo(user.id, videoBuffer, filename);
|
||||
|
||||
// Create a Post record to track this publication
|
||||
const post = await dbStorage.createPost({
|
||||
userId: user.id,
|
||||
content: description || "",
|
||||
aiGenerated: "false",
|
||||
status: scheduledFor ? "scheduled" : "published",
|
||||
status: scheduledFor ? "scheduled" : "draft",
|
||||
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
||||
});
|
||||
|
||||
// Create a Media record for the processed video
|
||||
// Vignette extraite avant que la vidéo ne soit purgée du disque
|
||||
const thumbnailUrl = await createVideoThumbnail(localPath);
|
||||
|
||||
const mediaRecord = await dbStorage.createMedia({
|
||||
userId: user.id,
|
||||
type: "video",
|
||||
cloudinaryPublicId: cloudinaryResult.publicId,
|
||||
originalUrl: cloudinaryResult.originalUrl,
|
||||
facebookFeedUrl: null,
|
||||
instagramFeedUrl: null,
|
||||
instagramStoryUrl: null,
|
||||
thumbnailUrl,
|
||||
fileName: filename,
|
||||
fileSize: fs.statSync(localPath).size,
|
||||
});
|
||||
|
||||
await dbStorage.updatePostMedia(post.id, [mediaRecord.id]);
|
||||
|
||||
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
|
||||
const results = await publishReelToPages({
|
||||
postId: post.id,
|
||||
pageIds,
|
||||
videoBuffer,
|
||||
description: description || "",
|
||||
scheduledFor,
|
||||
});
|
||||
|
||||
for (const pageId of pageIds) {
|
||||
try {
|
||||
const page = await dbStorage.getSocialPage(pageId);
|
||||
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
|
||||
if (page.platform !== "facebook" && page.platform !== "tiktok") {
|
||||
results.push({ pageId, success: false, error: `Plateforme non supportée pour les reels : ${page.platform}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduledPost = await dbStorage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: "reel",
|
||||
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
|
||||
});
|
||||
|
||||
if (!scheduledFor) {
|
||||
console.log(`🚀 Publishing to ${page.platform} ${page.pageName} (direct binary upload)...`);
|
||||
// Upload video bytes directly — les APIs ne peuvent pas lire une URL locale
|
||||
const videoBuffer = await fs.promises.readFile(localPath);
|
||||
|
||||
if (page.platform === "facebook") {
|
||||
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
||||
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
|
||||
results.push({ pageId, success: true, reelId });
|
||||
} else {
|
||||
// TikTok finalise la publication de son côté : le poller de statut
|
||||
// renseignera l'identifiant définitif du post.
|
||||
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
||||
await dbStorage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
publishId,
|
||||
publishStatus: "PROCESSING_UPLOAD",
|
||||
});
|
||||
results.push({ pageId, success: true, reelId: publishId });
|
||||
}
|
||||
} else {
|
||||
results.push({ pageId, success: true, reelId: "scheduled" });
|
||||
}
|
||||
} catch (pageErr: any) {
|
||||
console.error(`❌ Error publishing to page ${pageId}:`, pageErr);
|
||||
results.push({ pageId, success: false, error: pageErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
const anySuccess = results.some(r => r.success);
|
||||
if (!anySuccess) {
|
||||
await dbStorage.updatePost(post.id, { status: "failed" });
|
||||
}
|
||||
|
||||
res.json({ success: anySuccess, results, postId: post.id });
|
||||
|
||||
} catch (err: any) {
|
||||
res.json({ success: results.some((r) => r.success), results, postId: post.id });
|
||||
} catch (err) {
|
||||
console.error("❌ Remotion publish error:", err);
|
||||
res.status(500).json({ error: "Erreur lors de la publication: " + err.message });
|
||||
res.status(500).json({ error: "Erreur lors de la publication: " + (err instanceof Error ? err.message : err) });
|
||||
}
|
||||
});
|
||||
@@ -1,36 +1,82 @@
|
||||
/**
|
||||
* FFmpeg Docker API Service
|
||||
*
|
||||
* Intégration avec l'API FFmpeg Docker locale pour le traitement vidéo des Reels.
|
||||
* L'API attend une vidéo en base64 et retourne la vidéo traitée en base64.
|
||||
* Client du service FFmpeg (conteneur Python `ffmpeg-service`).
|
||||
*
|
||||
* Le service rend le Reel puis expose le MP4 en téléchargement
|
||||
* (GET /files/{job}/output.mp4) : la vidéo ne transite plus en base64 dans du
|
||||
* JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire.
|
||||
*/
|
||||
|
||||
interface FFmpegReelRequest {
|
||||
video_base64?: string; // Vidéo source en base64
|
||||
video_url?: string; // OU URL de la vidéo source
|
||||
text?: string; // Texte overlay style TikTok
|
||||
music_id?: string; // ID de la musique (catalogue FFmpeg)
|
||||
music_url?: string; // OU URL directe de la musique
|
||||
tts_enabled?: boolean; // Activation du TTS
|
||||
tts_voice?: string; // Voix TTS (ex: fr-FR-VivienneNeural)
|
||||
tts_engine?: string; // Moteur TTS: "edge" ou "gemini"
|
||||
gemini_api_key?: string; // Clé API Google Gemini pour TTS
|
||||
word_duration?: number; // Durée par mot (default: 0.6s)
|
||||
font_size?: number; // Taille police (default: 24)
|
||||
music_volume?: number; // Volume musique (default: 0.25)
|
||||
draw_text?: boolean; // Dessiner le texte sur la vidéo (default: true)
|
||||
stabilize?: boolean; // Stabilisation vidéo via vidstab (default: false)
|
||||
watermark_url?: string; // URL du logo
|
||||
store_name?: string; // Nom du magasin pour l'outro
|
||||
enable_ending_effect?: boolean; // Activer l'effet de fin (logo+fondu)
|
||||
import fs from 'fs';
|
||||
import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import type { ReadableStream as WebReadableStream } from 'stream/web';
|
||||
import type { SrtCue } from '@shared/srt';
|
||||
import type { TtsEngine, TtsStyle } from '@shared/voices';
|
||||
|
||||
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
|
||||
const PROCESS_TIMEOUT_MS = 20 * 60_000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
||||
const TTS_TIMEOUT_MS = 3 * 60_000;
|
||||
const HEALTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface ReelRenderOptions {
|
||||
text?: string;
|
||||
/** Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant. */
|
||||
srtCues?: SrtCue[];
|
||||
musicUrl?: string;
|
||||
ttsEnabled?: boolean;
|
||||
ttsVoice?: string;
|
||||
ttsEngine?: TtsEngine;
|
||||
ttsStyle?: TtsStyle;
|
||||
geminiApiKey?: string;
|
||||
fontSize?: number;
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
stabilize?: boolean;
|
||||
watermarkUrl?: string;
|
||||
storeName?: string;
|
||||
enableEndingEffect?: boolean;
|
||||
/** Petit logo pendant la vidéo (le grand logo de fin dépend de enableEndingEffect). */
|
||||
showLogo?: boolean;
|
||||
}
|
||||
|
||||
interface FFmpegReelResponse {
|
||||
success: boolean;
|
||||
output_base64?: string;
|
||||
duration?: number;
|
||||
detail?: string;
|
||||
tts_error?: string;
|
||||
export interface ReelRenderResult {
|
||||
video: Buffer;
|
||||
duration: number;
|
||||
ttsEngine?: string | null;
|
||||
ttsVoice?: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. */
|
||||
export interface PreparedReel {
|
||||
videoPath: string;
|
||||
audioPath: string | null;
|
||||
totalDuration: number;
|
||||
videoDuration: number;
|
||||
logoStart: number | null;
|
||||
/** Fondu final, calculé par le service pour ne jamais rogner la voix. */
|
||||
fadeStart: number | null;
|
||||
fadeDuration: number | null;
|
||||
words: TimedWord[];
|
||||
ttsEngine?: string | null;
|
||||
ttsVoice?: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface TimedWord {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VoicePreview {
|
||||
audio: Buffer;
|
||||
duration: number;
|
||||
words: TimedWord[];
|
||||
engine: string;
|
||||
voice: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface FFmpegConfig {
|
||||
@@ -38,171 +84,63 @@ interface FFmpegConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/** Erreur renvoyée par le service, avec son message lisible. */
|
||||
export class FFmpegServiceError extends Error {
|
||||
constructor(message: string, readonly status?: number) {
|
||||
super(message);
|
||||
this.name = 'FFmpegServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class FFmpegService {
|
||||
private config: FFmpegConfig | null = null;
|
||||
|
||||
/**
|
||||
* Configure le service avec l'URL et la clé API
|
||||
*/
|
||||
configure(apiUrl: string, apiKey: string): void {
|
||||
this.config = { apiUrl, apiKey };
|
||||
console.log('🎬 FFmpeg Service configured:', apiUrl);
|
||||
this.config = { apiUrl: apiUrl.replace(/\/$/, ''), apiKey };
|
||||
console.log('🎬 FFmpeg Service configured:', this.config.apiUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le service est configuré
|
||||
*/
|
||||
private ensureConfigured(): FFmpegConfig {
|
||||
if (!this.config) {
|
||||
throw new Error('FFmpeg Service not configured. Call configure() first.');
|
||||
throw new FFmpegServiceError("Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY).");
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite une vidéo pour créer un Reel avec musique et texte overlay
|
||||
*
|
||||
* @param videoBase64 - Vidéo source encodée en base64
|
||||
* @param options - Options de traitement (texte, musique, etc.)
|
||||
* @returns Vidéo traitée en base64
|
||||
*/
|
||||
async processReelVideo(
|
||||
videoBase64: string,
|
||||
options: {
|
||||
text?: string;
|
||||
musicId?: string;
|
||||
musicUrl?: string;
|
||||
ttsEnabled?: boolean;
|
||||
ttsVoice?: string;
|
||||
ttsEngine?: string;
|
||||
geminiApiKey?: string;
|
||||
wordDuration?: number;
|
||||
fontSize?: number;
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
stabilize?: boolean;
|
||||
watermarkUrl?: string;
|
||||
storeName?: string;
|
||||
enableEndingEffect?: boolean;
|
||||
} = {}
|
||||
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
|
||||
private async call(path: string, init: RequestInit & { timeoutMs: number }): Promise<Response> {
|
||||
const config = this.ensureConfigured();
|
||||
|
||||
const requestBody: FFmpegReelRequest = {
|
||||
video_base64: videoBase64,
|
||||
text: options.text,
|
||||
music_id: options.musicId,
|
||||
music_url: options.musicUrl,
|
||||
tts_enabled: options.ttsEnabled,
|
||||
tts_voice: options.ttsVoice,
|
||||
tts_engine: options.ttsEngine,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
word_duration: options.wordDuration ?? 0.6,
|
||||
font_size: options.fontSize ?? 64,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
stabilize: options.stabilize ?? false,
|
||||
watermark_url: options.watermarkUrl,
|
||||
store_name: options.storeName,
|
||||
enable_ending_effect: options.enableEndingEffect ?? true,
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(requestBody).forEach(key => {
|
||||
if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
|
||||
delete requestBody[key as keyof FFmpegReelRequest];
|
||||
}
|
||||
const { timeoutMs, headers, ...rest } = init;
|
||||
const response = await fetch(`${config.apiUrl}${path}`, {
|
||||
...rest,
|
||||
headers: { 'X-API-Key': config.apiKey, ...headers },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
console.log('🎬 Processing Reel video:', {
|
||||
hasVideo: !!videoBase64,
|
||||
hasText: !!options.text,
|
||||
hasMusicId: !!options.musicId,
|
||||
hasMusicUrl: !!options.musicUrl,
|
||||
hasTTS: options.ttsEnabled,
|
||||
drawText: options.drawText,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/process-reel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ FFmpeg API error:', response.status, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `FFmpeg API error: ${response.status} - ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as FFmpegReelResponse;
|
||||
|
||||
if (!data.success) {
|
||||
console.error('❌ FFmpeg processing failed:', data.detail);
|
||||
return {
|
||||
success: false,
|
||||
error: data.detail || 'Unknown FFmpeg processing error',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ Reel video processed successfully, duration:', data.duration);
|
||||
return {
|
||||
success: true,
|
||||
videoBase64: data.output_base64,
|
||||
duration: data.duration,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ FFmpeg Service error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
let detail = body;
|
||||
try {
|
||||
detail = JSON.parse(body).detail ?? body;
|
||||
} catch { /* corps non JSON */ }
|
||||
throw new FFmpegServiceError(String(detail).slice(0, 2000), response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite une vidéo depuis une URL (télécharge, traite, retourne base64)
|
||||
* Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit.
|
||||
* Lève FFmpegServiceError en cas d'échec (voix comprise).
|
||||
*/
|
||||
async processReelFromUrl(
|
||||
videoUrl: string,
|
||||
options: {
|
||||
text?: string;
|
||||
musicId?: string;
|
||||
musicUrl?: string;
|
||||
ttsEnabled?: boolean;
|
||||
ttsVoice?: string;
|
||||
ttsEngine?: string;
|
||||
geminiApiKey?: string;
|
||||
wordDuration?: number;
|
||||
fontSize?: number;
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
stabilize?: boolean;
|
||||
watermarkUrl?: string;
|
||||
storeName?: string;
|
||||
enableEndingEffect?: boolean;
|
||||
} = {}
|
||||
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string; ttsError?: string }> {
|
||||
const config = this.ensureConfigured();
|
||||
|
||||
const requestBody: FFmpegReelRequest = {
|
||||
async renderReel(videoUrl: string, options: ReelRenderOptions = {}): Promise<ReelRenderResult> {
|
||||
const body = {
|
||||
video_url: videoUrl,
|
||||
text: options.text,
|
||||
music_id: options.musicId,
|
||||
srt_cues: options.srtCues,
|
||||
music_url: options.musicUrl,
|
||||
tts_enabled: options.ttsEnabled,
|
||||
tts_enabled: options.ttsEnabled ?? false,
|
||||
tts_voice: options.ttsVoice,
|
||||
tts_engine: options.ttsEngine,
|
||||
tts_style: options.ttsStyle,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
word_duration: options.wordDuration ?? 0.6,
|
||||
font_size: options.fontSize ?? 64,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
@@ -210,138 +148,176 @@ export class FFmpegService {
|
||||
watermark_url: options.watermarkUrl,
|
||||
store_name: options.storeName,
|
||||
enable_ending_effect: options.enableEndingEffect ?? true,
|
||||
show_watermark: options.showLogo ?? true,
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(requestBody).forEach(key => {
|
||||
if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
|
||||
delete requestBody[key as keyof FFmpegReelRequest];
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🎬 Processing Reel from URL:', {
|
||||
console.log('🎬 Rendu du Reel :', {
|
||||
videoUrl,
|
||||
hasText: !!options.text,
|
||||
textLength: options.text?.length || 0,
|
||||
hasMusicId: !!options.musicId,
|
||||
hasMusicUrl: !!options.musicUrl,
|
||||
ttsEnabled: options.ttsEnabled,
|
||||
drawText: options.drawText,
|
||||
textLength: options.text?.length ?? 0,
|
||||
music: !!options.musicUrl,
|
||||
tts: options.ttsEnabled ? `${options.ttsEngine}/${options.ttsVoice}/${options.ttsStyle ?? 'neutral'}` : false,
|
||||
});
|
||||
|
||||
const debugBody = { ...requestBody };
|
||||
console.log('📤 Sending to FFmpeg API:', JSON.stringify({ ...debugBody, text: debugBody.text ? `[${debugBody.text.length} chars]` : undefined }));
|
||||
const response = await this.call('/process-reel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
timeoutMs: PROCESS_TIMEOUT_MS,
|
||||
});
|
||||
const data = await response.json() as {
|
||||
job_id: string;
|
||||
output_path: string;
|
||||
duration: number;
|
||||
tts_engine?: string | null;
|
||||
tts_voice?: string | null;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/process-reel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ FFmpeg API error:', response.status, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `FFmpeg API error: ${response.status} - ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as FFmpegReelResponse;
|
||||
|
||||
if (!data.success) {
|
||||
console.error('❌ FFmpeg processing failed:', data.detail);
|
||||
return {
|
||||
success: false,
|
||||
error: data.detail || 'Unknown FFmpeg processing error',
|
||||
};
|
||||
}
|
||||
|
||||
if (data.tts_error) {
|
||||
console.error('❌ TTS failed in Python service:', data.tts_error);
|
||||
}
|
||||
console.log('✅ Reel video processed successfully from URL');
|
||||
const file = await this.call(data.output_path, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
||||
const video = Buffer.from(await file.arrayBuffer());
|
||||
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
|
||||
return {
|
||||
success: true,
|
||||
videoBase64: data.output_base64,
|
||||
video,
|
||||
duration: data.duration,
|
||||
ttsError: data.tts_error,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ FFmpeg Service error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
ttsEngine: data.tts_engine,
|
||||
ttsVoice: data.tts_voice,
|
||||
warnings: data.warnings ?? [],
|
||||
};
|
||||
} finally {
|
||||
// Le fichier n'est plus utile au service une fois récupéré
|
||||
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
|
||||
.catch(() => { /* purgé plus tard par le service */ });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie la santé de l'API FFmpeg
|
||||
* Prépare un rendu Remotion : image recadrée à la durée finale et piste son
|
||||
* finale, écrites dans `targetDir`, plus le minutage des mots.
|
||||
*/
|
||||
async prepareReel(
|
||||
videoUrl: string,
|
||||
options: ReelRenderOptions & { hasLogo: boolean },
|
||||
targetDir: string,
|
||||
): Promise<PreparedReel> {
|
||||
const response = await this.call('/prepare-reel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
video_url: videoUrl,
|
||||
text: options.text,
|
||||
srt_cues: options.srtCues,
|
||||
music_url: options.musicUrl,
|
||||
tts_enabled: options.ttsEnabled ?? false,
|
||||
tts_voice: options.ttsVoice,
|
||||
tts_engine: options.ttsEngine,
|
||||
tts_style: options.ttsStyle,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
stabilize: options.stabilize ?? false,
|
||||
store_name: options.storeName,
|
||||
enable_ending_effect: options.enableEndingEffect ?? true,
|
||||
has_logo: options.hasLogo,
|
||||
}),
|
||||
timeoutMs: PROCESS_TIMEOUT_MS,
|
||||
});
|
||||
const data = await response.json() as {
|
||||
job_id: string;
|
||||
video_path: string;
|
||||
audio_path: string | null;
|
||||
total_duration: number;
|
||||
video_duration: number;
|
||||
logo_start: number | null;
|
||||
fade_start?: number | null;
|
||||
fade_duration?: number | null;
|
||||
words: TimedWord[];
|
||||
tts_engine?: string | null;
|
||||
tts_voice?: string | null;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(targetDir, { recursive: true });
|
||||
const videoPath = `${targetDir}/video.mp4`;
|
||||
await this.downloadTo(data.video_path, videoPath);
|
||||
let audioPath: string | null = null;
|
||||
if (data.audio_path) {
|
||||
audioPath = `${targetDir}/audio.wav`;
|
||||
await this.downloadTo(data.audio_path, audioPath);
|
||||
}
|
||||
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
|
||||
return {
|
||||
videoPath,
|
||||
audioPath,
|
||||
totalDuration: data.total_duration,
|
||||
videoDuration: data.video_duration,
|
||||
logoStart: data.logo_start,
|
||||
fadeStart: data.fade_start ?? null,
|
||||
fadeDuration: data.fade_duration ?? null,
|
||||
words: data.words ?? [],
|
||||
ttsEngine: data.tts_engine,
|
||||
ttsVoice: data.tts_voice,
|
||||
warnings: data.warnings ?? [],
|
||||
};
|
||||
} finally {
|
||||
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
|
||||
.catch(() => { /* purgé plus tard par le service */ });
|
||||
}
|
||||
}
|
||||
|
||||
/** Télécharge un fichier du service en flux, sans le charger en mémoire. */
|
||||
private async downloadTo(servicePath: string, target: string): Promise<void> {
|
||||
const response = await this.call(servicePath, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
||||
if (!response.body) throw new FFmpegServiceError(`Réponse vide pour ${servicePath}`);
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as unknown as WebReadableStream<Uint8Array>),
|
||||
fs.createWriteStream(target),
|
||||
);
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const config = this.ensureConfigured();
|
||||
const response = await fetch(`${config.apiUrl}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-API-Key': config.apiKey,
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async previewTTS(
|
||||
|
||||
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
|
||||
async previewVoice(
|
||||
text: string,
|
||||
ttsVoice?: string,
|
||||
ttsEngine?: string,
|
||||
geminiApiKey?: string
|
||||
): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
||||
const config = this.ensureConfigured();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/preview-tts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
tts_enabled: true,
|
||||
tts_voice: ttsVoice,
|
||||
tts_engine: ttsEngine,
|
||||
gemini_api_key: geminiApiKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `FFmpeg API error: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
return { success: false, error: data.detail };
|
||||
}
|
||||
|
||||
return { success: true, audioBase64: data.audio_base64 };
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TTS Preview error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string } = {},
|
||||
): Promise<VoicePreview> {
|
||||
const response = await this.call('/preview-tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
tts_voice: options.voice,
|
||||
tts_engine: options.engine,
|
||||
tts_style: options.style,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
}),
|
||||
timeoutMs: TTS_TIMEOUT_MS,
|
||||
});
|
||||
const data = await response.json() as {
|
||||
audio_base64: string;
|
||||
duration: number;
|
||||
words: TimedWord[];
|
||||
engine: string;
|
||||
voice: string;
|
||||
warnings?: string[];
|
||||
};
|
||||
return {
|
||||
audio: Buffer.from(data.audio_base64, 'base64'),
|
||||
duration: data.duration,
|
||||
words: data.words ?? [],
|
||||
engine: data.engine,
|
||||
voice: data.voice,
|
||||
warnings: data.warnings ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Résolution des ressources d'un Reel (musique, logo, clé Gemini, nom du
|
||||
* magasin), jusqu'ici recopiée dans chaque route.
|
||||
*/
|
||||
|
||||
import { storage } from "../../storage";
|
||||
import { buildMinioUrl, resolveInternalUrl } from "../minio";
|
||||
|
||||
/**
|
||||
* URL de la musique téléchargeable par le service FFmpeg (réseau Docker
|
||||
* interne : l'URL publique HTTPS n'y est pas joignable).
|
||||
*/
|
||||
export async function resolveMusicUrl(
|
||||
musicTrackId?: string,
|
||||
musicUrl?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (musicUrl) return resolveInternalUrl(musicUrl);
|
||||
if (!musicTrackId?.startsWith("internal_")) return undefined;
|
||||
|
||||
try {
|
||||
const track = await storage.getAudioTrack(musicTrackId.slice("internal_".length));
|
||||
return track ? resolveInternalUrl(track.url) : undefined;
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Reels] Piste audio introuvable :", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Chemin relatif (/uploads/...) du logo configuré, s'il existe. */
|
||||
export async function resolveLogoPath(): Promise<string | undefined> {
|
||||
try {
|
||||
const config = await storage.getCloudinaryConfig();
|
||||
if (config?.logoPublicId) {
|
||||
return buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Reels] Configuration du logo illisible :", error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Clé Gemini : configuration de l'application, sinon variable d'environnement. */
|
||||
export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string | undefined> {
|
||||
if (ttsEngine !== "gemini") return undefined;
|
||||
const appConfig = await storage.getAppConfig();
|
||||
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
|
||||
}
|
||||
|
||||
/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */
|
||||
export async function resolveStoreName(
|
||||
userId: string,
|
||||
preferredPageId?: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
if (preferredPageId) {
|
||||
const page = await storage.getSocialPage(preferredPageId);
|
||||
if (page) return page.pageName;
|
||||
}
|
||||
const pages = await storage.getSocialPages(userId);
|
||||
return pages[0]?.pageName;
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Reels] Nom du magasin introuvable :", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Rendu d'un Reel à partir d'images avec Remotion.
|
||||
* Exécuté par la file `reel_jobs` ; la publication reste une étape distincte,
|
||||
* déclenchée par l'utilisateur après l'aperçu.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
|
||||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||
import { ffmpegService } from "../ffmpeg";
|
||||
import { generateVideoThumbnail } from "../thumbnail";
|
||||
import type { JobContext } from "./queue";
|
||||
import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
|
||||
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
|
||||
export { REMOTION_TEMP_DIR };
|
||||
|
||||
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
|
||||
const params = imagesReelParamsSchema.parse(job.params);
|
||||
const jobTempFiles: string[] = [];
|
||||
|
||||
try {
|
||||
await progress(5, "prepare");
|
||||
const images = await Promise.all(params.imageUrls.map(toDataUrl));
|
||||
const logoPath = await resolveLogoPath();
|
||||
const logoUrl = logoPath ? await toDataUrl(logoPath) : undefined;
|
||||
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
|
||||
const { overlayText, storeName } = params;
|
||||
|
||||
// --- Voix et minutage réel des mots ---
|
||||
let audioUrl: string | undefined;
|
||||
let words: { text: string; start: number; end: number }[] = [];
|
||||
let audioDuration = 0;
|
||||
|
||||
const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
|
||||
if (params.ttsEnabled && spokenText) {
|
||||
await progress(15, "voice");
|
||||
const voice = await ffmpegService.previewVoice(spokenText, {
|
||||
voice: params.ttsVoice,
|
||||
engine: params.ttsEngine,
|
||||
style: params.ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
||||
});
|
||||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||||
|
||||
const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
|
||||
await fs.promises.writeFile(audioPath, voice.audio);
|
||||
jobTempFiles.push(audioPath);
|
||||
audioUrl = tempFileUrl(audioPath);
|
||||
audioDuration = voice.duration;
|
||||
words = voice.words;
|
||||
}
|
||||
|
||||
// --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: images.length,
|
||||
voiceDuration: audioDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
|
||||
await progress(25, "render");
|
||||
const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
|
||||
let lastReported = 25;
|
||||
await renderReelComposition({
|
||||
compositionId: "ImageVideo",
|
||||
outputLocation,
|
||||
inputProps: {
|
||||
images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle: params.captionStyle,
|
||||
audioUrl,
|
||||
musicUrl,
|
||||
musicVolume: params.musicVolume,
|
||||
logoUrl,
|
||||
showWatermark: params.showLogo,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
},
|
||||
onProgress: (ratio) => {
|
||||
const pct = 25 + Math.floor(ratio * 70);
|
||||
if (pct >= lastReported + 5) {
|
||||
lastReported = pct;
|
||||
progress(pct, "render").catch(() => { /* non bloquant */ });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
|
||||
const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
|
||||
|
||||
return {
|
||||
url: `/uploads/temp/${path.basename(outputLocation)}`,
|
||||
thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : null,
|
||||
};
|
||||
} finally {
|
||||
// Images, musique importée et voix ne servent plus une fois le rendu fini
|
||||
for (const file of [...params.tempFiles, ...jobTempFiles]) {
|
||||
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Point d'entrée des Reels : enregistre les traitements de la file et démarre
|
||||
* le worker.
|
||||
*/
|
||||
|
||||
import { registerReelJobHandler, startReelWorker } from "./queue";
|
||||
import { runVideoReelJob } from "./videoPipeline";
|
||||
import { runImagesReelJob } from "./imagesPipeline";
|
||||
|
||||
export function startReelProcessing(): Promise<void> {
|
||||
registerReelJobHandler("video", runVideoReelJob);
|
||||
registerReelJobHandler("images", runImagesReelJob);
|
||||
return startReelWorker();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Enregistrement et publication d'un Reel rendu, commun aux Reels vidéo et
|
||||
* images.
|
||||
*
|
||||
* La vidéo est toujours envoyée en binaire : les fichiers vivent sous
|
||||
* /uploads, une URL relative que Facebook ne peut pas télécharger (c'était le
|
||||
* cas de l'ancien `publishReel(originalUrl)`).
|
||||
*/
|
||||
|
||||
import type { Media } from "@shared/schema";
|
||||
import { storage } from "../../storage";
|
||||
import { minioService } from "../minio";
|
||||
import { createVideoThumbnail } from "../thumbnail";
|
||||
import { facebookService } from "../facebook";
|
||||
import { tiktokService } from "../tiktok";
|
||||
|
||||
export interface PagePublishResult {
|
||||
pageId: string;
|
||||
success: boolean;
|
||||
reelId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
|
||||
export async function storeRenderedVideo(
|
||||
userId: string,
|
||||
videoBuffer: Buffer,
|
||||
fileName: string,
|
||||
): Promise<Media> {
|
||||
const uploaded = await minioService.uploadMedia(videoBuffer, fileName, userId, "video/mp4");
|
||||
// Vignette extraite maintenant : la vidéo sera supprimée du disque après
|
||||
// publication, mais l'historique doit rester illustré.
|
||||
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
|
||||
|
||||
return storage.createMedia({
|
||||
userId,
|
||||
type: "video",
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
thumbnailUrl,
|
||||
fileName,
|
||||
fileSize: videoBuffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
|
||||
* `scheduled_posts` et met à jour le statut du post.
|
||||
*/
|
||||
export async function publishReelToPages(options: {
|
||||
postId: string;
|
||||
pageIds: string[];
|
||||
videoBuffer: Buffer;
|
||||
description: string;
|
||||
scheduledFor?: string;
|
||||
}): Promise<PagePublishResult[]> {
|
||||
const { postId, pageIds, videoBuffer, description, scheduledFor } = options;
|
||||
const results: PagePublishResult[] = [];
|
||||
|
||||
for (const pageId of pageIds) {
|
||||
try {
|
||||
const page = await storage.getSocialPage(pageId);
|
||||
if (!page) {
|
||||
results.push({ pageId, success: false, error: "Page non trouvée" });
|
||||
continue;
|
||||
}
|
||||
if (page.platform !== "facebook" && page.platform !== "tiktok") {
|
||||
results.push({
|
||||
pageId,
|
||||
success: false,
|
||||
error: `Plateforme non supportée pour les reels : ${page.platform}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduledPost = await storage.createScheduledPost({
|
||||
postId,
|
||||
pageId: page.id,
|
||||
postType: "reel",
|
||||
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
|
||||
});
|
||||
|
||||
if (scheduledFor) {
|
||||
// Le planificateur publiera à l'heure dite
|
||||
results.push({ pageId, success: true, reelId: "scheduled" });
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`🚀 [Reels] Publication sur ${page.platform} ${page.pageName}...`);
|
||||
if (page.platform === "facebook") {
|
||||
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description);
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
externalPostId: reelId,
|
||||
});
|
||||
results.push({ pageId, success: true, reelId });
|
||||
} else {
|
||||
// TikTok finalise la publication de son côté : l'envoi est marqué comme
|
||||
// effectué pour ne pas republier, et le poller de statut renseignera
|
||||
// l'identifiant définitif du post.
|
||||
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description);
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
publishId,
|
||||
publishStatus: "PROCESSING_UPLOAD",
|
||||
});
|
||||
results.push({ pageId, success: true, reelId: publishId });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [Reels] Publication sur la page ${pageId} impossible :`, error);
|
||||
results.push({
|
||||
pageId,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erreur inconnue",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const status = scheduledFor && succeeded > 0 ? "scheduled" : succeeded > 0 ? "published" : "failed";
|
||||
await storage.updatePost(postId, { status });
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
|
||||
export function describePublishFailure(results: PagePublishResult[]): string {
|
||||
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
|
||||
return `Publication impossible sur toutes les pages${details ? ` : ${details}` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* File d'attente persistante des rendus de Reels.
|
||||
*
|
||||
* L'ancienne file comptait les posts « processing » pour décider de lancer un
|
||||
* rendu : un redémarrage pendant un job laissait ce compteur à 1 pour toujours
|
||||
* et bloquait tous les Reels suivants. Ici, chaque job est une ligne de
|
||||
* `reel_jobs` :
|
||||
* - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui exclut
|
||||
* tout double traitement, même avec plusieurs instances ;
|
||||
* - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs
|
||||
* dont le cœur ne bat plus sont remis en file (ou échouent après
|
||||
* MAX_ATTEMPTS tentatives).
|
||||
*/
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../../db";
|
||||
import { posts, reelJobs, type ReelJob } from "@shared/schema";
|
||||
import type { ReelJobKind, ReelJobStatus } from "@shared/reel";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
/** Au-delà, un job « processing » est considéré comme orphelin. */
|
||||
const STALE_AFTER_MS = 2 * 60_000;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const MAX_ATTEMPTS = 2;
|
||||
|
||||
/** Outils fournis à un handler pour rendre compte de son avancement. */
|
||||
export interface JobContext {
|
||||
job: ReelJob;
|
||||
progress(progress: number, step?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type JobHandler = (ctx: JobContext) => Promise<unknown>;
|
||||
|
||||
const handlers = new Map<ReelJobKind, JobHandler>();
|
||||
let running = false;
|
||||
let started = false;
|
||||
let pollTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
export function registerReelJobHandler(kind: ReelJobKind, handler: JobHandler): void {
|
||||
handlers.set(kind, handler);
|
||||
}
|
||||
|
||||
/** Ajoute un job à la file et réveille le worker. */
|
||||
export async function enqueueReelJob(input: {
|
||||
kind: ReelJobKind;
|
||||
userId: string;
|
||||
postId?: string;
|
||||
params: unknown;
|
||||
}): Promise<ReelJob> {
|
||||
const [job] = await db
|
||||
.insert(reelJobs)
|
||||
.values({
|
||||
kind: input.kind,
|
||||
userId: input.userId,
|
||||
postId: input.postId,
|
||||
params: input.params,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (job.postId) {
|
||||
await syncPost(job.postId, "pending", 0);
|
||||
}
|
||||
|
||||
kick();
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function getReelJob(id: string): Promise<ReelJob | undefined> {
|
||||
const [job] = await db.select().from(reelJobs).where(eq(reelJobs.id, id));
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Nombre de jobs qui passeront avant un nouveau job (utile pour informer l'utilisateur). */
|
||||
export async function countActiveReelJobs(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(reelJobs)
|
||||
.where(sql`${reelJobs.status} IN ('pending', 'processing')`);
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le worker : récupère les jobs orphelins puis traite la file.
|
||||
* Idempotent.
|
||||
*/
|
||||
export async function startReelWorker(): Promise<void> {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
try {
|
||||
await recoverStaleJobs();
|
||||
await failLegacyStuckPosts();
|
||||
} catch (error) {
|
||||
console.error("❌ [ReelQueue] Récupération des jobs orphelins impossible :", error);
|
||||
}
|
||||
|
||||
console.log(`🎬 [ReelQueue] Worker démarré (traitements : ${Array.from(handlers.keys()).join(", ")})`);
|
||||
pollTimer = setInterval(kick, POLL_INTERVAL_MS);
|
||||
pollTimer.unref();
|
||||
kick();
|
||||
}
|
||||
|
||||
/** Relance la boucle si elle est à l'arrêt. */
|
||||
function kick(): void {
|
||||
if (!started || running) return;
|
||||
running = true;
|
||||
drain()
|
||||
.catch((error) => console.error("❌ [ReelQueue] Erreur de la boucle :", error))
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
for (;;) {
|
||||
const job = await claimNextJob();
|
||||
if (!job) return;
|
||||
await runJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
async function claimNextJob(): Promise<ReelJob | undefined> {
|
||||
const result = await db.execute<{ id: string }>(sql`
|
||||
UPDATE reel_jobs
|
||||
SET status = 'processing',
|
||||
attempts = attempts + 1,
|
||||
heartbeat_at = now(),
|
||||
updated_at = now(),
|
||||
error = NULL
|
||||
WHERE id = (
|
||||
SELECT id FROM reel_jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
const id = result.rows[0]?.id;
|
||||
return id ? getReelJob(id) : undefined;
|
||||
}
|
||||
|
||||
async function runJob(job: ReelJob): Promise<void> {
|
||||
const handler = handlers.get(job.kind as ReelJobKind);
|
||||
console.log(`🎬 [ReelQueue] Job ${job.id} (${job.kind}), tentative ${job.attempts}`);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
db.update(reelJobs)
|
||||
.set({ heartbeatAt: new Date() })
|
||||
.where(eq(reelJobs.id, job.id))
|
||||
.catch((error) => console.warn(`⚠️ [ReelQueue] Heartbeat ${job.id} :`, error));
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
const ctx: JobContext = {
|
||||
job,
|
||||
async progress(progress, step) {
|
||||
await updateJob(job, { progress, step });
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
if (!handler) {
|
||||
throw new Error(`Aucun traitement enregistré pour les jobs « ${job.kind} »`);
|
||||
}
|
||||
if (job.postId) {
|
||||
await syncPost(job.postId, "processing", 0);
|
||||
}
|
||||
const result = await handler(ctx);
|
||||
await updateJob(job, { status: "completed", progress: 100, step: "done", result: result ?? null });
|
||||
console.log(`✅ [ReelQueue] Job ${job.id} terminé`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`❌ [ReelQueue] Job ${job.id} en échec :`, error);
|
||||
await updateJob(job, { status: "failed", error: message }).catch((e) =>
|
||||
console.error(`⚠️ [ReelQueue] Statut d'échec non enregistré pour ${job.id} :`, e),
|
||||
);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateJob(
|
||||
job: ReelJob,
|
||||
patch: {
|
||||
status?: ReelJobStatus;
|
||||
progress?: number;
|
||||
step?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(reelJobs)
|
||||
.set({ ...patch, heartbeatAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(reelJobs.id, job.id));
|
||||
|
||||
if (job.postId) {
|
||||
const status = patch.status ?? "processing";
|
||||
const progress = patch.status === "failed" ? 0 : patch.progress;
|
||||
await syncPost(job.postId, status, progress, patch.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les
|
||||
* colonnes `generation_*` du post.
|
||||
*/
|
||||
async function syncPost(
|
||||
postId: string,
|
||||
status: ReelJobStatus,
|
||||
progress?: number,
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const patch: Partial<typeof posts.$inferInsert> = {
|
||||
generationStatus: status,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (progress !== undefined) patch.generationProgress = progress;
|
||||
if (error !== undefined) patch.generationError = error;
|
||||
if (status === "failed") patch.status = "failed";
|
||||
await db.update(posts).set(patch).where(eq(posts.id, postId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou les
|
||||
* fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait tomber
|
||||
* le serveur ne doit pas le faire tomber en boucle.
|
||||
*/
|
||||
async function recoverStaleJobs(): Promise<void> {
|
||||
const staleBefore = new Date(Date.now() - STALE_AFTER_MS);
|
||||
const stale = await db
|
||||
.select()
|
||||
.from(reelJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(reelJobs.status, "processing"),
|
||||
sql`(${reelJobs.heartbeatAt} IS NULL OR ${reelJobs.heartbeatAt} < ${staleBefore})`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const job of stale) {
|
||||
if (job.attempts >= MAX_ATTEMPTS) {
|
||||
console.warn(`⚠️ [ReelQueue] Job ${job.id} abandonné après ${job.attempts} tentatives`);
|
||||
await updateJob(job, {
|
||||
status: "failed",
|
||||
error: "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur).",
|
||||
});
|
||||
} else {
|
||||
console.warn(`♻️ [ReelQueue] Job ${job.id} interrompu, remis en file`);
|
||||
await db
|
||||
.update(reelJobs)
|
||||
.set({ status: "pending", step: null, progress: 0, updatedAt: new Date() })
|
||||
.where(eq(reelJobs.id, job.id));
|
||||
if (job.postId) await syncPost(job.postId, "pending", 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts laissés « processing »/« pending » par l'ancienne file (avant
|
||||
* reel_jobs) : ils n'ont aucun job pour les reprendre et bloquaient l'affichage
|
||||
* des Reels en cours. On les marque en échec pour que l'utilisateur relance.
|
||||
*/
|
||||
async function failLegacyStuckPosts(): Promise<void> {
|
||||
const result = await db.execute(sql`
|
||||
UPDATE posts
|
||||
SET generation_status = 'failed',
|
||||
generation_progress = 0,
|
||||
generation_error = 'Génération interrompue, veuillez relancer le Reel.',
|
||||
status = 'failed',
|
||||
updated_at = now()
|
||||
WHERE generation_status IN ('processing', 'pending')
|
||||
AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id)
|
||||
`);
|
||||
if (result.rowCount) {
|
||||
console.warn(`⚠️ [ReelQueue] ${result.rowCount} Reel(s) de l'ancienne file marqués en échec`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toDataUrl } from "./remotionAssets";
|
||||
|
||||
describe("toDataUrl", () => {
|
||||
it("refuse un chemin qui sort du dossier uploads", async () => {
|
||||
await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/);
|
||||
});
|
||||
|
||||
it("laisse les URL externes telles quelles", async () => {
|
||||
await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Ressources fournies aux compositions Remotion pendant un rendu serveur.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */
|
||||
export const REMOTION_TEMP_DIR =
|
||||
process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp");
|
||||
|
||||
/**
|
||||
* URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont
|
||||
* lus côté Node par le moteur de rendu : l'adresse locale suffit.
|
||||
*/
|
||||
export function localHttpUrl(url: string): string {
|
||||
if (!url.startsWith("/")) return url;
|
||||
const port = process.env.PORT || "5555";
|
||||
return `http://localhost:${port}${url}`;
|
||||
}
|
||||
|
||||
/** URL servie d'un fichier du dossier temporaire. */
|
||||
export function tempFileUrl(absolutePath: string): string {
|
||||
const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/");
|
||||
return localHttpUrl(`/uploads/temp/${relative}`);
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
|
||||
/**
|
||||
* Convertit un chemin local /uploads/... en data URL : les images sont
|
||||
* affichées par Chromium, qui ne lit pas http://localhost de façon fiable
|
||||
* dans Docker. Refuse tout chemin sortant du dossier uploads.
|
||||
*/
|
||||
export async function toDataUrl(relativeUrl: string): Promise<string> {
|
||||
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
|
||||
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
|
||||
const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep;
|
||||
const filePath = relativeUrl.startsWith("/uploads/temp/")
|
||||
? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length))
|
||||
: path.resolve(process.cwd(), "." + relativeUrl);
|
||||
if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) {
|
||||
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
|
||||
}
|
||||
try {
|
||||
const buffer = await fs.promises.readFile(filePath);
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase();
|
||||
return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`;
|
||||
} catch {
|
||||
console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`);
|
||||
return relativeUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis
|
||||
* en cache, réglages d'encodage des réseaux sociaux, progression.
|
||||
*/
|
||||
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { bundle } from "@remotion/bundler";
|
||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||
|
||||
let bundleCache: Promise<string> | null = null;
|
||||
|
||||
/** Bundle des compositions, construit une fois par processus. */
|
||||
export function getRemotionBundle(): Promise<string> {
|
||||
if (!bundleCache) {
|
||||
// process.cwd() = /app dans Docker, racine du projet en dev
|
||||
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
|
||||
console.log("📦 [Remotion] Bundle de", entryPoint);
|
||||
bundleCache = bundle({
|
||||
entryPoint,
|
||||
// Mêmes alias que Vite : les compositions importent @shared/*
|
||||
webpackOverride: (config) => ({
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: {
|
||||
...(config.resolve?.alias ?? {}),
|
||||
"@shared": path.resolve(process.cwd(), "shared"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).catch((error) => {
|
||||
bundleCache = null; // réessayer au prochain rendu
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return bundleCache;
|
||||
}
|
||||
|
||||
/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */
|
||||
function renderConcurrency(): number {
|
||||
const configured = Number(process.env.RENDER_CONCURRENCY);
|
||||
if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
|
||||
return Math.max(1, Math.floor(os.cpus().length / 2));
|
||||
}
|
||||
|
||||
export async function renderReelComposition(options: {
|
||||
compositionId: "ReelVideo" | "ImageVideo";
|
||||
inputProps: Record<string, unknown>;
|
||||
outputLocation: string;
|
||||
onProgress?: (ratio: number) => void;
|
||||
}): Promise<void> {
|
||||
const serveUrl = await getRemotionBundle();
|
||||
const composition = await selectComposition({
|
||||
serveUrl,
|
||||
id: options.compositionId,
|
||||
inputProps: options.inputProps,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
await renderMedia({
|
||||
composition,
|
||||
serveUrl,
|
||||
inputProps: options.inputProps,
|
||||
outputLocation: options.outputLocation,
|
||||
codec: "h264",
|
||||
// Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok
|
||||
crf: 18,
|
||||
x264Preset: "medium",
|
||||
pixelFormat: "yuv420p",
|
||||
colorSpace: "bt709",
|
||||
audioCodec: "aac",
|
||||
audioBitrate: "192k",
|
||||
concurrency: renderConcurrency(),
|
||||
onProgress: ({ progress }) => options.onProgress?.(progress),
|
||||
// Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu
|
||||
timeoutInMilliseconds: 60_000,
|
||||
onBrowserLog: (log) => {
|
||||
if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300));
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication.
|
||||
* Exécuté par la file `reel_jobs` (voir queue.ts).
|
||||
*
|
||||
* Deux moteurs :
|
||||
* - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR,
|
||||
* stabilisation) et la piste son finale, Remotion compose les sous-titres
|
||||
* animés, le logo et l'effet de fin — le même rendu que l'aperçu ;
|
||||
* - « ffmpeg » : tout est fait par le service Python (sous-titres ASS),
|
||||
* plus rapide, conservé en secours (REEL_RENDERER=ffmpeg).
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
|
||||
import { storage } from "../../storage";
|
||||
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
|
||||
import { resolveInternalUrl } from "../minio";
|
||||
import type { JobContext } from "./queue";
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
|
||||
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
|
||||
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
|
||||
export type ReelRenderer = "remotion" | "ffmpeg";
|
||||
|
||||
export function configuredRenderer(): ReelRenderer {
|
||||
return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion";
|
||||
}
|
||||
|
||||
export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
if (!job.postId) throw new Error("Job vidéo sans post associé");
|
||||
const postId = job.postId;
|
||||
const params = videoReelParamsSchema.parse(job.params);
|
||||
|
||||
await progress(5, "prepare");
|
||||
const media = await storage.getMediaById(params.videoMediaId);
|
||||
if (!media || media.type !== "video") {
|
||||
throw new Error("Vidéo source introuvable ou invalide");
|
||||
}
|
||||
|
||||
const [musicUrl, logoPath, geminiApiKey] = await Promise.all([
|
||||
resolveMusicUrl(params.musicTrackId, params.musicUrl),
|
||||
resolveLogoPath(),
|
||||
resolveGeminiApiKey(params.ttsEngine),
|
||||
]);
|
||||
|
||||
const options: ReelRenderOptions = {
|
||||
text: params.srtCues ? undefined : params.overlayText,
|
||||
srtCues: params.srtCues,
|
||||
musicUrl,
|
||||
ttsEnabled: params.ttsEnabled,
|
||||
ttsVoice: params.ttsVoice,
|
||||
ttsEngine: params.ttsEngine,
|
||||
ttsStyle: params.ttsStyle,
|
||||
geminiApiKey,
|
||||
fontSize: params.fontSize,
|
||||
musicVolume: params.musicVolume,
|
||||
drawText: params.drawText,
|
||||
stabilize: params.stabilize,
|
||||
storeName: params.storeName,
|
||||
enableEndingEffect: params.enableEndingEffect,
|
||||
showLogo: params.showLogo,
|
||||
};
|
||||
|
||||
const renderer = configuredRenderer();
|
||||
const startedAt = Date.now();
|
||||
const videoBuffer =
|
||||
renderer === "remotion"
|
||||
? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress)
|
||||
: await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress);
|
||||
console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`);
|
||||
|
||||
await progress(88, "store");
|
||||
const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
|
||||
await storage.updatePostMedia(postId, [processedMedia.id]);
|
||||
|
||||
await progress(93, "publish");
|
||||
const results = await publishReelToPages({
|
||||
postId,
|
||||
pageIds: params.pageIds,
|
||||
videoBuffer,
|
||||
description: params.description || params.overlayText || "",
|
||||
scheduledFor: params.scheduledFor,
|
||||
});
|
||||
|
||||
if (!results.some((r) => r.success)) {
|
||||
throw new Error(describePublishFailure(results));
|
||||
}
|
||||
|
||||
return { mediaId: processedMedia.id, renderer, results };
|
||||
}
|
||||
|
||||
async function renderWithFfmpeg(
|
||||
videoUrl: string,
|
||||
options: ReelRenderOptions,
|
||||
logoPath: string | undefined,
|
||||
progress: JobContext["progress"],
|
||||
): Promise<Buffer> {
|
||||
await progress(15, "render");
|
||||
// Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux
|
||||
const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false);
|
||||
const rendered = await ffmpegService.renderReel(videoUrl, {
|
||||
...options,
|
||||
watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined,
|
||||
});
|
||||
return rendered.video;
|
||||
}
|
||||
|
||||
async function renderWithRemotion(
|
||||
jobId: string,
|
||||
videoUrl: string,
|
||||
options: ReelRenderOptions,
|
||||
params: VideoReelParams,
|
||||
logoPath: string | undefined,
|
||||
progress: JobContext["progress"],
|
||||
): Promise<Buffer> {
|
||||
const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`);
|
||||
try {
|
||||
// 1. Image recadrée à la durée finale, piste son finale, mots minutés
|
||||
await progress(10, "voice");
|
||||
const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir);
|
||||
|
||||
// 2. Composition : sous-titres animés, logo, effet de fin
|
||||
await progress(40, "render");
|
||||
const outputLocation = path.join(workdir, "reel.mp4");
|
||||
let lastReported = 40;
|
||||
await renderReelComposition({
|
||||
compositionId: "ReelVideo",
|
||||
outputLocation,
|
||||
inputProps: {
|
||||
videoUrl: tempFileUrl(prepared.videoPath),
|
||||
videoDuration: prepared.totalDuration,
|
||||
totalDuration: prepared.totalDuration,
|
||||
words: prepared.words,
|
||||
captionStyle: params.captionStyle,
|
||||
logoUrl: logoPath ? await toDataUrl(logoPath) : undefined,
|
||||
showWatermark: params.showLogo,
|
||||
storeName: params.enableEndingEffect ? params.storeName : undefined,
|
||||
logoStart: prepared.logoStart,
|
||||
fadeStart: prepared.fadeStart,
|
||||
fadeDuration: prepared.fadeDuration,
|
||||
mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined,
|
||||
},
|
||||
onProgress: (ratio) => {
|
||||
const pct = 40 + Math.floor(ratio * 45);
|
||||
if (pct >= lastReported + 5) {
|
||||
lastReported = pct;
|
||||
progress(pct, "render").catch(() => { /* non bloquant */ });
|
||||
}
|
||||
},
|
||||
});
|
||||
return await fs.promises.readFile(outputLocation);
|
||||
} finally {
|
||||
await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ });
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { minioService } from './minio';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||
|
||||
@@ -35,8 +35,10 @@ export async function generateVideoThumbnail(
|
||||
seekTime: number = 1
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
|
||||
await execAsync(cmd);
|
||||
// Arguments séparés : aucun passage par le shell, les chemins ne sont jamais interprétés
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y', '-ss', String(seekTime), '-i', videoPath, '-vframes', '1', '-q:v', '2', outputPath,
|
||||
]);
|
||||
return fs.existsSync(outputPath);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to generate video thumbnail:', error);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateVoiceTiming } from "./ttsSync";
|
||||
|
||||
describe("estimateVoiceTiming", () => {
|
||||
it("ignore hashtags et liens dans le décompte", () => {
|
||||
expect(estimateVoiceTiming("Venez vite #promo https://x.fr").wordCount).toBe(2);
|
||||
});
|
||||
|
||||
it("prévient quand la voix sera trop longue", () => {
|
||||
const timing = estimateVoiceTiming("mot ".repeat(200));
|
||||
expect(timing.isHealthy).toBe(false);
|
||||
expect(timing.warnings[0]).toMatch(/Texte long/);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import { ffmpegService } from './ffmpeg';
|
||||
import * as musicMetadata from 'music-metadata';
|
||||
/**
|
||||
* Estimation de la durée de lecture d'un texte, affichée pendant la saisie.
|
||||
*
|
||||
* Auparavant, chaque pause de frappe générait une voix complète pour la
|
||||
* mesurer (un appel Gemini facturé à chaque fois). Le minutage réel des mots
|
||||
* vient désormais de la voix au moment du rendu ; ici, une estimation suffit.
|
||||
*/
|
||||
|
||||
/** Débit moyen d'une voix de synthèse française, en mots par seconde. */
|
||||
const WORDS_PER_SECOND = 2.6;
|
||||
/** Au-delà, la voix dépasse la durée confortable d'un Reel. */
|
||||
const MAX_COMFORTABLE_SECONDS = 45;
|
||||
|
||||
export interface SyncTiming {
|
||||
wordDuration: number;
|
||||
@@ -10,62 +20,30 @@ export interface SyncTiming {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class TtsSyncService {
|
||||
/**
|
||||
* Calcule le word_duration optimal pour synchroniser l'affichage du texte
|
||||
* avec la durée réelle de la voix TTS générée.
|
||||
*/
|
||||
async calculateSyncTiming(text: string, voice: string, ttsEngine?: string): Promise<SyncTiming> {
|
||||
const cleanText = this.cleanText(text);
|
||||
export function estimateVoiceTiming(text: string): SyncTiming {
|
||||
const words = text
|
||||
.replace(/#[\wÀ-ÿ]+/g, '')
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
.split(/\s+/)
|
||||
.filter((w) => /[A-Za-z0-9À-ÿ]/.test(w));
|
||||
const pauses = (text.match(/[.!?;:]/g) ?? []).length;
|
||||
const punctuationPause = 0.35;
|
||||
const audioDuration = words.length / WORDS_PER_SECOND + pauses * punctuationPause;
|
||||
|
||||
// 1. Générer le TTS preview et mesurer sa durée exacte
|
||||
const ttsResult = await ffmpegService.previewTTS(cleanText, voice, ttsEngine);
|
||||
if (!ttsResult.success || !ttsResult.audioBase64) {
|
||||
throw new Error('TTS preview failed: ' + (ttsResult.error || 'unknown'));
|
||||
}
|
||||
|
||||
const audioBuffer = Buffer.from(ttsResult.audioBase64, 'base64');
|
||||
const metadata = await musicMetadata.parseBuffer(audioBuffer, 'audio/mpeg');
|
||||
const audioDuration = metadata.format.duration || 0;
|
||||
|
||||
// 2. Analyser le texte (compte les mots réellement lus par la voix)
|
||||
const wordCount = this.calculateWordCount(cleanText);
|
||||
|
||||
// 3. Calculer le word_duration
|
||||
const wordDuration = wordCount > 0 ? audioDuration / wordCount : 0.6;
|
||||
|
||||
// 4. Validation
|
||||
const warnings: string[] = [];
|
||||
const isHealthy = wordDuration >= 0.25 && wordDuration <= 1.5;
|
||||
if (wordDuration < 0.25) {
|
||||
warnings.push('Texte trop long : les mots défileront très vite. Envisagez de raccourcir.');
|
||||
}
|
||||
if (wordDuration > 1.2) {
|
||||
warnings.push('Texte très court : affichage lent.');
|
||||
}
|
||||
|
||||
return {
|
||||
wordDuration,
|
||||
audioDuration,
|
||||
wordCount,
|
||||
punctuationPause: 0,
|
||||
isHealthy,
|
||||
warnings,
|
||||
};
|
||||
const warnings: string[] = [];
|
||||
if (audioDuration > MAX_COMFORTABLE_SECONDS) {
|
||||
warnings.push(`Texte long : environ ${Math.round(audioDuration)} s de voix. Visez moins de ${MAX_COMFORTABLE_SECONDS} s.`);
|
||||
}
|
||||
if (words.length > 0 && words.length < 4) {
|
||||
warnings.push('Texte très court : la voix ne durera que quelques secondes.');
|
||||
}
|
||||
|
||||
private cleanText(text: string): string {
|
||||
return text
|
||||
.replace(/#\w+/g, '')
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
.replace(/[\uD83C-\uD83E][\uDC00-\uDFFF]|[☀-⛿✀-➿]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private calculateWordCount(text: string): number {
|
||||
const tokens = text.split(/\s+/).filter(w => w.length > 0);
|
||||
return tokens.filter(w => /[a-zA-Z0-9À-ſ]/.test(w)).length;
|
||||
}
|
||||
return {
|
||||
wordDuration: words.length ? audioDuration / words.length : 0,
|
||||
audioDuration,
|
||||
wordCount: words.length,
|
||||
punctuationPause,
|
||||
isHealthy: warnings.length === 0,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export const ttsSyncService = new TtsSyncService();
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
socialPages,
|
||||
media,
|
||||
posts,
|
||||
reelJobs,
|
||||
postMedia,
|
||||
scheduledPosts,
|
||||
aiGenerations,
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
type InsertFacebookConfig,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
|
||||
import { eq, and, or, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
|
||||
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
|
||||
|
||||
/**
|
||||
@@ -147,14 +148,12 @@ export interface IStorage {
|
||||
getPosts(userId: string): Promise<Post[]>;
|
||||
getPost(id: string): Promise<Post | undefined>;
|
||||
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
||||
getOngoingReelPosts(userId: string): Promise<Post[]>;
|
||||
getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>;
|
||||
createPost(post: InsertPost): Promise<Post>;
|
||||
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
||||
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
||||
deletePost(id: string): Promise<void>;
|
||||
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
|
||||
countProcessingReels(): Promise<number>;
|
||||
getNextPendingReel(): Promise<Post | undefined>;
|
||||
|
||||
// Scheduled Posts
|
||||
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
||||
@@ -459,24 +458,6 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async countProcessingReels(): Promise<number> {
|
||||
const processing = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(eq(posts.generationStatus, "processing"));
|
||||
return processing.length;
|
||||
}
|
||||
|
||||
async getNextPendingReel(): Promise<Post | undefined> {
|
||||
const [post] = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(eq(posts.generationStatus, "pending"))
|
||||
.orderBy(asc(posts.createdAt))
|
||||
.limit(1);
|
||||
return post || undefined;
|
||||
}
|
||||
|
||||
// Scheduled Posts
|
||||
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
|
||||
return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate);
|
||||
@@ -571,21 +552,34 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reels dont la génération est en cours. Filtré en SQL : la route chargeait
|
||||
* tous les posts de l'utilisateur pour n'en garder qu'une poignée, toutes les
|
||||
* trois secondes.
|
||||
* Reels dont la génération est en cours, plus ceux qui ont échoué dans les
|
||||
* dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape
|
||||
* courante du rendu. Filtré en SQL : la route est interrogée toutes les
|
||||
* trois secondes pendant un rendu.
|
||||
*/
|
||||
async getOngoingReelPosts(userId: string): Promise<Post[]> {
|
||||
return await db
|
||||
async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> {
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(
|
||||
and(
|
||||
eq(posts.userId, userId),
|
||||
inArray(posts.generationStatus, ['processing', 'pending'])
|
||||
or(
|
||||
inArray(posts.generationStatus, ['processing', 'pending']),
|
||||
and(eq(posts.generationStatus, 'failed'), gte(posts.updatedAt, since), isNotNull(posts.generationError)),
|
||||
),
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const jobs = await db
|
||||
.select({ postId: reelJobs.postId, step: reelJobs.step })
|
||||
.from(reelJobs)
|
||||
.where(inArray(reelJobs.postId, rows.map((p) => p.id)));
|
||||
const stepByPost = new Map(jobs.map((j) => [j.postId, j.step]));
|
||||
return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null }));
|
||||
}
|
||||
|
||||
// AI Generations
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
groupLines,
|
||||
offsetWords,
|
||||
spreadWords,
|
||||
} from "./captions";
|
||||
|
||||
// Mêmes cas que ffmpeg-service/tests/test_render.py : l'aperçu et le rendu
|
||||
// doivent tomber sur les mêmes durées.
|
||||
describe("computeReelTiming", () => {
|
||||
it("allonge la vidéo quand la voix dépasse", () => {
|
||||
expect(computeReelTiming({ videoDuration: 5, voiceDuration: 10, hasOutro: false, endingEffect: true }).total).toBe(12.8);
|
||||
});
|
||||
|
||||
it("réserve l'effet de fin après la voix", () => {
|
||||
const timing = computeReelTiming({ videoDuration: 6, voiceDuration: 4.8, hasOutro: true, endingEffect: true });
|
||||
expect(timing.total).toBe(9.3);
|
||||
expect(timing.logoStart).toBeCloseTo(6.8);
|
||||
expect(timing.fadeStart).toBeCloseTo(7.3);
|
||||
});
|
||||
|
||||
it("n'a ni logo ni fondu sans effet de fin", () => {
|
||||
const timing = computeReelTiming({ videoDuration: 12, hasOutro: true, endingEffect: false });
|
||||
expect(timing).toEqual({ total: 12, logoStart: null, fadeStart: null, fadeDuration: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fondu final", () => {
|
||||
it("ne commence jamais avant la fin de la voix (mêmes valeurs que Python)", () => {
|
||||
const timing = computeReelTiming({ videoDuration: 4, voiceDuration: 5, hasOutro: false, endingEffect: true });
|
||||
expect(timing.total).toBe(7.8);
|
||||
expect(timing.fadeStart).toBeGreaterThanOrEqual(7);
|
||||
expect(timing.fadeDuration).toBeGreaterThanOrEqual(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeImagesTiming", () => {
|
||||
it("garde un Reel d'images entre 25 et 30 s", () => {
|
||||
expect(computeImagesTiming({ imageCount: 3, hasEnding: true })).toEqual({ total: 25, endingSeconds: 3 });
|
||||
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 40, hasEnding: true }).total).toBe(30);
|
||||
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 24, hasEnding: false }).total).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupLines", () => {
|
||||
const words = (texts: string[]) => texts.map((text, i) => ({ text, start: i, end: i + 0.5 }));
|
||||
|
||||
it("coupe à la ponctuation et limite chaque ligne", () => {
|
||||
const lines = groupLines(words(["Bonjour,", "venez", "découvrir", "nos", "nouveautés"]));
|
||||
expect(lines[0].map((w) => w.text)).toEqual(["Bonjour,"]);
|
||||
expect(lines.every((line) => line.length <= 3)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("texte des sous-titres", () => {
|
||||
it("retire hashtags, emojis et liens", () => {
|
||||
expect(cleanCaptionText("Promo 🌸 #AménagementExtérieur ici https://x.fr !")).toBe("Promo ici !");
|
||||
});
|
||||
|
||||
it("répartit l'estimation sur l'intervalle", () => {
|
||||
const spread = spreadWords("un deux trois", 2, 5);
|
||||
expect(spread[0].start).toBe(2);
|
||||
expect(spread[spread.length - 1].end).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it("décale les mots de la voix vers le temps de la vidéo", () => {
|
||||
expect(offsetWords([{ text: "a", start: 0.1, end: 0.4 }], 2)).toEqual([{ text: "a", start: 2.1, end: 2.4 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Sous-titres et minutage des Reels, partagés entre l'aperçu (navigateur),
|
||||
* le rendu Remotion (serveur) et les tests.
|
||||
* Les règles de durée reprennent celles du service Python (app/render.py).
|
||||
*/
|
||||
|
||||
export const CAPTION_STYLES = ["bold", "neon", "minimal"] as const;
|
||||
export type CaptionStyle = (typeof CAPTION_STYLES)[number];
|
||||
|
||||
export const CAPTION_STYLE_OPTIONS: { id: CaptionStyle; label: string; description: string }[] = [
|
||||
{ id: "bold", label: "Impact", description: "Majuscules, mot prononcé en jaune" },
|
||||
{ id: "neon", label: "Surligné", description: "Mot prononcé sur fond coloré" },
|
||||
{ id: "minimal", label: "Épuré", description: "Texte qui se révèle au fil de la voix" },
|
||||
];
|
||||
|
||||
export const DEFAULT_CAPTION_STYLE: CaptionStyle = "bold";
|
||||
|
||||
/** Mot affiché et son intervalle, en secondes depuis le début de la vidéo. */
|
||||
export interface TimedWord {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export const REEL_FPS = 30;
|
||||
export const REEL_WIDTH = 1080;
|
||||
export const REEL_HEIGHT = 1920;
|
||||
|
||||
/** La voix démarre après ce délai (le temps de capter l'attention). */
|
||||
export const VOICE_DELAY = 2;
|
||||
const VOICE_TAIL = 0.8;
|
||||
const OUTRO_MIN = 2.5;
|
||||
const LOGO_SECONDS = 5;
|
||||
export const FADE_SECONDS = 2;
|
||||
/** Fondu raccourci quand la voix finit tard, mais jamais sec. */
|
||||
const MIN_FADE = 0.3;
|
||||
|
||||
export interface ReelTiming {
|
||||
total: number;
|
||||
/** Début de l'effet de fin (grand logo), ou null sans effet de fin. */
|
||||
logoStart: number | null;
|
||||
fadeStart: number | null;
|
||||
fadeDuration: number | null;
|
||||
}
|
||||
|
||||
/** Durée finale : la vidéo s'allonge (dernière image figée) si la voix dépasse. */
|
||||
export function computeReelTiming(input: {
|
||||
videoDuration: number;
|
||||
voiceDuration?: number;
|
||||
hasOutro: boolean;
|
||||
endingEffect: boolean;
|
||||
/** 0 pour une voix déjà posée aux instants d'un fichier SRT. */
|
||||
voiceDelay?: number;
|
||||
}): ReelTiming {
|
||||
const hasOutro = input.hasOutro && input.endingEffect;
|
||||
const speechEnd = input.voiceDuration ? (input.voiceDelay ?? VOICE_DELAY) + input.voiceDuration : 0;
|
||||
let total = input.videoDuration;
|
||||
if (input.voiceDuration) {
|
||||
total = Math.max(total, speechEnd + VOICE_TAIL);
|
||||
if (hasOutro) total = Math.max(total, speechEnd + OUTRO_MIN);
|
||||
}
|
||||
total = Math.round(total * 1000) / 1000;
|
||||
// Le fondu final ne commence jamais avant la fin de la voix
|
||||
let fadeStart = Math.max(0, total - FADE_SECONDS);
|
||||
if (input.voiceDuration) fadeStart = Math.max(fadeStart, Math.min(speechEnd + 0.15, total - MIN_FADE));
|
||||
fadeStart = Math.round(fadeStart * 1000) / 1000;
|
||||
return {
|
||||
total,
|
||||
logoStart: hasOutro ? Math.max(0, total - LOGO_SECONDS, speechEnd) : null,
|
||||
fadeStart: input.endingEffect ? fadeStart : null,
|
||||
fadeDuration: input.endingEffect ? Math.max(MIN_FADE, total - fadeStart) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_WORDS_PER_LINE = 3;
|
||||
const MAX_CHARS_PER_LINE = 18;
|
||||
const LINE_BREAK = /[.!?,:;…]$/;
|
||||
|
||||
/** Découpe en lignes courtes, coupées de préférence à la ponctuation. */
|
||||
export function groupLines(words: TimedWord[]): TimedWord[][] {
|
||||
const lines: TimedWord[][] = [];
|
||||
let current: TimedWord[] = [];
|
||||
for (const word of words) {
|
||||
const chars = current.reduce((n, w) => n + w.text.length + 1, 0) + word.text.length;
|
||||
if (current.length && (current.length >= MAX_WORDS_PER_LINE || chars > MAX_CHARS_PER_LINE)) {
|
||||
lines.push(current);
|
||||
current = [];
|
||||
}
|
||||
current.push(word);
|
||||
if (LINE_BREAK.test(word.text)) {
|
||||
lines.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Retire emojis, hashtags et liens : ce qui n'est ni lu ni affiché. */
|
||||
export function cleanCaptionText(text: string): string {
|
||||
return text
|
||||
.replace(/https?:\/\/\S+/g, " ")
|
||||
.replace(/#[\wÀ-ÿ]+/g, " ")
|
||||
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, " ")
|
||||
.replace(/[☀-➿⬀-⯿️]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Répartit un texte sur un intervalle au prorata de la longueur des mots.
|
||||
* Sert d'aperçu tant que la voix n'a pas été générée.
|
||||
*/
|
||||
export function spreadWords(text: string, start: number, end: number): TimedWord[] {
|
||||
const tokens = cleanCaptionText(text).split(" ").filter(Boolean);
|
||||
if (!tokens.length || end <= start) return [];
|
||||
const weights = tokens.map((t) => Math.max(1, t.replace(/[^\wÀ-ÿ]/g, "").length));
|
||||
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||
let cursor = start;
|
||||
return tokens.map((text, i) => {
|
||||
const duration = ((end - start) * weights[i]) / totalWeight;
|
||||
const word = { text, start: cursor, end: cursor + duration };
|
||||
cursor += duration;
|
||||
return word;
|
||||
});
|
||||
}
|
||||
|
||||
/** Décale des mots minutés depuis le début de la voix vers le temps de la vidéo. */
|
||||
export function offsetWords(words: TimedWord[], offset: number): TimedWord[] {
|
||||
return words.map((w) => ({ ...w, start: w.start + offset, end: w.end + offset }));
|
||||
}
|
||||
|
||||
const IMAGES_ENDING_SECONDS = 3;
|
||||
const IMAGES_MIN_CONTENT = 22;
|
||||
const IMAGES_MAX_CONTENT = 27;
|
||||
const SECONDS_PER_IMAGE = 3;
|
||||
|
||||
/** Reel d'images : 25 à 30 s, diapositive de fin comprise. */
|
||||
export function computeImagesTiming(input: { imageCount: number; voiceDuration?: number; hasEnding: boolean }): {
|
||||
total: number;
|
||||
endingSeconds: number;
|
||||
} {
|
||||
const natural = Math.max((input.voiceDuration ?? 0) + 1, input.imageCount * SECONDS_PER_IMAGE);
|
||||
const content = Math.min(Math.max(natural, IMAGES_MIN_CONTENT), IMAGES_MAX_CONTENT);
|
||||
const endingSeconds = input.hasEnding ? IMAGES_ENDING_SECONDS : 0;
|
||||
return { total: content + endingSeconds, endingSeconds };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { imagesReelParamsSchema, ttsPreviewSchema, videoReelParamsSchema } from "./reel";
|
||||
|
||||
describe("videoReelParamsSchema", () => {
|
||||
it("exige une vidéo et une page, avec des messages lisibles", () => {
|
||||
const result = videoReelParamsSchema.safeParse({ pageIds: [] });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((i) => i.message)).toContain("Vidéo requise");
|
||||
});
|
||||
|
||||
it("applique les valeurs par défaut", () => {
|
||||
const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] });
|
||||
expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true });
|
||||
});
|
||||
|
||||
it("accepte les anciens paramètres encore stockés dans des jobs", () => {
|
||||
expect(videoReelParamsSchema.safeParse({ videoMediaId: "v", pageIds: ["p"], wordDuration: 0.6 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("refuse un moteur de voix inconnu", () => {
|
||||
expect(videoReelParamsSchema.safeParse({ videoMediaId: "v", pageIds: ["p"], ttsEngine: "autre" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("imagesReelParamsSchema", () => {
|
||||
it("limite à 4 images", () => {
|
||||
expect(imagesReelParamsSchema.safeParse({ imageUrls: ["a", "b", "c", "d", "e"] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ttsPreviewSchema", () => {
|
||||
it("refuse un texte vide", () => {
|
||||
expect(ttsPreviewSchema.safeParse({ text: " " }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Paramètres des rendus de Reels, validés à l'entrée des routes et stockés tels
|
||||
* quels dans `reel_jobs.params` : un job repris après redémarrage retrouve ainsi
|
||||
* exactement ce que l'utilisateur avait demandé.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { CAPTION_STYLES, DEFAULT_CAPTION_STYLE } from "./captions";
|
||||
import { srtCuesSchema } from "./srt";
|
||||
import { TTS_ENGINES, TTS_STYLES } from "./voices";
|
||||
|
||||
const optionalText = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((value) => (value ? value : undefined));
|
||||
|
||||
/** Reel construit à partir d'une vidéo de la médiathèque. */
|
||||
export const videoReelParamsSchema = z.object({
|
||||
videoMediaId: z.string({ required_error: "Vidéo requise" }).min(1, "Vidéo requise"),
|
||||
pageIds: z
|
||||
.array(z.string().min(1), { required_error: "Au moins une page requise" })
|
||||
.min(1, "Au moins une page requise"),
|
||||
musicTrackId: optionalText,
|
||||
musicUrl: optionalText,
|
||||
overlayText: optionalText,
|
||||
// Sous-titres SRT : remplacent le texte libre, la voix lit chacun à son instant
|
||||
srtCues: srtCuesSchema.optional(),
|
||||
description: optionalText,
|
||||
ttsEnabled: z.boolean().default(false),
|
||||
ttsVoice: optionalText,
|
||||
ttsEngine: z.enum(TTS_ENGINES).optional(),
|
||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||
scheduledFor: optionalText,
|
||||
// Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même
|
||||
wordDuration: z.number().optional(),
|
||||
fontSize: z.number().int().min(16).max(200).default(64),
|
||||
musicVolume: z.number().min(0).max(2).default(0.25),
|
||||
drawText: z.boolean().default(true),
|
||||
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
|
||||
stabilize: z.boolean().default(false),
|
||||
// Petit logo pendant la vidéo ; le grand logo de fin dépend de enableEndingEffect
|
||||
showLogo: z.boolean().default(true),
|
||||
enableEndingEffect: z.boolean().default(true),
|
||||
// Déterminé par le serveur à partir de la première page, jamais par le client
|
||||
storeName: z.string().optional(),
|
||||
});
|
||||
|
||||
export type VideoReelParams = z.infer<typeof videoReelParamsSchema>;
|
||||
|
||||
/** Reel construit à partir d'images (rendu Remotion). */
|
||||
export const imagesReelParamsSchema = z.object({
|
||||
// Chemins relatifs /uploads/... ou URL absolues
|
||||
imageUrls: z.array(z.string().min(1)).min(1, "Aucune image fournie").max(4),
|
||||
overlayText: optionalText,
|
||||
musicUrl: optionalText,
|
||||
musicVolume: z.number().min(0).max(2).default(0.3),
|
||||
ttsEnabled: z.boolean().default(true),
|
||||
ttsEngine: z.enum(TTS_ENGINES).optional(),
|
||||
ttsVoice: optionalText,
|
||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
|
||||
showLogo: z.boolean().default(true),
|
||||
storeName: z.string().optional(),
|
||||
// Fichiers temporaires à supprimer une fois le rendu terminé
|
||||
tempFiles: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** Aperçu de la voix. */
|
||||
export const ttsPreviewSchema = z.object({
|
||||
text: z.string({ required_error: "Texte requis" }).trim().min(1, "Texte requis").max(2000),
|
||||
ttsVoice: optionalText,
|
||||
ttsEngine: z.enum(TTS_ENGINES).optional(),
|
||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||
});
|
||||
|
||||
export type ImagesReelParams = z.infer<typeof imagesReelParamsSchema>;
|
||||
|
||||
export type ReelJobKind = "video" | "images";
|
||||
|
||||
export type ReelJobStatus = "pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** Résultat d'un rendu d'images, consulté par le client pour l'aperçu. */
|
||||
export interface ImagesReelResult {
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||