mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
- 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
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
/**
|
||
* Fichiers de sous-titres SRT : lecture côté navigateur, validation côté
|
||
* serveur et minutage de l'aperçu. Quand un SRT est fourni, il remplace le
|
||
* texte libre : la voix lit chaque sous-titre à son instant.
|
||
*/
|
||
|
||
import { z } from "zod";
|
||
import { cleanCaptionText, spreadWords, type TimedWord } from "./captions";
|
||
|
||
export const MAX_SRT_CUES = 300;
|
||
|
||
export const srtCueSchema = z
|
||
.object({
|
||
start: z.number().min(0),
|
||
end: z.number().min(0),
|
||
text: z.string().trim().min(1).max(500),
|
||
})
|
||
.refine((cue) => cue.end > cue.start, { message: "Sous-titre de durée nulle" });
|
||
|
||
export const srtCuesSchema = z
|
||
.array(srtCueSchema)
|
||
.min(1, "Fichier SRT vide")
|
||
.max(MAX_SRT_CUES, `Fichier SRT trop long (${MAX_SRT_CUES} sous-titres au plus)`);
|
||
|
||
export type SrtCue = z.infer<typeof srtCueSchema>;
|
||
|
||
const TIMING = /(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})/;
|
||
|
||
function toSeconds(h: string, m: string, s: string, ms: string): number {
|
||
return Number(h) * 3600 + Number(m) * 60 + Number(s) + Number(ms.padEnd(3, "0")) / 1000;
|
||
}
|
||
|
||
/**
|
||
* Lit un fichier SRT. Les balises (<i>, {\an8}…) sont retirées, les lignes
|
||
* d'un même sous-titre réunies ; les sous-titres sans texte lisible sont
|
||
* ignorés. Lève une erreur lisible si aucun sous-titre n'est trouvé.
|
||
*/
|
||
export function parseSrt(content: string): SrtCue[] {
|
||
const blocks = content
|
||
.replace(/^/, "")
|
||
.replace(/\r\n?/g, "\n")
|
||
.split(/\n\s*\n/);
|
||
|
||
const cues: SrtCue[] = [];
|
||
for (const block of blocks) {
|
||
const lines = block.split("\n").map((l) => l.trim()).filter(Boolean);
|
||
const timingIndex = lines.findIndex((l) => TIMING.test(l));
|
||
if (timingIndex < 0) continue;
|
||
const match = lines[timingIndex].match(TIMING)!;
|
||
const start = toSeconds(match[1], match[2], match[3], match[4]);
|
||
const end = toSeconds(match[5], match[6], match[7], match[8]);
|
||
const text = lines
|
||
.slice(timingIndex + 1)
|
||
.join(" ")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/\{[^}]*\}/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
if (!text || end <= start || !cleanCaptionText(text)) continue;
|
||
cues.push({ start: round(start), end: round(end), text });
|
||
}
|
||
|
||
if (!cues.length) throw new Error("Aucun sous-titre valide trouvé dans ce fichier SRT");
|
||
if (cues.length > MAX_SRT_CUES) {
|
||
throw new Error(`Fichier SRT trop long (${MAX_SRT_CUES} sous-titres au plus)`);
|
||
}
|
||
return cues.sort((a, b) => a.start - b.start);
|
||
}
|
||
|
||
function round(seconds: number): number {
|
||
return Math.round(seconds * 1000) / 1000;
|
||
}
|
||
|
||
/** Texte complet du SRT (description de la publication, aperçu de la voix). */
|
||
export function srtText(cues: SrtCue[]): string {
|
||
return cues.map((c) => c.text).join(" ");
|
||
}
|
||
|
||
/** Fin du dernier sous-titre : la vidéo dure au moins jusque-là. */
|
||
export function srtEnd(cues: SrtCue[]): number {
|
||
return cues.reduce((max, c) => Math.max(max, c.end), 0);
|
||
}
|
||
|
||
/** Mots de chaque sous-titre répartis sur son intervalle (aperçu, rendu sans voix). */
|
||
export function srtWords(cues: SrtCue[]): TimedWord[] {
|
||
return cues.flatMap((c) => spreadWords(c.text, c.start, c.end));
|
||
}
|
||
|
||
/** « 1:05 » pour l'affichage. */
|
||
export function formatSrtTime(seconds: number): string {
|
||
const m = Math.floor(seconds / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
return `${m}:${String(s).padStart(2, "0")}`;
|
||
}
|