mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-19 05:07:08 +02:00
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:
15 files changed
+635
-44
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user