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
+2488 -782

No files matched your search

+43 -3
View File
@@ -16,7 +16,10 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
import { VoicePicker, 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 { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
@@ -67,6 +70,10 @@ export default function MobileNewReel() {
style: DEFAULT_TTS_STYLE,
});
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
@@ -203,8 +210,10 @@ export default function MobileNewReel() {
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({
title: data.success ? "Reel créé !" : "Attention",
description: data.success ? "Publication réussie" : "Certaines erreurs",
title: "Création du Reel lancée",
description: data.queued
? "Un autre Reel est en cours : le vôtre démarrera juste après."
: "Suivez l'avancement sur l'accueil.",
});
navigate('/');
},
@@ -264,6 +273,7 @@ export default function MobileNewReel() {
ttsEngine,
ttsVoice,
ttsStyle,
captionStyle,
enableEndingEffect,
});
};
@@ -456,6 +466,10 @@ export default function MobileNewReel() {
className="text-lg"
rows={3}
/>
<div className="space-y-1.5 mt-3">
<Label className="text-xs font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
</div>
<div className="flex items-center space-x-2 mt-4">
<Switch
id="enable-ending-effect"
@@ -490,6 +504,7 @@ export default function MobileNewReel() {
value={voiceSettings}
onChange={setVoiceSettings}
sampleText={overlayText}
onPreview={setVoicePreview}
compact
/>
</div>
@@ -524,6 +539,31 @@ export default function MobileNewReel() {
{/* PUBLISH STEP */}
{currentStep === 'publish' && (
<div className="space-y-6">
{selectedVideo && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[260px] mx-auto">
<ReelPreview
kind="video"
videoUrl={selectedVideo.originalUrl}
text={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>
<CardHeader className="pb-3">
<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 { 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -50,6 +53,11 @@ export default function MobileRemotionVideoPage() {
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [publishDescription, setPublishDescription] = useState("");
const [isPublishing, setIsPublishing] = useState(false);
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const [renderProgress, setRenderProgress] = useState(0);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
const audioRef = useRef<HTMLAudioElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
@@ -59,6 +67,13 @@ export default function MobileRemotionVideoPage() {
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
@@ -142,6 +157,7 @@ export default function MobileRemotionVideoPage() {
const handleGenerate = async () => {
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
setIsRendering(true);
setRenderProgress(0);
setVideoUrl(null);
try {
const formData = new FormData();
@@ -152,6 +168,7 @@ export default function MobileRemotionVideoPage() {
formData.append("ttsEngine", voiceSettings.engine);
formData.append("ttsVoice", voiceSettings.voice);
formData.append("ttsStyle", voiceSettings.style);
formData.append("captionStyle", captionStyle);
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
@@ -180,6 +197,7 @@ export default function MobileRemotionVideoPage() {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false);
} else {
setRenderProgress(job.progress ?? 0);
setTimeout(checkStatus, 3000);
}
} catch (e: any) {
@@ -328,6 +346,10 @@ export default function MobileRemotionVideoPage() {
)}
<Textarea placeholder="Texte overlay…" value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
<div className="space-y-1.5">
<Label className="text-xs font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
</div>
<div className="flex items-center gap-3">
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
@@ -336,7 +358,7 @@ export default function MobileRemotionVideoPage() {
<div className="space-y-2">
<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>
)}
</CardContent>
@@ -433,10 +455,37 @@ export default function MobileRemotionVideoPage() {
</CardContent>
</Card>
{/* Aperçu en direct */}
{previewImages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[260px] mx-auto">
<ReelPreview
kind="images"
images={previewImages}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={musicFileUrl ?? selectedTrack?.url}
musicVolume={musicVolume}
logoUrl={reelConfig?.logoUrl}
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
endingEffect
/>
</div>
</CardContent>
</Card>
)}
{/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
{isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération (1-2 min)…</>
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
</Button>