/** * Sous-titres et minutage des Reels, partagés entre l'aperçu (navigateur), * le rendu Remotion (serveur) et les tests. * Les règles de durée reprennent celles du service Python (app/render.py). */ export const CAPTION_STYLES = ["bold", "neon", "minimal"] as const; export type CaptionStyle = (typeof CAPTION_STYLES)[number]; export const CAPTION_STYLE_OPTIONS: { id: CaptionStyle; label: string; description: string }[] = [ { id: "bold", label: "Impact", description: "Majuscules, mot prononcé en jaune" }, { id: "neon", label: "Surligné", description: "Mot prononcé sur fond coloré" }, { id: "minimal", label: "Épuré", description: "Texte qui se révèle au fil de la voix" }, ]; export const DEFAULT_CAPTION_STYLE: CaptionStyle = "bold"; /** Mot affiché et son intervalle, en secondes depuis le début de la vidéo. */ export interface TimedWord { text: string; start: number; end: number; } export const REEL_FPS = 30; export const REEL_WIDTH = 1080; export const REEL_HEIGHT = 1920; /** La voix démarre après ce délai (le temps de capter l'attention). */ export const VOICE_DELAY = 2; const VOICE_TAIL = 0.8; const OUTRO_MIN = 2.5; const LOGO_SECONDS = 5; export const FADE_SECONDS = 2; /** Fondu raccourci quand la voix finit tard, mais jamais sec. */ const MIN_FADE = 0.3; export interface ReelTiming { total: number; /** Début de l'effet de fin (grand logo), ou null sans effet de fin. */ logoStart: number | null; fadeStart: number | null; fadeDuration: number | null; } /** Durée finale : la vidéo s'allonge (dernière image figée) si la voix dépasse. */ export function computeReelTiming(input: { videoDuration: number; 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 ? (input.voiceDelay ?? VOICE_DELAY) + input.voiceDuration : 0; let total = input.videoDuration; if (input.voiceDuration) { total = Math.max(total, speechEnd + VOICE_TAIL); if (hasOutro) total = Math.max(total, speechEnd + OUTRO_MIN); } total = Math.round(total * 1000) / 1000; // Le fondu final ne commence jamais avant la fin de la voix let fadeStart = Math.max(0, total - FADE_SECONDS); if (input.voiceDuration) fadeStart = Math.max(fadeStart, Math.min(speechEnd + 0.15, total - MIN_FADE)); fadeStart = Math.round(fadeStart * 1000) / 1000; return { total, logoStart: hasOutro ? Math.max(0, total - LOGO_SECONDS, speechEnd) : null, fadeStart: input.endingEffect ? fadeStart : null, fadeDuration: input.endingEffect ? Math.max(MIN_FADE, total - fadeStart) : null, }; } const MAX_WORDS_PER_LINE = 3; const MAX_CHARS_PER_LINE = 18; const LINE_BREAK = /[.!?,:;…]$/; /** Découpe en lignes courtes, coupées de préférence à la ponctuation. */ export function groupLines(words: TimedWord[]): TimedWord[][] { const lines: TimedWord[][] = []; let current: TimedWord[] = []; for (const word of words) { const chars = current.reduce((n, w) => n + w.text.length + 1, 0) + word.text.length; if (current.length && (current.length >= MAX_WORDS_PER_LINE || chars > MAX_CHARS_PER_LINE)) { lines.push(current); current = []; } current.push(word); if (LINE_BREAK.test(word.text)) { lines.push(current); current = []; } } if (current.length) lines.push(current); return lines; } /** Retire emojis, hashtags et liens : ce qui n'est ni lu ni affiché. */ export function cleanCaptionText(text: string): string { return text .replace(/https?:\/\/\S+/g, " ") .replace(/#[\wÀ-ÿ]+/g, " ") .replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, " ") .replace(/[☀-➿⬀-⯿️]/g, " ") .replace(/\s+/g, " ") .trim(); } /** * Répartit un texte sur un intervalle au prorata de la longueur des mots. * Sert d'aperçu tant que la voix n'a pas été générée. */ export function spreadWords(text: string, start: number, end: number): TimedWord[] { const tokens = cleanCaptionText(text).split(" ").filter(Boolean); if (!tokens.length || end <= start) return []; const weights = tokens.map((t) => Math.max(1, t.replace(/[^\wÀ-ÿ]/g, "").length)); const totalWeight = weights.reduce((a, b) => a + b, 0); let cursor = start; return tokens.map((text, i) => { const duration = ((end - start) * weights[i]) / totalWeight; const word = { text, start: cursor, end: cursor + duration }; cursor += duration; return word; }); } /** Décale des mots minutés depuis le début de la voix vers le temps de la vidéo. */ export function offsetWords(words: TimedWord[], offset: number): TimedWord[] { return words.map((w) => ({ ...w, start: w.start + offset, end: w.end + offset })); }