Files
Socialflow/shared/srt.ts
T
Claude f5e2461da1 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
2026-09-27 08:19:44 +00:00

95 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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")}`;
}