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

+3 -1
View File
@@ -46,9 +46,11 @@ export function computeReelTiming(input: {
voiceDuration?: number;
hasOutro: boolean;
endingEffect: boolean;
/** 0 pour une voix déjà posée aux instants d'un fichier SRT. */
voiceDelay?: number;
}): ReelTiming {
const hasOutro = input.hasOutro && input.endingEffect;
const speechEnd = input.voiceDuration ? VOICE_DELAY + input.voiceDuration : 0;
const speechEnd = input.voiceDuration ? (input.voiceDelay ?? VOICE_DELAY) + input.voiceDuration : 0;
let total = input.videoDuration;
if (input.voiceDuration) {
total = Math.max(total, speechEnd + VOICE_TAIL);
+3
View File
@@ -6,6 +6,7 @@
import { z } from "zod";
import { CAPTION_STYLES, DEFAULT_CAPTION_STYLE } from "./captions";
import { srtCuesSchema } from "./srt";
import { TTS_ENGINES, TTS_STYLES } from "./voices";
const optionalText = z
@@ -23,6 +24,8 @@ export const videoReelParamsSchema = z.object({
musicTrackId: optionalText,
musicUrl: optionalText,
overlayText: optionalText,
// Sous-titres SRT : remplacent le texte libre, la voix lit chacun à son instant
srtCues: srtCuesSchema.optional(),
description: optionalText,
ttsEnabled: z.boolean().default(false),
ttsVoice: optionalText,
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { parseSrt, srtEnd, srtText, srtWords } from "./srt";
import { videoReelParamsSchema } from "./reel";
const SAMPLE = `1
00:00:01,000 --> 00:00:03,500
Bonjour à tous !
2
00:00:04,000 --> 00:00:06,250
<i>Découvrez</i> nos
nouveautés en magasin
3
00:00:07,000 --> 00:00:07,000
Durée nulle, ignoré
`;
describe("parseSrt", () => {
it("lit les sous-titres, retire les balises et réunit les lignes", () => {
const cues = parseSrt(SAMPLE.replace(/\n/g, "\r\n"));
expect(cues).toEqual([
{ start: 1, end: 3.5, text: "Bonjour à tous !" },
{ start: 4, end: 6.25, text: "Découvrez nos nouveautés en magasin" },
]);
expect(srtText(cues)).toBe("Bonjour à tous ! Découvrez nos nouveautés en magasin");
expect(srtEnd(cues)).toBe(6.25);
});
it("accepte les millisecondes séparées par un point et sans numéro", () => {
expect(parseSrt("00:00:02.5 --> 00:00:04.0\nSalut")).toEqual([{ start: 2.5, end: 4, text: "Salut" }]);
});
it("refuse un fichier sans sous-titre", () => {
expect(() => parseSrt("pas un srt")).toThrow(/Aucun sous-titre/);
});
it("répartit les mots dans l'intervalle de leur sous-titre", () => {
const words = srtWords(parseSrt(SAMPLE));
const second = words.findIndex((w) => w.text === "Découvrez");
expect(words[0].start).toBe(1);
expect(words[second - 1].end).toBeCloseTo(3.5);
expect(words[second].start).toBe(4);
expect(words[words.length - 1].end).toBeCloseTo(6.25);
});
});
describe("videoReelParamsSchema avec SRT", () => {
it("accepte des sous-titres valides et refuse une durée nulle", () => {
const base = { videoMediaId: "v", pageIds: ["p"] };
expect(videoReelParamsSchema.safeParse({ ...base, srtCues: [{ start: 0, end: 1, text: "a" }] }).success).toBe(true);
expect(videoReelParamsSchema.safeParse({ ...base, srtCues: [{ start: 1, end: 1, text: "a" }] }).success).toBe(false);
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* 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")}`;
}