feat(reels): import d'un fichier SRT lu par la voix à son minutage

- Nouveau Reel (bureau et mobile) : bouton « Importer un fichier SRT ».
  Tant qu'un SRT est chargé, le texte libre et l'assistant IA sont
  désactivés ; le choix de la voix (moteur, voix, ton) reste disponible.
- Service Python : chaque sous-titre est lu séparément, ses silences
  retirés, puis posé à son instant de début. Une lecture trop longue est
  accélérée (atempo, sans changer la hauteur) pour tenir dans la durée du
  sous-titre, avec un avertissement au-delà de ×1,35. Après un repli sur
  Edge, tous les sous-titres gardent la même voix.
- Sans voix, les mots de chaque sous-titre s'affichent sur son intervalle.
- Aperçu : sous-titres calés sur le minutage du fichier.
- Tests : lecture SRT (TS), fenêtres, accélération et mixage (Python).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVqEw4Xycgf8BtwmSftE2M
This commit is contained in:
Claude committed 2026-09-27 08:19:44 +00:00
1 parent 9b66a6dc5a
commit f5e2461da1
15 files changed
+635 -44

No files matched your search

+18 -6
View File
@@ -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) {
</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
{srtCues
? `Minutage du fichier SRT (la voix sera posée sur chaque sous-titre au rendu) · ${composition.total.toFixed(1)} s`
: estimated
? "Minutage estimé : cliquez sur « Tester la voix » pour caler les sous-titres sur la vraie voix."
: `Aperçu fidèle au rendu final · ${composition.total.toFixed(1)} s`}
</p>
@@ -0,0 +1,89 @@
import { useRef } from "react";
import { FileText, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { formatSrtTime, parseSrt, srtEnd, type SrtCue } from "@shared/srt";
const MAX_SRT_BYTES = 512 * 1024;
export interface SrtFile {
name: string;
cues: SrtCue[];
}
interface SrtUploadProps {
value: SrtFile | null;
onChange: (value: SrtFile | null) => void;
}
/**
* Import d'un fichier de sous-titres SRT. Tant qu'un fichier est chargé, il
* remplace le texte libre : la voix lit chaque sous-titre à son instant.
*/
export function SrtUpload({ value, onChange }: SrtUploadProps) {
const { toast } = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = async (file: File | undefined) => {
if (!file) return;
try {
if (file.size > MAX_SRT_BYTES) throw new Error("Fichier SRT trop volumineux (512 Ko au plus)");
const cues = parseSrt(await file.text());
onChange({ name: file.name, cues });
toast({ title: "Sous-titres importés", description: `${cues.length} sous-titre(s) · ${formatSrtTime(srtEnd(cues))}` });
} catch (error) {
toast({
title: "Fichier SRT invalide",
description: error instanceof Error ? error.message : "Lecture impossible",
variant: "destructive",
});
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
accept=".srt,application/x-subrip,text/plain"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
{value ? (
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 text-sm font-medium">
<FileText className="w-4 h-4 shrink-0 text-primary" />
<span className="truncate">{value.name}</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => onChange(null)}>
<X className="w-4 h-4 mr-1" />
Retirer
</Button>
</div>
<p className="text-xs text-muted-foreground">
{value.cues.length} sous-titre(s) · jusqu'à {formatSrtTime(srtEnd(value.cues))}. La voix lira chaque
sous-titre à son instant et accélérera si besoin pour tenir dans sa durée.
</p>
<ul className="max-h-40 overflow-y-auto space-y-1 text-xs">
{value.cues.map((cue, i) => (
<li key={i} className="flex gap-2">
<span className="shrink-0 font-mono text-muted-foreground">
{formatSrtTime(cue.start)}–{formatSrtTime(cue.end)}
</span>
<span>{cue.text}</span>
</li>
))}
</ul>
</div>
) : (
<Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()}>
<Upload className="w-4 h-4 mr-2" />
Importer un fichier SRT
</Button>
)}
</div>
);
}
+30 -15
View File
@@ -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<Media | null>(null);
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
const [overlayText, setOverlayText] = useState('');
// Fichier SRT : remplace le texte libre, la voix suit son minutage
const [srtFile, setSrtFile] = useState<SrtFile | null>(null);
const [productInfo, setProductInfo] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
@@ -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<SocialPage[]>({
@@ -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' && (
<div className="space-y-6">
{!srtFile && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center"><Sparkles className="w-4 h-4 mr-2 text-primary" /> Assistant IA</CardTitle>
@@ -446,8 +452,9 @@ export default function MobileNewReel() {
</Button>
</CardContent>
</Card>
)}
{generatedVariants.length > 0 && (
{!srtFile && generatedVariants.length > 0 && (
<div className="space-y-3">
{generatedVariants.map((v, i) => (
<div key={i} className="bg-card p-3 rounded-lg border text-sm" onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}>
@@ -459,13 +466,20 @@ export default function MobileNewReel() {
<div className="space-y-2">
<label className="text-sm font-medium">Texte Overlay</label>
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Texte sur la vidéo..."
className="text-lg"
rows={3}
/>
<SrtUpload value={srtFile} onChange={setSrtFile} />
{srtFile ? (
<p className="text-xs text-muted-foreground">
Texte libre désactivé : le fichier SRT est utilisé. Retirez-le pour écrire un texte.
</p>
) : (
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Texte sur la vidéo..."
className="text-lg"
rows={3}
/>
)}
<div className="space-y-1.5 mt-3">
<Label className="text-xs font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
@@ -503,13 +517,13 @@ export default function MobileNewReel() {
<VoicePicker
value={voiceSettings}
onChange={setVoiceSettings}
sampleText={overlayText}
sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
onPreview={setVoicePreview}
compact
/>
</div>
{syncInfo && (
{syncInfo && !srtFile && (
<div className={`p-2 rounded-lg border mt-2 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
<div className="flex items-center justify-between text-xs">
<span className="font-medium">Sync</span>
@@ -549,7 +563,8 @@ export default function MobileNewReel() {
<ReelPreview
kind="video"
videoUrl={selectedVideo.originalUrl}
text={overlayText}
text={srtFile ? srtText(srtFile.cues) : overlayText}
srtCues={srtFile?.cues}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
+38 -18
View File
@@ -28,6 +28,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, getErrorMessage } from "@/lib/queryClient";
@@ -65,6 +67,8 @@ export default function NewReel() {
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
const [overlayText, setOverlayText] = useState('');
// Fichier SRT : remplace le texte libre, la voix suit son minutage
const [srtFile, setSrtFile] = useState<SrtFile | null>(null);
const [productInfo, setProductInfo] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
@@ -108,7 +112,7 @@ export default function NewReel() {
// Auto-calculate TTS sync when text changes
useEffect(() => {
if (!ttsEnabled || !overlayText.trim()) {
if (!ttsEnabled || !overlayText.trim() || srtFile) {
setSyncInfo(null);
return;
}
@@ -123,7 +127,7 @@ export default function NewReel() {
.catch(() => setSyncInfo(null));
}, 800);
return () => clearTimeout(timer);
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]);
// État audio preview
@@ -405,8 +409,9 @@ export default function NewReel() {
createReelMutation.mutate({
videoMediaId: selectedVideoId,
musicTrackId: selectedTrack?.id,
overlayText: overlayText,
description: overlayText,
overlayText: srtFile ? undefined : overlayText,
srtCues: srtFile?.cues,
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100,
@@ -702,6 +707,7 @@ export default function NewReel() {
{/* ÉTAPE 3: Texte */}
{currentStep === 'text' && (
<>
{!srtFile && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -729,8 +735,9 @@ export default function NewReel() {
</Button>
</CardContent>
</Card>
)}
{generatedVariants.length > 0 && (
{!srtFile && generatedVariants.length > 0 && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Variations générées</CardTitle>
@@ -770,16 +777,26 @@ export default function NewReel() {
Texte Overlay
</CardTitle>
<CardDescription>
Ce texte s'affichera au centre de votre Reel (style TikTok)
Ce texte s'affichera au centre de votre Reel (style TikTok).
Vous pouvez aussi importer un fichier SRT : il remplace le texte.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Écrivez le texte qui apparaîtra sur votre Reel..."
rows={4}
/>
<div className="mb-4">
<SrtUpload value={srtFile} onChange={setSrtFile} />
</div>
{srtFile ? (
<p className="text-xs text-muted-foreground">
Texte libre désactivé : le fichier SRT est utilisé. Retirez-le pour écrire un texte.
</p>
) : (
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Écrivez le texte qui apparaîtra sur votre Reel..."
rows={4}
/>
)}
<div className="flex items-center space-x-2 mt-4">
<Switch
@@ -834,12 +851,12 @@ export default function NewReel() {
<VoicePicker
value={voiceSettings}
onChange={setVoiceSettings}
sampleText={overlayText}
sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
onPreview={setVoicePreview}
/>
</div>
{syncInfo && (
{syncInfo && !srtFile && (
<div className={`p-3 rounded-lg border mt-3 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Sync texte/voix</span>
@@ -857,8 +874,10 @@ export default function NewReel() {
)}
<p className="text-xs text-muted-foreground mt-2">
Le texte sera automatiquement synchronisé avec la voix.
Les #hashtags et émojis ne seront pas lus.
{srtFile
? "Chaque sous-titre sera lu à son instant et accéléré si besoin pour respecter la durée du fichier SRT."
: "Le texte sera automatiquement synchronisé avec la voix."}
{' '}Les #hashtags et émojis ne seront pas lus.
</p>
</div>
)}
@@ -984,7 +1003,8 @@ export default function NewReel() {
<ReelPreview
kind="video"
videoUrl={selectedVideo.originalUrl}
text={overlayText}
text={srtFile ? srtText(srtFile.cues) : overlayText}
srtCues={srtFile?.cues}
showCaptions={drawText}
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
@@ -1022,7 +1042,7 @@ export default function NewReel() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Texte</span>
<span>{overlayText ? '✓' : '—'}</span>
<span>{srtFile ? `SRT (${srtFile.cues.length})` : overlayText ? '✓' : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pages</span>