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, computeReelTiming, offsetWords, spreadWords, type CaptionStyle, type TimedWord, } from "@shared/captions"; import { srtEnd, srtWords, type SrtCue } from "@shared/srt"; import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo"; import type { VoicePreviewResult } from "./voice-picker"; /** Débit moyen d'une voix de synthèse française (estimation avant génération). */ const WORDS_PER_SECOND = 2.6; interface CommonProps { text: string; showCaptions: boolean; captionStyle: CaptionStyle; ttsEnabled: boolean; /** Voix générée pour ce texte et ces réglages, sinon null (minutage estimé). */ voice: VoicePreviewResult | null; musicUrl?: string; musicVolume: number; logoUrl?: string | null; /** Petit logo pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */ showWatermark: boolean; storeName?: string; endingEffect: boolean; /** Sous-titres SRT : remplacent `text`, minutés par le fichier. */ srtCues?: SrtCue[] | null; } type ReelPreviewProps = CommonProps & { kind: "video"; videoUrl: string }; /** 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(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.videoUrl); const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } = props; const logoUrl = props.logoUrl ?? undefined; const storeName = endingEffect ? props.storeName || undefined : undefined; const srtCues = props.srtCues?.length ? props.srtCues : null; const cleanText = cleanCaptionText(text); const voiceDuration = srtCues ? ttsEnabled ? srtEnd(srtCues) : 0 : ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0; const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice; const composition = useMemo(() => { if (videoDuration == null) return null; const timing = computeReelTiming({ videoDuration, voiceDuration: voiceDuration || undefined, hasOutro: Boolean(logoUrl), endingEffect, voiceDelay: srtCues ? 0 : VOICE_DELAY, }); let words: TimedWord[] = []; if (srtCues) { // La voix de l'aperçu n'est pas générée sous-titre par sous-titre : minutage du fichier if (showCaptions) words = srtWords(srtCues); } else if (showCaptions && cleanText) { if (voice) words = offsetWords(voice.words, VOICE_DELAY); else if (ttsEnabled) words = spreadWords(text, VOICE_DELAY, VOICE_DELAY + voiceDuration); else words = spreadWords(text, 0.5, (timing.logoStart ?? timing.total) - 0.5); } const inputProps: ReelVideoProps = { videoUrl: props.videoUrl, videoDuration, totalDuration: timing.total, words, captionStyle, logoUrl, showWatermark, storeName, logoStart: timing.logoStart, fadeStart: timing.fadeStart, fadeDuration: timing.fadeDuration, voiceUrl: srtCues ? undefined : voice?.audioUrl, voiceDelay: VOICE_DELAY, musicUrl, musicVolume, }; return { component: ReelVideo, inputProps, total: timing.total }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ props.videoUrl, videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration, musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect, ]); if (!composition) { return (
); } return (
>} inputProps={composition.inputProps as unknown as Record} 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 />

{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`}

); }