feat(reels): montage Remotion unifié, aperçu en direct, tests et CI

Montage (étape 3) :
- Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles :
  Impact, Surligné, Épuré), logo, effet de fin et fondu, en React
- Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière
  image figée) et la piste son finale (/prepare-reel) ; Remotion compose
- Reels d'images sur les mêmes composants, avec le vrai minutage de la voix
- Police Montserrat embarquée (plus de dépendance à Google Fonts)
- REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours
- Script de pré-bundle réparé (échouait en silence : require en ESM)

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
Claude committed 2026-09-24 14:37:42 +00:00
1 parent 3c6a66bf64
commit 9b66a6dc5a
64 files changed
+2481 -775

No files matched your search

+10
View File
@@ -124,3 +124,13 @@ GEMINI_API_KEY=
# (tiny, base, small). Choisi à la construction de l'image : changer la valeur # (tiny, base, small). Choisi à la construction de l'image : changer la valeur
# demande de reconstruire ffmpeg-api. # demande de reconstruire ffmpeg-api.
# WHISPER_MODEL=base # WHISPER_MODEL=base
#
# Moteur de rendu des Reels vidéo :
# - remotion (défaut) : sous-titres animés, logo et effet de fin identiques à
# l'aperçu affiché dans l'application ; ~30 à 60 s pour un Reel de 30 s ;
# - ffmpeg : plus rapide (~10 s), sous-titres karaoké classiques.
# REEL_RENDERER=remotion
#
# Onglets Chromium utilisés en parallèle par Remotion (défaut : moitié des cœurs).
# Réduisez-le si le serveur manque de mémoire.
# RENDER_CONCURRENCY=
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
app:
name: Application (TypeScript)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# Les dépendances natives (canvas, sharp) et le navigateur Remotion ne
# servent pas aux vérifications : on saute leurs scripts d'installation.
- run: npm ci --ignore-scripts
- run: npm run check
- run: npm test
- run: npm run build
ffmpeg-service:
name: Service vidéo (Python)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ffmpeg-service
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: ffmpeg-service/requirements*.txt
- run: pip install -r requirements-dev.txt
- run: ruff check .
- run: ruff format --check .
- run: pytest -q
+4
View File
@@ -182,6 +182,10 @@ La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
- **Voix Gemini** : renseignez `GEMINI_API_KEY` (ou la clé dans l'application). - **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 Sans clé, la voix Edge gratuite est utilisée et un avertissement apparaît
dans les logs. 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` - **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 affichent `[ReelQueue] Worker démarré`, et `socialflow-ffmpeg` répond
`{"status":"ok","version":2}` sur `/health`. `{"status":"ok","version":2}` sur `/health`.
+1 -1
View File
@@ -70,7 +70,7 @@ RUN rm -rf client/node_modules \
/tmp/remotion-* /tmp/remotion-*
# Pré-bundler la composition Remotion # Pré-bundler la composition Remotion
RUN node scripts/prebundle-remotion.js || true RUN node scripts/prebundle-remotion.mjs || true
# Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian) # Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian)
RUN npx remotion browser ensure && echo "✓ Remotion browser prêt" RUN npx remotion browser ensure && echo "✓ Remotion browser prêt"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

BIN
View File
Binary file not shown.
+25 -16
View File
@@ -4,16 +4,20 @@ import { Progress } from "@/components/ui/progress";
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react"; import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
import type { Post } from "@shared/schema"; import type { Post } from "@shared/schema";
/** Labels for each progress stage */ type OngoingReel = Post & { generationStep?: string | null };
function getStageLabel(progress: number, status?: string): string {
if (status === 'pending') return "En attente de traitement..."; /** Libellé de l'étape en cours, telle que rapportée par la file de rendu. */
if (progress <= 5) return "Démarrage du traitement…"; const STEP_LABELS: Record<string, string> = {
if (progress <= 15) return "Traitement vidéo (FFmpeg)…"; prepare: "Préparation…",
if (progress <= 65) return "Encodage en cours…"; voice: "Voix et préparation de la vidéo…",
if (progress <= 85) return "Upload vers le cloud…"; render: "Montage : sous-titres, logo, effets…",
if (progress <= 90) return "Liaison du média…"; store: "Enregistrement dans la médiathèque…",
if (progress <= 95) return "Publication en cours…"; publish: "Publication…",
return "Finalisation…"; };
function getStageLabel(post: OngoingReel): string {
if (post.generationStatus === "pending") return "En attente : un autre Reel est en cours…";
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
} }
interface OngoingReelsProps { interface OngoingReelsProps {
@@ -22,12 +26,13 @@ interface OngoingReelsProps {
} }
export default function OngoingReels({ compact = false }: OngoingReelsProps) { export default function OngoingReels({ compact = false }: OngoingReelsProps) {
const { data: ongoingPosts = [] } = useQuery<Post[]>({ const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
queryKey: ["/api/reels/ongoing"], queryKey: ["/api/reels/ongoing"],
// Cette route ne renvoie que les générations en cours : tant qu'elle est // 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 // 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. // 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) // 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" : ""}> <CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}> <CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
<Loader2 className="w-5 h-5 animate-spin text-primary" /> <Loader2 className="w-5 h-5 animate-spin text-primary" />
Génération en cours ) : (
<XCircle className="w-5 h-5 text-destructive" />
)}
Reels en cours
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}> <CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
@@ -73,8 +82,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
{isFailed ? ( {isFailed ? (
<div className="flex items-center gap-1.5 mt-2 text-destructive"> <div className="flex items-center gap-1.5 mt-2 text-destructive">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span className="text-xs"> <span className="text-xs break-words">
{post.generationError || "Erreur lors du traitement"} Échec : {post.generationError || "erreur lors du traitement"}
</span> </span>
</div> </div>
) : ( ) : (
@@ -87,7 +96,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
</div> </div>
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{getStageLabel(progress)} {getStageLabel(post)}
</span> </span>
<span className="text-xs font-medium text-primary"> <span className="text-xs font-medium text-primary">
{progress}% {progress}%
@@ -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,180 @@
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 { 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;
storeName?: string;
endingEffect: boolean;
}
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 } = props;
const logoUrl = props.logoUrl ?? undefined;
const storeName = endingEffect ? props.storeName || undefined : undefined;
const cleanText = cleanCaptionText(text);
const voiceDuration = ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0;
const estimated = 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,
storeName,
endingSeconds,
};
return { component: ImageComposition, inputProps, total };
}
if (videoDuration == null) return null;
const timing = computeReelTiming({
videoDuration,
voiceDuration: voiceDuration || undefined,
hasOutro: Boolean(logoUrl),
endingEffect,
});
let words: TimedWord[] = [];
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,
storeName,
logoStart: timing.logoStart,
fadeStart: timing.fadeStart,
voiceUrl: 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, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
musicUrl, musicVolume, logoUrl, 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" />
{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>
);
}
+35 -3
View File
@@ -20,6 +20,7 @@ import {
type TtsEngine, type TtsEngine,
type TtsStyle, type TtsStyle,
} from "@shared/voices"; } from "@shared/voices";
import type { TimedWord } from "@shared/captions";
export interface VoiceSettings { export interface VoiceSettings {
engine: TtsEngine; engine: TtsEngine;
@@ -27,12 +28,38 @@ export interface VoiceSettings {
style: TtsStyle; 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 { interface VoicePickerProps {
value: VoiceSettings; value: VoiceSettings;
onChange: (value: VoiceSettings) => void; onChange: (value: VoiceSettings) => void;
/** Texte lu par le bouton « Tester la voix ». */ /** Texte lu par le bouton « Tester la voix ». */
sampleText?: string; sampleText?: string;
compact?: boolean; 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 !"; const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !";
@@ -41,7 +68,7 @@ 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. * 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). * Partagé par les pages Reel (vidéo et images, bureau et mobile).
*/ */
export function VoicePicker({ value, onChange, sampleText, compact = false }: VoicePickerProps) { export function VoicePicker({ value, onChange, sampleText, compact = false, onPreview }: VoicePickerProps) {
const { toast } = useToast(); const { toast } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
@@ -68,8 +95,9 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo
if (playing) return stop(); if (playing) return stop();
setLoading(true); setLoading(true);
try { try {
const text = sampleText?.trim() || FALLBACK_SAMPLE;
const response = await apiRequest("POST", "/api/reels/tts-preview", { const response = await apiRequest("POST", "/api/reels/tts-preview", {
text: sampleText?.trim() || FALLBACK_SAMPLE, text,
ttsEngine: value.engine, ttsEngine: value.engine,
ttsVoice: value.voice, ttsVoice: value.voice,
ttsStyle: value.style, ttsStyle: value.style,
@@ -78,7 +106,11 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo
for (const warning of data.warnings ?? []) { for (const warning of data.warnings ?? []) {
toast({ title: "Voix de secours utilisée", description: warning }); toast({ title: "Voix de secours utilisée", description: warning });
} }
const audio = new Audio(`data:audio/mpeg;base64,${data.audioBase64}`); 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?.pause();
audioRef.current = audio; audioRef.current = audio;
audio.onended = () => setPlaying(false); audio.onended = () => setPlaying(false);
+43 -3
View File
@@ -16,7 +16,10 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker"; 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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
@@ -67,6 +70,10 @@ export default function MobileNewReel() {
style: DEFAULT_TTS_STYLE, style: DEFAULT_TTS_STYLE,
}); });
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings; 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 // État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null); const [isPlaying, setIsPlaying] = useState<string | null>(null);
@@ -203,8 +210,10 @@ export default function MobileNewReel() {
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({ toast({
title: data.success ? "Reel créé !" : "Attention", title: "Création du Reel lancée",
description: data.success ? "Publication réussie" : "Certaines erreurs", description: data.queued
? "Un autre Reel est en cours : le vôtre démarrera juste après."
: "Suivez l'avancement sur l'accueil.",
}); });
navigate('/'); navigate('/');
}, },
@@ -264,6 +273,7 @@ export default function MobileNewReel() {
ttsEngine, ttsEngine,
ttsVoice, ttsVoice,
ttsStyle, ttsStyle,
captionStyle,
enableEndingEffect, enableEndingEffect,
}); });
}; };
@@ -456,6 +466,10 @@ export default function MobileNewReel() {
className="text-lg" className="text-lg"
rows={3} 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"> <div className="flex items-center space-x-2 mt-4">
<Switch <Switch
id="enable-ending-effect" id="enable-ending-effect"
@@ -490,6 +504,7 @@ export default function MobileNewReel() {
value={voiceSettings} value={voiceSettings}
onChange={setVoiceSettings} onChange={setVoiceSettings}
sampleText={overlayText} sampleText={overlayText}
onPreview={setVoicePreview}
compact compact
/> />
</div> </div>
@@ -524,6 +539,31 @@ export default function MobileNewReel() {
{/* PUBLISH STEP */} {/* PUBLISH STEP */}
{currentStep === 'publish' && ( {currentStep === 'publish' && (
<div className="space-y-6"> <div className="space-y-6">
{selectedVideo && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[260px] mx-auto">
<ReelPreview
kind="video"
videoUrl={selectedVideo.originalUrl}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={selectedTrack?.previewUrl}
musicVolume={musicVolume[0] / 100}
logoUrl={reelConfig?.logoUrl}
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
endingEffect={enableEndingEffect}
/>
</div>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Diffusion</CardTitle> <CardTitle className="text-base">Diffusion</CardTitle>
+53 -4
View File
@@ -1,6 +1,9 @@
import { useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker"; 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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -50,6 +53,11 @@ export default function MobileRemotionVideoPage() {
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined); const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [publishDescription, setPublishDescription] = useState(""); const [publishDescription, setPublishDescription] = useState("");
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const [renderProgress, setRenderProgress] = useState(0);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null); const galleryInputRef = useRef<HTMLInputElement>(null);
@@ -59,6 +67,13 @@ export default function MobileRemotionVideoPage() {
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] }); const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] }); const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
const facebookPages = socialPages.filter(p => p.platform === 'facebook'); const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok'); const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
@@ -142,6 +157,7 @@ export default function MobileRemotionVideoPage() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; } if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
setIsRendering(true); setIsRendering(true);
setRenderProgress(0);
setVideoUrl(null); setVideoUrl(null);
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -152,6 +168,7 @@ export default function MobileRemotionVideoPage() {
formData.append("ttsEngine", voiceSettings.engine); formData.append("ttsEngine", voiceSettings.engine);
formData.append("ttsVoice", voiceSettings.voice); formData.append("ttsVoice", voiceSettings.voice);
formData.append("ttsStyle", voiceSettings.style); formData.append("ttsStyle", voiceSettings.style);
formData.append("captionStyle", captionStyle);
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
@@ -180,6 +197,7 @@ export default function MobileRemotionVideoPage() {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" }); toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false); setIsRendering(false);
} else { } else {
setRenderProgress(job.progress ?? 0);
setTimeout(checkStatus, 3000); setTimeout(checkStatus, 3000);
} }
} catch (e: any) { } catch (e: any) {
@@ -328,6 +346,10 @@ export default function MobileRemotionVideoPage() {
)} )}
<Textarea placeholder="Texte overlay…" value={overlayText} <Textarea placeholder="Texte overlay…" value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" /> onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
<div className="space-y-1.5">
<Label className="text-xs font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} /> <Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label> <Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
@@ -336,7 +358,7 @@ export default function MobileRemotionVideoPage() {
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground">TTS — voix activée</p> <p className="text-xs text-muted-foreground">TTS — voix activée</p>
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} compact /> <VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -433,10 +455,37 @@ export default function MobileRemotionVideoPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Aperçu en direct */}
{previewImages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[260px] mx-auto">
<ReelPreview
kind="images"
images={previewImages}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={musicFileUrl ?? selectedTrack?.url}
musicVolume={musicVolume}
logoUrl={reelConfig?.logoUrl}
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
endingEffect
/>
</div>
</CardContent>
</Card>
)}
{/* Générer */} {/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg"> <Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
{isRendering {isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération (1-2 min)…</> ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>} : <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
</Button> </Button>
+37 -138
View File
@@ -25,7 +25,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker"; 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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
@@ -89,6 +92,11 @@ export default function NewReel() {
style: DEFAULT_TTS_STYLE, style: DEFAULT_TTS_STYLE,
}); });
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings; 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 // Enable TTS by default on mobile
useEffect(() => { useEffect(() => {
@@ -245,10 +253,10 @@ export default function NewReel() {
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({ toast({
title: data.success ? "Reel créé !" : "Reel créé avec avertissements", title: "Création du Reel lancée",
description: data.success description: data.queued
? "Votre Reel a été publié avec succès" ? "Un autre Reel est en cours : le vôtre démarrera juste après. Suivez l'avancement sur le tableau de bord."
: "Certaines pages ont échoué", : "Suivez l'avancement sur le tableau de bord.",
}); });
navigate('/'); navigate('/');
}, },
@@ -406,6 +414,7 @@ export default function NewReel() {
ttsEngine, ttsEngine,
ttsVoice, ttsVoice,
ttsStyle, ttsStyle,
captionStyle,
drawText, drawText,
stabilize: stabilize, stabilize: stabilize,
enableEndingEffect, enableEndingEffect,
@@ -783,6 +792,15 @@ export default function NewReel() {
</Label> </Label>
</div> </div>
{drawText && (
<div className="mt-3 ml-12">
<Label className="text-sm font-medium">Style des sous-titres</Label>
<div className="mt-2">
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
</div>
</div>
)}
<div className="flex items-center space-x-2 mt-4"> <div className="flex items-center space-x-2 mt-4">
<Switch <Switch
id="enable-ending-effect" id="enable-ending-effect"
@@ -817,6 +835,7 @@ export default function NewReel() {
value={voiceSettings} value={voiceSettings}
onChange={setVoiceSettings} onChange={setVoiceSettings}
sampleText={overlayText} sampleText={overlayText}
onPreview={setVoicePreview}
/> />
</div> </div>
@@ -962,31 +981,20 @@ export default function NewReel() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{selectedVideo ? ( {selectedVideo ? (
<div className="relative aspect-[9/16] bg-black rounded-lg overflow-hidden"> <ReelPreview
<video kind="video"
src={selectedVideo.originalUrl} videoUrl={selectedVideo.originalUrl}
className="w-full h-full object-cover" text={overlayText}
muted showCaptions={drawText}
playsInline captionStyle={captionStyle}
loop ttsEnabled={ttsEnabled}
autoPlay voice={currentVoice}
musicUrl={selectedTrack?.previewUrl}
musicVolume={musicVolume[0] / 100}
logoUrl={reelConfig?.logoUrl}
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
endingEffect={enableEndingEffect}
/> />
{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>
) : ( ) : (
<div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center"> <div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
@@ -1026,115 +1034,6 @@ export default function NewReel() {
</div > </div >
</div > </div >
</main > </main >
{/* Overlay de progression */}
< ProcessingOverlay
isVisible={createReelMutation.isPending}
stabilize={stabilize}
ttsEnabled={ttsEnabled}
/>
</div > </div >
); );
} }
function ProcessingOverlay({ isVisible, stabilize, ttsEnabled }: { isVisible: boolean; stabilize: boolean; ttsEnabled: boolean }) {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("Initialisation...");
useEffect(() => {
if (!isVisible) {
setProgress(0);
return;
}
setProgress(0);
setStatus("Préparation des fichiers...");
const timeouts: any[] = [];
let interval: any;
// Sequence de simulation
// 1. 2s: Téléchargement
timeouts.push(setTimeout(() => {
setStatus("Téléchargement des médias...");
setProgress(10);
}, 1500));
// 2. 4s: Audio/TTS
timeouts.push(setTimeout(() => {
setStatus(ttsEnabled ? "Génération de la voix IA..." : "Mixage audio...");
setProgress(25);
}, 3500));
// 3. 7s: Stabilisation ou Encodage
timeouts.push(setTimeout(() => {
if (stabilize) {
setStatus("Stabilisation vidéo (Traitement long)...");
setProgress(35);
// Progression lente : +1% toutes les 800ms
// Ça permet de couvrir ~45 secondes avant d'arriver à 90%
let p = 35;
interval = setInterval(() => {
if (p < 90) {
p++;
setProgress(p);
}
}, 800);
} else {
setStatus("Encodage optimisé...");
setProgress(40);
// Progression pour encodage standard (~10-15s)
let p = 40;
interval = setInterval(() => {
if (p < 90) {
p += 2;
setProgress(p);
}
}, 500);
}
}, 6500));
// Nettoyage
return () => {
timeouts.forEach(clearTimeout);
if (interval) clearInterval(interval);
};
}, [isVisible, stabilize, ttsEnabled]);
if (!isVisible) return null;
return (
<div className="fixed inset-0 z-[60] bg-black/90 flex flex-col items-center justify-center p-4 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-8 text-center">
<div className="relative w-24 h-24 mx-auto mb-8">
<Loader2 className="w-full h-full text-primary animate-spin" />
<div className="absolute inset-0 flex items-center justify-center font-bold text-xl">
{progress}%
</div>
</div>
<div className="space-y-4">
<h3 className="text-2xl font-bold text-white tracking-tight">Création de votre Reel</h3>
<p className="text-lg text-muted-foreground animate-pulse">
{status}
</p>
</div>
<div className="w-full h-2 bg-secondary/30 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{stabilize && (
<div className="flex items-center justify-center gap-2 text-sm text-yellow-500/80 bg-yellow-500/10 py-2 px-4 rounded-full mx-auto w-fit">
<Sparkles className="w-4 h-4" />
<span>Stabilisation activée : traitement prolongé</span>
</div>
)}
</div>
</div>
);
}
+53 -4
View File
@@ -1,8 +1,11 @@
import { useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker"; 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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -51,6 +54,11 @@ export default function RemotionVideoPage() {
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined); const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [publishDescription, setPublishDescription] = useState(""); const [publishDescription, setPublishDescription] = useState("");
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const [renderProgress, setRenderProgress] = useState(0);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const { toast } = useToast(); const { toast } = useToast();
@@ -58,6 +66,13 @@ export default function RemotionVideoPage() {
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] }); const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] }); const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
const facebookPages = socialPages.filter(p => p.platform === 'facebook'); const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok'); const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
@@ -108,6 +123,7 @@ export default function RemotionVideoPage() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; } if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
setIsRendering(true); setIsRendering(true);
setRenderProgress(0);
setVideoUrl(null); setVideoUrl(null);
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -118,6 +134,7 @@ export default function RemotionVideoPage() {
formData.append("ttsEngine", voiceSettings.engine); formData.append("ttsEngine", voiceSettings.engine);
formData.append("ttsVoice", voiceSettings.voice); formData.append("ttsVoice", voiceSettings.voice);
formData.append("ttsStyle", voiceSettings.style); formData.append("ttsStyle", voiceSettings.style);
formData.append("captionStyle", captionStyle);
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
@@ -146,6 +163,7 @@ export default function RemotionVideoPage() {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" }); toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false); setIsRendering(false);
} else { } else {
setRenderProgress(job.progress ?? 0);
setTimeout(checkStatus, 3000); setTimeout(checkStatus, 3000);
} }
} catch (e: any) { } catch (e: any) {
@@ -268,6 +286,10 @@ export default function RemotionVideoPage() {
)} )}
<Textarea placeholder="Votre texte overlay..." value={overlayText} <Textarea placeholder="Votre texte overlay..." value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={3} /> onChange={e => setOverlayText(e.target.value)} rows={3} />
<div className="space-y-1.5">
<Label className="text-sm font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} /> <Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts">Activer la voix TTS</Label> <Label htmlFor="tts">Activer la voix TTS</Label>
@@ -276,7 +298,7 @@ export default function RemotionVideoPage() {
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border"> <div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
<p className="text-sm text-muted-foreground">TTS — voix activée</p> <p className="text-sm text-muted-foreground">TTS — voix activée</p>
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} /> <VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -379,10 +401,37 @@ export default function RemotionVideoPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Aperçu en direct */}
{previewImages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[300px] mx-auto">
<ReelPreview
kind="images"
images={previewImages}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={musicFileUrl ?? selectedTrack?.url}
musicVolume={musicVolume}
logoUrl={reelConfig?.logoUrl}
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
endingEffect
/>
</div>
</CardContent>
</Card>
)}
{/* Générer */} {/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg"> <Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
{isRendering {isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération en cours (1-2 min)...</> ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>} : <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
</Button> </Button>
+38 -209
View File
@@ -1,21 +1,22 @@
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion"; import React from "react";
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
export type WordTiming = { import type { CaptionStyle, TimedWord } from "@shared/captions";
word: string; import { Captions } from "./components/Captions";
startFrame: number; import { Outro, Watermark } from "./components/Branding";
endFrame: number;
};
export type ImageCompositionProps = { export type ImageCompositionProps = {
images: string[]; images: string[];
overlayText?: string; totalDuration: number;
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
words: TimedWord[];
captionStyle: CaptionStyle;
audioUrl?: string; audioUrl?: string;
wordTimings?: WordTiming[];
musicUrl?: string; musicUrl?: string;
musicVolume?: number; musicVolume?: number;
logoUrl?: string; logoUrl?: string;
storeName?: string; storeName?: string;
endingFrames?: number; /** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
endingSeconds?: number;
}; };
/** /**
@@ -75,217 +76,45 @@ const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effec
); );
}; };
/** /** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
* CaptionGroup — renders a group of words (CapCut-style). export const ImageComposition: React.FC<ImageCompositionProps> = ({
* Active word is highlighted in yellow with glow; others are white. images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, storeName, endingSeconds = 3,
* Background: semi-transparent dark pill. }) => {
*/
const CaptionGroup: React.FC<{
words: WordTiming[];
activeIdx: number; // index within this group (0-3), -1 if none active
groupFirstFrame: number;
fps: number;
}> = ({ words, activeIdx, groupFirstFrame, fps }) => {
const frame = useCurrentFrame();
// Fade in when this group first appears
const fadeIn = spring({
frame: frame - groupFirstFrame,
fps,
config: { damping: 30, stiffness: 120 },
durationInFrames: 10,
});
return (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: "0 22px",
maxWidth: "90%",
opacity: fadeIn,
}}
>
{words.map((w, i) => {
const isActive = i === activeIdx;
return (
<span
key={i}
style={{
fontFamily: "'Arial Black', 'Impact', 'Helvetica Neue', sans-serif",
fontSize: isActive ? 82 : 74,
fontWeight: 900,
lineHeight: 1.15,
color: isActive ? "#FFE600" : "white",
textTransform: "uppercase",
textShadow: isActive
? "0 0 30px rgba(255,230,0,0.6), 0 4px 24px rgba(0,0,0,0.95)"
: "0 3px 18px rgba(0,0,0,0.9)",
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.85)" : "2px rgba(0,0,0,0.75)",
display: "inline-block",
transform: isActive ? "scale(1.1)" : "scale(1)",
transition: "transform 0.05s",
}}
>
{w.word}
</span>
);
})}
</div>
);
};
export const ImageComposition = ({
images, audioUrl, wordTimings, musicUrl, musicVolume = 0.3,
logoUrl, storeName, endingFrames = 90,
}: ImageCompositionProps) => {
const { fps, durationInFrames } = useVideoConfig(); const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame(); const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
const hasEnding = !!(logoUrl || storeName); const contentFrames = durationInFrames - endingFrames;
const effectiveEndingFrames = hasEnding ? endingFrames : 0; const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
const contentFrames = durationInFrames - effectiveEndingFrames; const endingStart = contentFrames / fps;
const durationPerImage = Math.floor(contentFrames / Math.max(images.length, 1));
const endingStart = contentFrames;
// Ending slide spring animation
const endingProgress = spring({
frame: frame - endingStart,
fps,
config: { damping: 20, stiffness: 80 },
durationInFrames: 30,
});
// Group words into sets of 3 for caption display
const WORDS_PER_GROUP = 3;
const activeWordIdx = wordTimings
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
: -1;
// Only show caption when a word is actively being spoken
const renderCaption = frame < endingStart && activeWordIdx >= 0;
const groupIdx = activeWordIdx >= 0 ? Math.floor(activeWordIdx / WORDS_PER_GROUP) : 0;
const groupStart = groupIdx * WORDS_PER_GROUP;
const groupWords = wordTimings ? wordTimings.slice(groupStart, groupStart + WORDS_PER_GROUP) : [];
const activeIdxInGroup = activeWordIdx - groupStart;
// First frame of the current group (for fade-in)
const groupFirstFrame = groupWords.length > 0 ? groupWords[0].startFrame : 0;
return ( return (
<AbsoluteFill style={{ backgroundColor: "black" }}> <AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Images — displayed immediately, no entrance transition */}
{images.map((imgUrl, index) => ( {images.map((imgUrl, index) => (
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}> <Sequence key={index} from={index * perImage} durationInFrames={perImage}>
<ImageSlide src={imgUrl} effectIndex={index} /> <ImageSlide src={imgUrl} effectIndex={index} />
</Sequence> </Sequence>
))} ))}
{/* TTS Audio */}
{audioUrl && <Html5Audio src={audioUrl} />} {audioUrl && <Html5Audio src={audioUrl} />}
{musicUrl && (
{/* Background music */} <Html5Audio
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />} src={musicUrl}
loop
{/* Caption overlay — CapCut style */} volume={(frame) =>
{renderCaption && ( (words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
<AbsoluteFill ? musicVolume * 0.35
style={{ : musicVolume) *
justifyContent: "flex-end", interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
alignItems: "center", extrapolateLeft: "clamp",
paddingBottom: 180, extrapolateRight: "clamp",
}} })
> }
{/* Semi-transparent dark background pill */}
<div
style={{
backgroundColor: "rgba(0,0,0,0.55)",
borderRadius: 28,
paddingTop: 22,
paddingBottom: 22,
paddingLeft: 36,
paddingRight: 36,
maxWidth: "92%",
backdropFilter: "blur(4px)",
}}
>
<CaptionGroup
words={groupWords}
activeIdx={activeIdxInGroup}
groupFirstFrame={groupFirstFrame}
fps={fps}
/>
</div>
</AbsoluteFill>
)}
{/* Logo watermark — bottom right during content */}
{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 <Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
style={{ {logoUrl && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
color: "white", {hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
fontSize: 90,
fontFamily: "'Arial Black', Impact, sans-serif",
fontWeight: 900,
textAlign: "center",
opacity: endingProgress,
letterSpacing: 3,
textShadow: "0 4px 24px rgba(255,255,255,0.2)",
transform: `translateY(${interpolate(endingProgress, [0, 1], [30, 0])}px)`,
}}
>
{storeName}
</div>
)}
</AbsoluteFill>
</Sequence>
)}
</AbsoluteFill> </AbsoluteFill>
); );
}; };
+90
View File
@@ -0,0 +1,90 @@
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;
storeName?: string;
logoStart?: number | null;
fadeStart?: number | null;
/** Rendu final : piste son déjà mixée (voix, musique baissée sous la voix, niveau normalisé). */
mixedAudioUrl?: string;
/** Aperçu : pistes séparées, mixées dans le navigateur. */
voiceUrl?: string;
voiceDelay?: number;
musicUrl?: string;
musicVolume?: number;
};
/** Reel à partir d'une vidéo : image, sous-titres animés, logo et effet de fin. */
export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
const { fps } = useVideoConfig();
const {
videoUrl, videoDuration, totalDuration, words, captionStyle, logoUrl, storeName,
logoStart = null, fadeStart = null, mixedAudioUrl, voiceUrl, voiceDelay = 2, musicUrl, musicVolume = 0.25,
} = props;
const videoFrames = Math.max(1, Math.floor(videoDuration * fps));
const totalFrames = Math.max(1, Math.round(totalDuration * fps));
const hasAddedAudio = Boolean(mixedAudioUrl || voiceUrl || musicUrl);
const video = (
<OffthreadVideo src={videoUrl} muted={hasAddedAudio} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
);
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
{totalFrames > videoFrames ? (
<>
<Sequence durationInFrames={videoFrames}>{video}</Sequence>
<Sequence from={videoFrames}>
<Freeze frame={videoFrames - 1}>{video}</Freeze>
</Sequence>
</>
) : (
video
)}
{mixedAudioUrl && <Html5Audio src={mixedAudioUrl} />}
{!mixedAudioUrl && voiceUrl && (
<Sequence from={Math.round(voiceDelay * fps)}>
<Html5Audio src={voiceUrl} />
</Sequence>
)}
{!mixedAudioUrl && musicUrl && (
<Html5Audio
src={musicUrl}
loop
volume={(frame) => previewMusicVolume(frame / fps, words, musicVolume, fadeStart)}
/>
)}
<Captions words={words} style={captionStyle} hideAfter={logoStart} />
{logoUrl && <Watermark logoUrl={logoUrl} until={logoStart} />}
{logoStart != null && (logoUrl || storeName) && (
<Outro start={logoStart} logoUrl={logoUrl} storeName={storeName} />
)}
{fadeStart != null && <FadeOut start={fadeStart} duration={FADE_SECONDS} />}
</AbsoluteFill>
);
};
/** Aperçu : la musique baisse pendant la parole et s'éteint avec le fondu final. */
function previewMusicVolume(time: number, words: TimedWord[], base: number, fadeStart: number | null): number {
const speaking = words.some((w) => time >= w.start - 0.2 && time <= w.end + 0.3);
const fade =
fadeStart == null
? 1
: interpolate(time, [fadeStart, fadeStart + FADE_SECONDS], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (speaking ? base * 0.35 : base) * fade;
}
+34 -11
View File
@@ -1,22 +1,45 @@
import { Composition } from "remotion"; import { Composition, type CalculateMetadataFunction } from "remotion";
import { ImageComposition, ImageCompositionProps } from "./ImageComposition"; import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
/** La durée vient des propriétés : chaque Reel a la sienne. */
function byTotalDuration<P extends { totalDuration: number }>(): CalculateMetadataFunction<P> {
return ({ props }) => ({ durationInFrames: Math.max(1, Math.round(props.totalDuration * REEL_FPS)) });
}
export const RemotionRoot: React.FC = () => { export const RemotionRoot: React.FC = () => {
return ( return (
<> <>
<Composition
id="ReelVideo"
component={ReelVideo}
fps={REEL_FPS}
width={REEL_WIDTH}
height={REEL_HEIGHT}
durationInFrames={REEL_FPS * 10}
calculateMetadata={byTotalDuration<ReelVideoProps>()}
defaultProps={{
videoUrl: "",
videoDuration: 10,
totalDuration: 10,
words: [],
captionStyle: DEFAULT_CAPTION_STYLE,
} as ReelVideoProps}
/>
<Composition <Composition
id="ImageVideo" id="ImageVideo"
component={ImageComposition} component={ImageComposition}
durationInFrames={360} fps={REEL_FPS}
fps={30} width={REEL_WIDTH}
width={1080} height={REEL_HEIGHT}
height={1920} durationInFrames={REEL_FPS * 25}
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
defaultProps={{ defaultProps={{
images: [ images: ["https://placehold.co/1080x1920?text=Image+1"],
"https://placehold.co/1080x1920?text=Image+1", totalDuration: 25,
"https://placehold.co/1080x1920?text=Image+2", words: [],
"https://placehold.co/1080x1920?text=Image+3" captionStyle: DEFAULT_CAPTION_STYLE,
],
} as ImageCompositionProps} } as ImageCompositionProps}
/> />
</> </>
@@ -0,0 +1,92 @@
import React from "react";
import { AbsoluteFill, Img, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { CAPTION_FONT } from "../fonts";
/** Petit logo en haut à droite, hors des zones couvertes par l'interface des réseaux. */
export const Watermark: React.FC<{ logoUrl: string; until?: number | null }> = ({ logoUrl, until }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (until != null && frame / fps >= until) return null;
return (
<AbsoluteFill style={{ alignItems: "flex-end", justifyContent: "flex-start", padding: "150px 48px" }}>
<Img
src={logoUrl}
style={{
width: 150,
height: 150,
objectFit: "contain",
opacity: 0.9,
filter: "drop-shadow(0 2px 10px rgba(0,0,0,0.55))",
}}
/>
</AbsoluteFill>
);
};
/**
* Effet de fin : l'image s'assombrit, le logo apparaît en grand puis le nom
* du magasin glisse en dessous.
*/
export const Outro: React.FC<{ start: number; logoUrl?: string; storeName?: string; opaque?: boolean }> = ({
start,
logoUrl,
storeName,
opaque = false,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const local = frame - Math.round(start * fps);
if (local < 0) return null;
const enter = spring({ frame: local, fps, config: { damping: 18, stiffness: 90 }, durationInFrames: 24 });
const name = spring({ frame: local - 8, fps, config: { damping: 18, stiffness: 90 }, durationInFrames: 24 });
const dim = interpolate(local, [0, 12], [0, opaque ? 1 : 0.62], { extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", gap: 44 }}>
<AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${dim})` }} />
{logoUrl && (
<Img
src={logoUrl}
style={{
width: 480,
height: 480,
objectFit: "contain",
opacity: enter,
transform: `scale(${interpolate(enter, [0, 1], [0.7, 1])})`,
filter: "drop-shadow(0 6px 30px rgba(0,0,0,0.5))",
}}
/>
)}
{storeName && (
<div
style={{
fontFamily: CAPTION_FONT,
fontWeight: 800,
fontSize: 84,
color: "#FFFFFF",
textAlign: "center",
maxWidth: "86%",
lineHeight: 1.1,
opacity: name,
transform: `translateY(${interpolate(name, [0, 1], [30, 0])}px)`,
textShadow: "0 4px 24px rgba(0,0,0,0.6)",
}}
>
{storeName}
</div>
)}
</AbsoluteFill>
);
};
/** Fondu au noir final. */
export const FadeOut: React.FC<{ start: number; duration: number }> = ({ start, duration }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame / fps, [start, start + duration], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return opacity > 0 ? <AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${opacity})` }} /> : null;
};
+142
View File
@@ -0,0 +1,142 @@
import React, { useMemo } from "react";
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { groupLines, type CaptionStyle, type TimedWord } from "@shared/captions";
import { CAPTION_FONT } from "../fonts";
interface CaptionsProps {
words: TimedWord[];
style: CaptionStyle;
/** Les sous-titres disparaissent à partir de cet instant (effet de fin). */
hideAfter?: number | null;
}
/** Couleur de mise en avant du mot prononcé, par style. */
const ACCENT = { bold: "#FFE600", neon: "#7C3AED", minimal: "#FFFFFF" } as const;
/**
* Sous-titres mot à mot, synchronisés sur la voix.
* Une ligne de 1 à 3 mots à la fois, placée au-dessus des boutons qu'Instagram
* et TikTok superposent en bas de l'écran.
*/
export const Captions: React.FC<CaptionsProps> = ({ words, style, hideAfter }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const time = frame / fps;
const lines = useMemo(() => groupLines(words), [words]);
if (hideAfter != null && time >= hideAfter) return null;
// Ligne courante : du début de son premier mot au début de la suivante
const index = lines.findIndex((line, i) => {
const start = line[0].start;
const next = lines[i + 1]?.[0].start ?? line[line.length - 1].end + 0.4;
return time >= start && time < next;
});
if (index < 0) return null;
const line = lines[index];
const lineFrame = Math.round(line[0].start * fps);
const appear = spring({ frame: frame - lineFrame, fps, config: { damping: 14, stiffness: 180 }, durationInFrames: 8 });
return (
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 520 }}>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: style === "neon" ? "12px 12px" : style === "bold" ? "4px 38px" : "0 24px",
maxWidth: "84%",
transform: `scale(${0.85 + 0.15 * appear})`,
opacity: appear,
}}
>
{line.map((word, i) => {
const spoken = time >= word.start;
const active = spoken && (i === line.length - 1 || time < line[i + 1].start);
const pop = spring({
frame: frame - Math.round(word.start * fps),
fps,
config: { damping: 12, stiffness: 220 },
durationInFrames: 6,
});
return (
<Word key={`${index}-${i}`} text={word.text} style={style} active={active} spoken={spoken} pop={pop} />
);
})}
</div>
</AbsoluteFill>
);
};
const Word: React.FC<{ text: string; style: CaptionStyle; active: boolean; spoken: boolean; pop: number }> = ({
text,
style,
active,
spoken,
pop,
}) => {
const base: React.CSSProperties = {
fontFamily: CAPTION_FONT,
display: "inline-block",
lineHeight: 1.1,
textAlign: "center",
paintOrder: "stroke fill",
};
if (style === "bold") {
return (
<span
style={{
...base,
fontSize: 92,
fontWeight: 900,
textTransform: "uppercase",
color: active ? ACCENT.bold : "#FFFFFF",
WebkitTextStroke: "12px #000000",
textShadow: "0 8px 24px rgba(0,0,0,0.55)",
transform: `scale(${active ? 1 + 0.12 * pop : 1})`,
}}
>
{text}
</span>
);
}
if (style === "neon") {
return (
<span
style={{
...base,
fontSize: 80,
fontWeight: 800,
color: "#FFFFFF",
padding: "6px 18px",
borderRadius: 18,
backgroundColor: active ? ACCENT.neon : "rgba(0,0,0,0.45)",
boxShadow: active ? "0 0 36px rgba(124,58,237,0.75)" : "none",
transform: `scale(${active ? 1 + 0.08 * pop : 1})`,
}}
>
{text}
</span>
);
}
return (
<span
style={{
...base,
fontSize: 78,
fontWeight: 700,
color: ACCENT.minimal,
opacity: spoken ? 1 : 0.45,
textShadow: "0 4px 18px rgba(0,0,0,0.85), 0 0 2px rgba(0,0,0,0.9)",
transform: `translateY(${active ? -6 * pop : 0}px)`,
}}
>
{text}
</span>
);
};
+14
View File
@@ -0,0 +1,14 @@
/**
* Police des sous-titres et de l'effet de fin, embarquée dans le bundle : le
* rendu ne dépend d'aucun service externe (Google Fonts).
*
* Chargée en CSS (@font-face) plutôt qu'avec delayRender : un onglet de rendu
* rechargé en cours de route restait bloqué sur l'attente de la police. Les
* sous-titres n'apparaissent qu'après le délai de la voix, la police est
* chargée bien avant.
*/
import "@fontsource/montserrat/700.css";
import "@fontsource/montserrat/800.css";
import "@fontsource/montserrat/900.css";
export const CAPTION_FONT = "Montserrat, 'Arial Black', Arial, sans-serif";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

+5
View File
@@ -52,6 +52,11 @@ services:
# Configuration FFmpeg Service # Configuration FFmpeg Service
FFMPEG_API_URL: http://ffmpeg-api:8000 FFMPEG_API_URL: http://ffmpeg-api:8000
FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key} FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
# Rendu des Reels vidéo : remotion (sous-titres animés, identique à
# l'aperçu) ou ffmpeg (plus rapide, sous-titres classiques)
REEL_RENDERER: ${REEL_RENDERER:-remotion}
# Onglets Chromium en parallèle pour Remotion (défaut : moitié des cœurs)
RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-}
networks: networks:
- internal - internal
- nginx_default - nginx_default
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+121 -52
View File
@@ -74,6 +74,9 @@ class ReelRequest(BaseModel):
draw_text: bool = True draw_text: bool = True
stabilize: bool = False stabilize: bool = False
enable_ending_effect: bool = True 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
# Champs d'anciennes versions, acceptés et ignorés # Champs d'anciennes versions, acceptés et ignorés
music_id: str | None = None music_id: str | None = None
word_duration: float | None = None word_duration: float | None = None
@@ -115,13 +118,23 @@ async def preview_tts(request: TtsRequest):
@app.post("/process-reel", dependencies=[Depends(require_key)]) @app.post("/process-reel", dependencies=[Depends(require_key)])
async def process_reel(request: ReelRequest): async def process_reel(request: ReelRequest):
"""Produit le Reel ; le MP4 se récupère ensuite via GET /files/{job_id}/output.mp4.""" """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() job_id, workdir = jobs.new_job()
stats: dict[str, float] = {}
started = time.monotonic() started = time.monotonic()
try: try:
async with render_slot: async with render_slot:
return await _process(request, job_id, workdir, stats, started) return await handler(request, job_id, workdir, started)
except HTTPException: except HTTPException:
jobs.remove_job(job_id) jobs.remove_job(job_id)
raise raise
@@ -140,7 +153,8 @@ async def get_file(job_id: str, name: str):
path = jobs.job_file(job_id, name) path = jobs.job_file(job_id, name)
if not path: if not path:
raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré") raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré")
return FileResponse(path, media_type="video/mp4" if name.endswith(".mp4") else None) 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)]) @app.delete("/jobs/{job_id}", dependencies=[Depends(require_key)])
@@ -185,8 +199,23 @@ async def _download(url: str, target: Path, what: str, required: bool) -> bool:
return False return False
async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict, started: float) -> dict: class Stopwatch:
step = time.monotonic() 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" video = workdir / "input.mp4"
if request.video_base64: if request.video_base64:
video.write_bytes(base64.b64decode(request.video_base64)) video.write_bytes(base64.b64decode(request.video_base64))
@@ -200,21 +229,21 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
request.music_url, music, "la musique", required=False request.music_url, music, "la musique", required=False
) )
watermark = workdir / "watermark.png" watermark = workdir / "watermark.png"
has_watermark = bool(request.watermark_url) and await _download( has_watermark = (
request.watermark_url, watermark, "le logo", required=False fetch_logo
and bool(request.watermark_url)
and await _download(request.watermark_url, watermark, "le logo", required=False)
) )
info = await proc.probe(video) info = await proc.probe(video)
if info.duration <= 0: if info.duration <= 0:
raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)") raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)")
stats["download"] = time.monotonic() - step clock.lap("download")
# --- Voix ---
step = time.monotonic()
track = None track = None
spoken_text = clean_text(request.text) spoken_text = clean_text(request.text)
if request.tts_enabled and spoken_text: if request.tts_enabled and spoken_text:
track = await _synthesize(spoken_text, request.text, request, workdir) track = await _synthesize(spoken_text, request.text, request, workdir)
stats["tts"] = time.monotonic() - step clock.lap("tts")
plan = render.RenderPlan( plan = render.RenderPlan(
video=video, video=video,
@@ -226,30 +255,11 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
voice=track.path if track else None, voice=track.path if track else None,
voice_duration=track.duration if track else 0.0, voice_duration=track.duration if track else 0.0,
watermark=watermark if has_watermark else None, watermark=watermark if has_watermark else None,
outro_expected=not fetch_logo and request.has_logo,
ending_effect=request.enable_ending_effect, ending_effect=request.enable_ending_effect,
keep_original_audio=info.has_audio, keep_original_audio=info.has_audio,
) )
# --- Sous-titres ---
step = time.monotonic()
font_size = max(48, round(request.font_size * 1.4))
display = clean_text(request.text)
if request.draw_text and display:
captions = workdir / "captions.ass"
if track:
subtitles.write_captions(track.words, captions, offset=plan.voice_delay, font_size=font_size)
else:
words = await _caption_words_without_voice(display, video, info, plan)
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
plan.captions = captions
if request.store_name and request.enable_ending_effect and has_watermark:
outro = workdir / "outro.ass"
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
plan.outro = outro
stats["subtitles"] = time.monotonic() - step
# --- Stabilisation (1re passe) ---
step = time.monotonic()
if request.stabilize: if request.stabilize:
transforms = workdir / "transforms.trf" transforms = workdir / "transforms.trf"
try: try:
@@ -257,36 +267,95 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
plan.stabilize_transforms = transforms plan.stabilize_transforms = transforms
except proc.CommandError as error: except proc.CommandError as error:
log.warning("Stabilisation ignorée : %s", error) log.warning("Stabilisation ignorée : %s", error)
stats["stabilize"] = time.monotonic() - step clock.lap("stabilize")
return plan, track, info
# --- Encodage ---
step = time.monotonic()
await proc.run(render.build_command(plan), timeout=1200)
stats["encode"] = time.monotonic() - step
stats["total"] = time.monotonic() - started
duration = (await proc.probe(plan.output)).duration async def _caption_words(request: ReelRequest, plan: render.RenderPlan, track, info) -> list[align.Word]:
log.info( """Mots à afficher, en secondes depuis le début de la vidéo."""
"Rendu %s terminé : %.1f s de vidéo, étapes %s", display = clean_text(request.text)
job_id, if not request.draw_text or not display:
duration, return []
{k: round(v, 1) for k, v in stats.items()}, 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)
# Seul le résultat est conservé jusqu'au téléchargement
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(): for entry in workdir.iterdir():
if entry != plan.output: if entry not in keep:
entry.unlink(missing_ok=True) 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 { return {
"success": True, "success": True,
"job_id": job_id, "job_id": job_id,
"output_path": f"/files/{job_id}/output.mp4", "output_path": f"/files/{job_id}/output.mp4",
"duration": duration, "duration": duration,
"tts_engine": track.engine if track else None, **_voice_info(track),
"tts_voice": track.voice if track else None, "processing_stats": clock.summary(),
"warnings": track.warnings if track else [], }
"processing_stats": stats,
async def _prepare(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
clock = Stopwatch(started)
plan, track, info = await _gather(request, workdir, clock, fetch_logo=False)
words = await _caption_words(request, plan, track, info)
clock.lap("subtitles")
video_out = workdir / "video.mp4"
audio_out = workdir / "audio.wav"
await proc.run(render.build_prepared_video_command(plan, video_out), timeout=1200)
clock.lap("video")
mix = render.build_audio_mix_command(plan, audio_out)
if mix:
await proc.run(mix, timeout=600)
clock.lap("audio")
log.info("Préparation %s terminée : %.1f s, étapes %s", job_id, plan.total_duration, clock.summary())
_keep_only(workdir, {video_out, audio_out})
return {
"success": True,
"job_id": job_id,
"video_path": f"/files/{job_id}/video.mp4",
"audio_path": f"/files/{job_id}/audio.wav" if mix else None,
"total_duration": plan.total_duration,
"video_duration": info.duration,
"logo_start": plan.logo_start if plan.has_outro else None,
"words": [w.to_dict() for w in words],
**_voice_info(track),
"processing_stats": clock.summary(),
} }
@@ -300,5 +369,5 @@ async def _caption_words_without_voice(display: str, video: Path, info, plan: re
return align.align_words(display, spoken, info.duration) return align.align_words(display, spoken, info.duration)
except Exception as error: # noqa: BLE001 — on retombe sur la répartition except Exception as error: # noqa: BLE001 — on retombe sur la répartition
log.warning("Transcription de la vidéo impossible : %s", error) log.warning("Transcription de la vidéo impossible : %s", error)
end = plan.logo_start if plan.ending_effect and plan.watermark else plan.total_duration end = plan.logo_start if plan.has_outro else plan.total_duration
return align.align_words(display, [], max(1.0, end - 0.5)) return align.align_words(display, [], max(1.0, end - 0.5))
+152 -113
View File
@@ -1,4 +1,11 @@
"""Construction de la commande FFmpeg d'un Reel (fonction pure, testable).""" """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 dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -19,6 +26,8 @@ TONEMAP = (
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" "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 @dataclass
class RenderPlan: class RenderPlan:
@@ -35,9 +44,15 @@ class RenderPlan:
captions: Path | None = None captions: Path | None = None
watermark: Path | None = None watermark: Path | None = None
outro: Path | None = None # nom du magasin (effet de fin) 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 ending_effect: bool = True
keep_original_audio: bool = False 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 @property
def speech_end(self) -> float: def speech_end(self) -> float:
return self.voice_delay + self.voice_duration if self.voice else 0.0 return self.voice_delay + self.voice_duration if self.voice else 0.0
@@ -48,42 +63,26 @@ class RenderPlan:
duration = self.video_duration duration = self.video_duration
if self.voice: if self.voice:
duration = max(duration, self.speech_end + VOICE_TAIL) duration = max(duration, self.speech_end + VOICE_TAIL)
if self.ending_effect and self.watermark: if self.has_outro:
duration = max(duration, self.speech_end + OUTRO_MIN) duration = max(duration, self.speech_end + OUTRO_MIN)
return round(duration, 3) return round(duration, 3)
@property @property
def logo_start(self) -> float: def logo_start(self) -> float:
"""Le grand logo n'arrive qu'une fois la voix terminée.""" """Le grand logo n'arrive qu'une fois la voix terminée."""
return max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end) return round(max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end), 3)
@property
def fade_start(self) -> float:
return max(0.0, self.total_duration - FADE_SECONDS)
@property @property
def freeze_duration(self) -> float: def freeze_duration(self) -> float:
return max(0.0, self.total_duration - self.video_duration) return max(0.0, self.total_duration - self.video_duration)
def build_command(plan: RenderPlan) -> list[str]: def video_filters(plan: RenderPlan) -> list[str]:
total = plan.total_duration """Stabilisation, HDR, recadrage 9:16, cadence fixe, dernière image figée."""
logo_start = plan.logo_start
fade_start = max(0.0, total - FADE_SECONDS)
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
index = 1
music_idx = voice_idx = wm_idx = None
if plan.music:
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
cmd += ["-stream_loop", "-1", "-i", str(plan.music)]
music_idx, index = index, index + 1
if plan.voice:
cmd += ["-i", str(plan.voice)]
voice_idx, index = index, index + 1
if plan.watermark:
cmd += ["-i", str(plan.watermark)]
wm_idx, index = index, index + 1
graph: list[str] = []
# --- Vidéo ---
chain = [] chain = []
if plan.stabilize_transforms: if plan.stabilize_transforms:
chain.append( chain.append(
@@ -98,35 +97,15 @@ def build_command(plan: RenderPlan) -> list[str]:
) )
if plan.freeze_duration > 0: if plan.freeze_duration > 0:
chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}") chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}")
if plan.captions: return chain
chain.append(f"subtitles='{filter_path(plan.captions)}'")
graph.append(f"[0:v]{','.join(chain)}[vbase]")
current = "vbase"
if wm_idx is not None:
corner = "W-w-30:H-h-30"
if plan.outro and plan.ending_effect:
graph.append(f"[{wm_idx}:v]scale=200:-1,split=2[wm_small][wm_big0]")
graph.append("[wm_big0]scale=-1:300[wm_big]")
graph.append(f"[{current}][wm_small]overlay={corner}:enable='lt(t,{logo_start:.3f})'[vwm1]")
graph.append(f"[vwm1][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]")
current = "vwm2"
else:
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]")
current = "vwm1"
if plan.outro and plan.ending_effect:
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
current = "vout0"
tail = [] def audio_graph(
if plan.ending_effect: plan: RenderPlan, music_idx: int | None, voice_idx: int | None
tail.append(f"fade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}") ) -> tuple[list[str], str | None]:
tail.append("format=yuv420p") """Graphe audio : voix décalée, musique bouclée et baissée sous la voix,
graph.append(f"[{current}]{','.join(tail)}[vout]") niveau final ~ -14 LUFS. Renvoie les filtres et l'étiquette de sortie."""
graph: list[str] = []
# --- Audio ---
mix = None
if voice_idx is not None: if voice_idx is not None:
delay_ms = int(plan.voice_delay * 1000) delay_ms = int(plan.voice_delay * 1000)
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]") graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
@@ -143,75 +122,135 @@ def build_command(plan: RenderPlan) -> list[str]:
mix = "voice" mix = "voice"
elif music_idx is not None: elif music_idx is not None:
mix = "music" mix = "music"
else:
return graph, None
audio_map: list[str] = [] tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
if mix:
# Niveau final recommandé par les réseaux sociaux (~ -14 LUFS)
audio_tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
if plan.ending_effect: if plan.ending_effect:
audio_tail.append(f"afade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}") tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
graph.append(f"[{mix}]{','.join(audio_tail)}[aout]") graph.append(f"[{mix}]{','.join(tail)}[aout]")
audio_map = ["-map", "[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"
if plan.outro and plan.ending_effect:
logo_start = plan.logo_start
graph.append(f"[{wm_idx}:v]scale=200:-1,split=2[wm_small][wm_big0]")
graph.append("[wm_big0]scale=-1:300[wm_big]")
graph.append(f"[{current}][wm_small]overlay={corner}:enable='lt(t,{logo_start:.3f})'[vwm1]")
graph.append(f"[vwm1][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]")
current = "vwm2"
else:
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]")
current = "vwm1"
if plan.outro and plan.ending_effect:
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
current = "vout0"
tail = []
if plan.ending_effect:
tail.append(f"fade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
tail.append("format=yuv420p")
graph.append(f"[{current}]{','.join(tail)}[vout]")
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx)
graph += audio_filters
if audio_out:
audio_map = ["-map", f"[{audio_out}]"]
elif plan.keep_original_audio: elif plan.keep_original_audio:
audio_map = ["-map", "0:a:0"] audio_map = ["-map", "0:a:0"]
else:
audio_map = []
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map] cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
cmd += [ cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
"-t", cmd += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-movflags", "+faststart"]
f"{total:.3f}", cmd.append(str(plan.output))
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
"19",
"-maxrate",
"10M",
"-bufsize",
"20M",
"-profile:v",
"high",
"-level",
"4.2",
"-g",
str(config.FPS * 2),
"-keyint_min",
str(config.FPS),
"-pix_fmt",
"yuv420p",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-colorspace",
"bt709",
"-c:a",
"aac",
"-b:a",
"192k",
"-ar",
"48000",
"-ac",
"2",
"-movflags",
"+faststart",
str(plan.output),
]
return cmd return cmd
def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
"""Image seule, prête pour Remotion : recadrée, en cadence fixe, à la durée finale.
Qualité élevée (CRF 16) : elle sera réencodée une fois par Remotion."""
chain = [*video_filters(plan), "format=yuv420p"]
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
"-vf", ",".join(chain), "-an", "-t", f"{plan.total_duration:.3f}",
*_video_encoding(16, "veryfast"), "-movflags", "+faststart", str(output),
] # fmt: skip
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
"""Piste son finale (WAV 48 kHz stéréo), ou None s'il n'y a aucun son à produire."""
total = f"{plan.total_duration:.3f}"
if not plan.music and not plan.voice:
if not plan.keep_original_audio:
return None
# Son d'origine seul : même niveau final que les autres Reels
chain = ["aresample=48000", "loudnorm=I=-14:TP=-1.5:LRA=11", "apad"]
if plan.ending_effect:
chain.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
"-map", "0:a:0", "-af", ",".join(chain), "-t", total,
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
] # fmt: skip
audio_args, music_idx, voice_idx, _ = _audio_inputs(plan, 0)
graph, audio_out = audio_graph(plan, music_idx, voice_idx)
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
] # fmt: skip
def stabilize_detect_command(video: Path, transforms: Path) -> list[str]: def stabilize_detect_command(video: Path, transforms: Path) -> list[str]:
return [ return [
"ffmpeg", "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(video),
"-y", "-vf", f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
"-hide_banner", "-f", "null", "-",
"-loglevel", ] # fmt: skip
"error",
"-i",
str(video),
"-vf",
f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
"-f",
"null",
"-",
]
+26
View File
@@ -75,3 +75,29 @@ def test_big_logo_waits_for_the_end_of_the_voice():
assert plan.logo_start == 6.8 assert plan.logo_start == 6.8
assert plan.total_duration == 9.3 assert plan.total_duration == 9.3
assert "gte(t,6.800)" in _graph(build_command(plan)) assert "gte(t,6.800)" in _graph(build_command(plan))
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")))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

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

Before

Width:  |  Height:  |  Size: 179 KiB

-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env node
// Pre-bundle Remotion composition during Docker build.
// This warms up the Webpack cache so the first user render is fast.
const { bundle } = require('@remotion/bundler');
const path = require('path');
bundle({ entryPoint: path.resolve(__dirname, '../client/src/remotion/index.ts') })
.then(loc => console.log('✅ Remotion pre-bundle OK:', loc))
.catch(e => { console.warn('⚠️ Remotion pre-bundle skipped:', e.message); process.exit(0); });
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
// Pré-bundle des compositions Remotion pendant le build Docker : le cache
// Webpack est chaud, le premier rendu d'un utilisateur démarre plus vite.
// Même configuration que server/services/reels/remotionRenderer.ts.
import path from "node:path";
import { fileURLToPath } from "node:url";
import { bundle } from "@remotion/bundler";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
bundle({
entryPoint: path.join(root, "client/src/remotion/index.ts"),
webpackOverride: (config) => ({
...config,
resolve: {
...config.resolve,
alias: { ...(config.resolve?.alias ?? {}), "@shared": path.join(root, "shared") },
},
}),
})
.then((location) => console.log("✅ Remotion pre-bundle OK:", location))
.catch((error) => {
console.warn("⚠️ Remotion pre-bundle skipped:", error.message);
process.exit(0);
});
+9 -2
View File
@@ -8,7 +8,8 @@ import { storage } from '../storage';
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg'; import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel'; import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue'; import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
import { resolveGeminiApiKey, resolveStoreName } from '../services/reels/assets'; import { resolveGeminiApiKey, resolveLogoPath, resolveStoreName } from '../services/reels/assets';
import { configuredRenderer } from '../services/reels/videoPipeline';
import { openRouterService, describeGenerationError } from '../services/openrouter'; import { openRouterService, describeGenerationError } from '../services/openrouter';
import { estimateVoiceTiming } from '../services/ttsSync'; import { estimateVoiceTiming } from '../services/ttsSync';
@@ -378,12 +379,18 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
*/ */
reelsRouter.get('/reels/config', async (req: Request, res: Response) => { reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
try { try {
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false); const [ffmpegConfigured, logoUrl] = await Promise.all([
ffmpegService.healthCheck().catch(() => false),
resolveLogoPath(),
]);
res.json({ res.json({
jamendo: { configured: false }, jamendo: { configured: false },
freesound: { configured: false }, freesound: { configured: false },
ffmpeg: { configured: ffmpegConfigured }, ffmpeg: { configured: ffmpegConfigured },
// Aperçu : logo affiché par le lecteur, moteur de rendu utilisé
logoUrl: logoUrl ?? null,
renderer: configuredRenderer(),
}); });
} catch (error) { } catch (error) {
console.error('❌ Error fetching Reels config:', error); console.error('❌ Error fetching Reels config:', error);
+1
View File
@@ -74,6 +74,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
ttsEngine: req.body.ttsEngine || undefined, ttsEngine: req.body.ttsEngine || undefined,
ttsVoice: req.body.ttsVoice, ttsVoice: req.body.ttsVoice,
ttsStyle: req.body.ttsStyle || undefined, ttsStyle: req.body.ttsStyle || undefined,
captionStyle: req.body.captionStyle || undefined,
storeName: await resolveStoreName(user.id, req.body.selectedPageId), storeName: await resolveStoreName(user.id, req.body.selectedPageId),
tempFiles: uploaded.map((f) => f.path), tempFiles: uploaded.map((f) => f.path),
}); });
+97
View File
@@ -6,6 +6,10 @@
* JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire. * JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire.
*/ */
import fs from 'fs';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import type { ReadableStream as WebReadableStream } from 'stream/web';
import type { TtsEngine, TtsStyle } from '@shared/voices'; import type { TtsEngine, TtsStyle } from '@shared/voices';
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */ /** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
@@ -39,6 +43,19 @@ export interface ReelRenderResult {
warnings: string[]; 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;
words: TimedWord[];
ttsEngine?: string | null;
ttsVoice?: string | null;
warnings: string[];
}
export interface TimedWord { export interface TimedWord {
text: string; text: string;
start: number; start: number;
@@ -164,6 +181,86 @@ export class FFmpegService {
} }
} }
/**
* 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,
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;
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,
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> { async healthCheck(): Promise<boolean> {
try { try {
await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS }); await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS });
+34 -147
View File
@@ -6,113 +6,16 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { bundle } from "@remotion/bundler"; import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel"; import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
import { ffmpegService, type TimedWord } from "../ffmpeg"; import { ffmpegService } from "../ffmpeg";
import { generateVideoThumbnail } from "../thumbnail"; import { generateVideoThumbnail } from "../thumbnail";
import type { JobContext } from "./queue"; import type { JobContext } from "./queue";
import { resolveGeminiApiKey, resolveLogoPath } from "./assets"; import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
import { renderReelComposition } from "./remotionRenderer";
export const REMOTION_TEMP_DIR = path.join(process.cwd(), "uploads", "temp"); export { REMOTION_TEMP_DIR };
// Durées
const FPS = 30;
const ENDING_SECONDS = 3; // diapositive de fin (logo + nom du magasin)
const MIN_CONTENT_SECONDS = 22; // total >= 25 s
const MAX_CONTENT_SECONDS = 27; // total <= 30 s
/** Bundle Remotion mis en cache : un seul bundle par processus. */
let bundleCache: Promise<string> | null = null;
function getBundle(): 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("📦 Bundling Remotion from:", entryPoint);
bundleCache = bundle({ entryPoint }).catch((error) => {
bundleCache = null; // réessayer au prochain rendu
throw error;
});
}
return bundleCache;
}
/** URL HTTP locale : l'audio est lu par Chromium, trop lourd pour une data URL. */
function localHttpUrl(url: string): string {
if (!url.startsWith("/")) return url;
const port = process.env.PORT || "5555";
return `http://localhost:${port}${url}`;
}
const MIME_BY_EXT: Record<string, string> = {
png: "image/png",
gif: "image/gif",
webp: "image/webp",
};
/**
* Convertit un chemin local /uploads/... en data URL : Chromium dans Docker ne
* lit pas http://localhost de façon fiable, les images sont donc embarquées.
*/
async function toDataUrl(relativeUrl: string): Promise<string> {
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
const filePath = path.resolve(process.cwd(), "." + relativeUrl);
if (!filePath.startsWith(uploadsRoot)) {
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
}
try {
const buffer = await fs.promises.readFile(filePath);
const ext = path.extname(filePath).slice(1).toLowerCase();
const mime = MIME_BY_EXT[ext] ?? "image/jpeg";
return `data:${mime};base64,${buffer.toString("base64")}`;
} catch {
console.warn(`⚠️ toDataUrl: fichier introuvable : ${filePath}`);
return relativeUrl;
}
}
/**
* Retire hashtags et emojis avant la synthèse vocale. Les lettres accentuées
* font partie du hashtag (#AménagementExtérieur est retiré en entier).
*/
export function stripForTTS(text: string): string {
return text
.replace(/#[\wÀ-ɏḀ-ỿ]*/g, " ")
.replace(/[\uD800-\uDFFF][\uDC00-\uDFFF]/g, " ") // paires de substitution (la plupart des emojis)
.replace(/[☀-➿]/g, " ") // symboles divers
.replace(/[⬀-⯿]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
const EDGE_PUNCT = /^[.,!?;:…«»"'()\[\]]+|[.,!?;:…«»"'()\[\]]+$/g;
export interface WordTiming {
word: string;
startFrame: number;
endFrame: number;
}
/**
* Convertit le minutage réel des mots (en secondes, fourni par la voix) en
* images. Chaque mot reste affiché jusqu'au début du suivant : pas de trou
* pendant les respirations.
*/
export function toWordTimings(words: TimedWord[], fps: number): WordTiming[] {
const timings = words
.map((w) => ({ word: w.text.replace(EDGE_PUNCT, "") || w.text, start: w.start, end: w.end }))
.filter((w) => w.word.trim());
return timings.map((w, i) => ({
word: w.word,
startFrame: Math.round(w.start * fps),
endFrame: Math.max(
Math.round(w.start * fps) + 1,
Math.round((i + 1 < timings.length ? timings[i + 1].start : w.end) * fps),
),
}));
}
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> { export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
const params = imagesReelParamsSchema.parse(job.params); const params = imagesReelParamsSchema.parse(job.params);
@@ -126,15 +29,15 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined; const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
const { overlayText, storeName } = params; const { overlayText, storeName } = params;
// --- Voix --- // --- Voix et minutage réel des mots ---
let audioUrl: string | undefined; let audioUrl: string | undefined;
let wordTimings: WordTiming[] | undefined; let words: { text: string; start: number; end: number }[] = [];
let audioDuration = 0; let audioDuration = 0;
const ttsText = overlayText ? stripForTTS(overlayText) : ""; const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
if (params.ttsEnabled && overlayText && ttsText) { if (params.ttsEnabled && spokenText) {
await progress(15, "voice"); await progress(15, "voice");
const voice = await ffmpegService.previewVoice(ttsText, { const voice = await ffmpegService.previewVoice(spokenText, {
voice: params.ttsVoice, voice: params.ttsVoice,
engine: params.ttsEngine, engine: params.ttsEngine,
style: params.ttsStyle, style: params.ttsStyle,
@@ -142,52 +45,40 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
}); });
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`); for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
const audioFilename = `tts-${job.id}.mp3`; const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
const audioPath = path.join(REMOTION_TEMP_DIR, audioFilename);
await fs.promises.writeFile(audioPath, voice.audio); await fs.promises.writeFile(audioPath, voice.audio);
jobTempFiles.push(audioPath); jobTempFiles.push(audioPath);
audioUrl = localHttpUrl(`/uploads/temp/${audioFilename}`); audioUrl = tempFileUrl(audioPath);
audioDuration = voice.duration; audioDuration = voice.duration;
wordTimings = toWordTimings(voice.words, FPS); words = voice.words;
} }
// --- Durée : 25 à 30 s --- // --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
const naturalContent = Math.max(audioDuration, images.length * 3); const { total, endingSeconds } = computeImagesTiming({
const contentSeconds = Math.min(Math.max(naturalContent, MIN_CONTENT_SECONDS), MAX_CONTENT_SECONDS); imageCount: images.length,
const endingFrames = logoUrl || storeName ? ENDING_SECONDS * FPS : 0; voiceDuration: audioDuration,
const totalFrames = Math.round(contentSeconds * FPS) + endingFrames; hasEnding: Boolean(logoUrl || storeName),
});
await progress(25, "render"); await progress(25, "render");
const serveUrl = await getBundle(); const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
const inputProps = { let lastReported = 25;
await renderReelComposition({
compositionId: "ImageVideo",
outputLocation,
inputProps: {
images, images,
overlayText, totalDuration: total,
words,
captionStyle: params.captionStyle,
audioUrl, audioUrl,
wordTimings,
musicUrl, musicUrl,
musicVolume: params.musicVolume, musicVolume: params.musicVolume,
logoUrl, logoUrl,
storeName, storeName,
endingFrames, endingSeconds,
};
const composition = await selectComposition({ serveUrl, id: "ImageVideo", inputProps });
const outputFilename = `out-${job.id}.mp4`;
const outputLocation = path.join(REMOTION_TEMP_DIR, outputFilename);
let lastReported = 25;
await renderMedia({
composition: { ...composition, durationInFrames: totalFrames },
serveUrl,
codec: "h264",
outputLocation,
inputProps,
chromiumOptions: {
disableWebSecurity: true,
ignoreCertificateErrors: true,
}, },
concurrency: 1, // évite d'épuiser la mémoire du conteneur onProgress: (ratio) => {
onProgress: ({ progress: ratio }) => {
const pct = 25 + Math.floor(ratio * 70); const pct = 25 + Math.floor(ratio * 70);
if (pct >= lastReported + 5) { if (pct >= lastReported + 5) {
lastReported = pct; lastReported = pct;
@@ -196,16 +87,12 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
}, },
}); });
const thumbnailFilename = outputFilename.replace(".mp4", "-thumb.jpg"); const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
const thumbnailOk = await generateVideoThumbnail( const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
outputLocation,
path.join(REMOTION_TEMP_DIR, thumbnailFilename),
2,
);
return { return {
url: `/uploads/temp/${outputFilename}`, url: `/uploads/temp/${path.basename(outputLocation)}`,
thumbnailUrl: thumbnailOk ? `/uploads/temp/${thumbnailFilename}` : null, thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : null,
}; };
} finally { } finally {
// Images, musique importée et voix ne servent plus une fois le rendu fini // Images, musique importée et voix ne servent plus une fois le rendu fini
@@ -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");
});
});
+58
View File
@@ -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;
}
}
+85
View File
@@ -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`,
);
}
+95 -13
View File
@@ -1,15 +1,33 @@
/** /**
* Rendu d'un Reel à partir d'une vidéo : FFmpeg → médiathèque → publication. * 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). * 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 { videoReelParamsSchema } from "@shared/reel"; import fs from "fs";
import path from "path";
import { FADE_SECONDS } from "@shared/captions";
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
import { storage } from "../../storage"; import { storage } from "../../storage";
import { ffmpegService } from "../ffmpeg"; import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
import { resolveInternalUrl } from "../minio"; import { resolveInternalUrl } from "../minio";
import type { JobContext } from "./queue"; import type { JobContext } from "./queue";
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets"; import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish"; 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) { export async function runVideoReelJob({ job, progress }: JobContext) {
if (!job.postId) throw new Error("Job vidéo sans post associé"); if (!job.postId) throw new Error("Job vidéo sans post associé");
@@ -28,9 +46,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
resolveGeminiApiKey(params.ttsEngine), resolveGeminiApiKey(params.ttsEngine),
]); ]);
await progress(15, "render"); const options: ReelRenderOptions = {
const startedAt = Date.now();
const rendered = await ffmpegService.renderReel(resolveInternalUrl(media.originalUrl), {
text: params.overlayText, text: params.overlayText,
musicUrl, musicUrl,
ttsEnabled: params.ttsEnabled, ttsEnabled: params.ttsEnabled,
@@ -42,18 +58,23 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
musicVolume: params.musicVolume, musicVolume: params.musicVolume,
drawText: params.drawText, drawText: params.drawText,
stabilize: params.stabilize, stabilize: params.stabilize,
watermarkUrl: logoPath ? resolveInternalUrl(logoPath) : undefined,
storeName: params.storeName, storeName: params.storeName,
enableEndingEffect: params.enableEndingEffect, enableEndingEffect: params.enableEndingEffect,
}); };
console.log(`⏱️ [Reels] Rendu : ${((Date.now() - startedAt) / 1000).toFixed(1)} s, vidéo de ${rendered.duration.toFixed(1)} s`);
await progress(65, "store"); const renderer = configuredRenderer();
const videoBuffer = rendered.video; 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`); const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
await storage.updatePostMedia(postId, [processedMedia.id]); await storage.updatePostMedia(postId, [processedMedia.id]);
await progress(85, "publish"); await progress(93, "publish");
const results = await publishReelToPages({ const results = await publishReelToPages({
postId, postId,
pageIds: params.pageIds, pageIds: params.pageIds,
@@ -66,5 +87,66 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
throw new Error(describePublishFailure(results)); throw new Error(describePublishFailure(results));
} }
return { mediaId: processedMedia.id, 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");
const rendered = await ffmpegService.renderReel(videoUrl, {
...options,
watermarkUrl: 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,
storeName: params.enableEndingEffect ? params.storeName : undefined,
logoStart: prepared.logoStart,
fadeStart: params.enableEndingEffect ? Math.max(0, prepared.totalDuration - FADE_SECONDS) : null,
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 */ });
}
} }
+14
View File
@@ -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/);
});
});
+22 -8
View File
@@ -3,6 +3,7 @@ import {
socialPages, socialPages,
media, media,
posts, posts,
reelJobs,
postMedia, postMedia,
scheduledPosts, scheduledPosts,
aiGenerations, aiGenerations,
@@ -44,7 +45,7 @@ import {
type InsertFacebookConfig, type InsertFacebookConfig,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; 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"; import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
/** /**
@@ -147,7 +148,7 @@ export interface IStorage {
getPosts(userId: string): Promise<Post[]>; getPosts(userId: string): Promise<Post[]>;
getPost(id: string): Promise<Post | undefined>; getPost(id: string): Promise<Post | undefined>;
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | 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>; createPost(post: InsertPost): Promise<Post>;
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>; updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>; updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
@@ -551,21 +552,34 @@ export class DatabaseStorage implements IStorage {
} }
/** /**
* Reels dont la génération est en cours. Filtré en SQL : la route chargeait * Reels dont la génération est en cours, plus ceux qui ont échoué dans les
* tous les posts de l'utilisateur pour n'en garder qu'une poignée, toutes les * dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape
* trois secondes. * courante du rendu. Filtré en SQL : la route est interrogée toutes les
* trois secondes pendant un rendu.
*/ */
async getOngoingReelPosts(userId: string): Promise<Post[]> { async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> {
return await db const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
const rows = await db
.select() .select()
.from(posts) .from(posts)
.where( .where(
and( and(
eq(posts.userId, userId), 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)); .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 // AI Generations
+63
View File
@@ -0,0 +1,63 @@
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 });
});
});
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 }]);
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* 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;
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;
}
/** 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;
}): ReelTiming {
const hasOutro = input.hasOutro && input.endingEffect;
const speechEnd = input.voiceDuration ? 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;
return {
total,
logoStart: hasOutro ? Math.max(0, total - LOGO_SECONDS, speechEnd) : null,
fadeStart: input.endingEffect ? Math.max(0, total - FADE_SECONDS) : 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 };
}
+35
View File
@@ -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 });
});
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);
});
});
+3
View File
@@ -5,6 +5,7 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { CAPTION_STYLES, DEFAULT_CAPTION_STYLE } from "./captions";
import { TTS_ENGINES, TTS_STYLES } from "./voices"; import { TTS_ENGINES, TTS_STYLES } from "./voices";
const optionalText = z const optionalText = z
@@ -33,6 +34,7 @@ export const videoReelParamsSchema = z.object({
fontSize: z.number().int().min(16).max(200).default(64), fontSize: z.number().int().min(16).max(200).default(64),
musicVolume: z.number().min(0).max(2).default(0.25), musicVolume: z.number().min(0).max(2).default(0.25),
drawText: z.boolean().default(true), drawText: z.boolean().default(true),
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
stabilize: z.boolean().default(false), stabilize: z.boolean().default(false),
enableEndingEffect: z.boolean().default(true), enableEndingEffect: z.boolean().default(true),
// Déterminé par le serveur à partir de la première page, jamais par le client // Déterminé par le serveur à partir de la première page, jamais par le client
@@ -52,6 +54,7 @@ export const imagesReelParamsSchema = z.object({
ttsEngine: z.enum(TTS_ENGINES).optional(), ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsVoice: optionalText, ttsVoice: optionalText,
ttsStyle: z.enum(TTS_STYLES).optional(), ttsStyle: z.enum(TTS_STYLES).optional(),
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
storeName: z.string().optional(), storeName: z.string().optional(),
// Fichiers temporaires à supprimer une fois le rendu terminé // Fichiers temporaires à supprimer une fois le rendu terminé
tempFiles: z.array(z.string()).default([]), tempFiles: z.array(z.string()).default([]),
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

-12
View File
@@ -1,12 +0,0 @@
import { bundle } from "@remotion/bundler";
import path from "path";
async function run() {
try {
const loc = await bundle({ entryPoint: path.resolve("client/src/remotion/index.ts") });
console.log("Success:", loc);
} catch(e) {
console.error("Error bundling", e);
}
}
run();
-27
View File
@@ -1,27 +0,0 @@
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import path from "path";
async function run() {
try {
const loc = await bundle({ entryPoint: path.resolve("client/src/remotion/index.ts") });
console.log("Bundle success:", loc);
const comp = await selectComposition({
serveUrl: loc,
id: "ImageVideo",
inputProps: { images: ["https://placehold.co/1080x1920?text=Image+1"] }
});
console.log("Comp:", comp?.id);
await renderMedia({
composition: comp,
serveUrl: loc,
codec: "h264",
outputLocation: path.resolve("out.mp4"),
inputProps: { images: ["https://placehold.co/1080x1920?text=Image+1"] }
});
console.log("Render success!");
} catch(e) {
console.error("Error rendering", e);
}
}
run();
+15
View File
@@ -0,0 +1,15 @@
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@shared": path.resolve(import.meta.dirname, "shared"),
"@": path.resolve(import.meta.dirname, "client", "src"),
},
},
test: {
include: ["shared/**/*.test.ts", "server/**/*.test.ts"],
environment: "node",
},
});