diff --git a/client/src/components/reels/reel-preview.tsx b/client/src/components/reels/reel-preview.tsx index 02c41fb..70d461d 100644 --- a/client/src/components/reels/reel-preview.tsx +++ b/client/src/components/reels/reel-preview.tsx @@ -14,6 +14,7 @@ import { type CaptionStyle, type TimedWord, } from "@shared/captions"; +import { srtEnd, srtWords, type SrtCue } from "@shared/srt"; import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo"; import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition"; import type { VoicePreviewResult } from "./voice-picker"; @@ -33,6 +34,8 @@ interface CommonProps { logoUrl?: string | null; storeName?: string; endingEffect: boolean; + /** Sous-titres SRT : remplacent `text`, minutés par le fichier. */ + srtCues?: SrtCue[] | null; } type ReelPreviewProps = @@ -74,9 +77,12 @@ export function ReelPreview(props: ReelPreviewProps) { const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect } = props; const logoUrl = props.logoUrl ?? undefined; const storeName = endingEffect ? props.storeName || undefined : undefined; + const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null; const cleanText = cleanCaptionText(text); - const voiceDuration = ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0; - const estimated = ttsEnabled && Boolean(cleanText) && !voice; + const voiceDuration = srtCues + ? ttsEnabled ? srtEnd(srtCues) : 0 + : ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0; + const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice; const composition = useMemo(() => { if (props.kind === "images") { @@ -112,9 +118,13 @@ export function ReelPreview(props: ReelPreviewProps) { voiceDuration: voiceDuration || undefined, hasOutro: Boolean(logoUrl), endingEffect, + voiceDelay: srtCues ? 0 : VOICE_DELAY, }); let words: TimedWord[] = []; - if (showCaptions && cleanText) { + if (srtCues) { + // La voix de l'aperçu n'est pas générée sous-titre par sous-titre : minutage du fichier + if (showCaptions) words = srtWords(srtCues); + } else if (showCaptions && cleanText) { if (voice) words = offsetWords(voice.words, VOICE_DELAY); else if (ttsEnabled) words = spreadWords(text, VOICE_DELAY, VOICE_DELAY + voiceDuration); else words = spreadWords(text, 0.5, (timing.logoStart ?? timing.total) - 0.5); @@ -129,7 +139,7 @@ export function ReelPreview(props: ReelPreviewProps) { storeName, logoStart: timing.logoStart, fadeStart: timing.fadeStart, - voiceUrl: voice?.audioUrl, + voiceUrl: srtCues ? undefined : voice?.audioUrl, voiceDelay: VOICE_DELAY, musicUrl, musicVolume, @@ -139,7 +149,7 @@ export function ReelPreview(props: ReelPreviewProps) { }, [ props.kind, props.kind === "video" ? props.videoUrl : props.images.join("|"), - videoDuration, text, cleanText, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration, + videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration, musicUrl, musicVolume, logoUrl, storeName, endingEffect, ]); @@ -171,7 +181,9 @@ export function ReelPreview(props: ReelPreviewProps) {

- {estimated + {srtCues + ? `Minutage du fichier SRT (la voix sera posée sur chaque sous-titre au rendu) · ${composition.total.toFixed(1)} s` + : estimated ? "Minutage estimé : cliquez sur « Tester la voix » pour caler les sous-titres sur la vraie voix." : `Aperçu fidèle au rendu final · ${composition.total.toFixed(1)} s`}

diff --git a/client/src/components/reels/srt-upload.tsx b/client/src/components/reels/srt-upload.tsx new file mode 100644 index 0000000..a308c75 --- /dev/null +++ b/client/src/components/reels/srt-upload.tsx @@ -0,0 +1,89 @@ +import { useRef } from "react"; +import { FileText, Upload, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { formatSrtTime, parseSrt, srtEnd, type SrtCue } from "@shared/srt"; + +const MAX_SRT_BYTES = 512 * 1024; + +export interface SrtFile { + name: string; + cues: SrtCue[]; +} + +interface SrtUploadProps { + value: SrtFile | null; + onChange: (value: SrtFile | null) => void; +} + +/** + * Import d'un fichier de sous-titres SRT. Tant qu'un fichier est chargé, il + * remplace le texte libre : la voix lit chaque sous-titre à son instant. + */ +export function SrtUpload({ value, onChange }: SrtUploadProps) { + const { toast } = useToast(); + const inputRef = useRef(null); + + const handleFile = async (file: File | undefined) => { + if (!file) return; + try { + if (file.size > MAX_SRT_BYTES) throw new Error("Fichier SRT trop volumineux (512 Ko au plus)"); + const cues = parseSrt(await file.text()); + onChange({ name: file.name, cues }); + toast({ title: "Sous-titres importés", description: `${cues.length} sous-titre(s) · ${formatSrtTime(srtEnd(cues))}` }); + } catch (error) { + toast({ + title: "Fichier SRT invalide", + description: error instanceof Error ? error.message : "Lecture impossible", + variant: "destructive", + }); + } finally { + if (inputRef.current) inputRef.current.value = ""; + } + }; + + return ( +
+ handleFile(e.target.files?.[0])} + /> + {value ? ( +
+
+
+ + {value.name} +
+ +
+

+ {value.cues.length} sous-titre(s) · jusqu'à {formatSrtTime(srtEnd(value.cues))}. La voix lira chaque + sous-titre à son instant et accélérera si besoin pour tenir dans sa durée. +

+
    + {value.cues.map((cue, i) => ( +
  • + + {formatSrtTime(cue.start)}–{formatSrtTime(cue.end)} + + {cue.text} +
  • + ))} +
+
+ ) : ( + + )} +
+ ); +} diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index f54ec35..83d51cc 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -19,6 +19,8 @@ import { useToast } from "@/hooks/use-toast"; import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker"; import { CaptionStylePicker } from "@/components/reels/caption-style-picker"; import { ReelPreview } from "@/components/reels/reel-preview"; +import { SrtUpload, type SrtFile } from "@/components/reels/srt-upload"; +import { srtText } from "@shared/srt"; import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions"; import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; @@ -56,6 +58,8 @@ export default function MobileNewReel() { const [selectedVideo, setSelectedVideo] = useState(null); const [selectedTrack, setSelectedTrack] = useState(null); const [overlayText, setOverlayText] = useState(''); + // Fichier SRT : remplace le texte libre, la voix suit son minutage + const [srtFile, setSrtFile] = useState(null); const [productInfo, setProductInfo] = useState(''); const [generatedVariants, setGeneratedVariants] = useState([]); const [selectedPages, setSelectedPages] = useState([]); @@ -92,7 +96,7 @@ export default function MobileNewReel() { // Auto-calculate TTS sync useEffect(() => { - if (!ttsEnabled || !overlayText.trim()) { + if (!ttsEnabled || !overlayText.trim() || srtFile) { setSyncInfo(null); return; } @@ -107,7 +111,7 @@ export default function MobileNewReel() { .catch(() => setSyncInfo(null)); }, 800); return () => clearTimeout(timer); - }, [overlayText, ttsEnabled, ttsEngine, ttsVoice]); + }, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]); const { data: pages = [] } = useQuery({ @@ -262,8 +266,9 @@ export default function MobileNewReel() { createReelMutation.mutate({ videoMediaId: selectedVideoId, musicTrackId: selectedTrack?.id, - overlayText: overlayText, - description: overlayText, + overlayText: srtFile ? undefined : overlayText, + srtCues: srtFile?.cues, + description: srtFile ? srtText(srtFile.cues) : overlayText, pageIds: selectedPages, scheduledFor: scheduledDate?.toISOString(), musicVolume: musicVolume[0] / 100, @@ -429,6 +434,7 @@ export default function MobileNewReel() { {/* TEXT STEP */} {currentStep === 'text' && (
+ {!srtFile && ( Assistant IA @@ -446,8 +452,9 @@ export default function MobileNewReel() { + )} - {generatedVariants.length > 0 && ( + {!srtFile && generatedVariants.length > 0 && (
{generatedVariants.map((v, i) => (
{ setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}> @@ -459,13 +466,20 @@ export default function MobileNewReel() {
-