mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 13:38:10 +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
+3
-1
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")}`;
|
||||
}
|
||||
Reference in new issue
Block a user