import { useEffect, useRef, useState } from "react"; import { Download, RefreshCw, Save } from "lucide-react"; import type { SubtitleLanguage } from "@/types/settings/settings"; import type { DialogueDefinition, DialogueManifest, DialogueSpeaker, DialogueVoiceId, } from "@/types/dialogues/dialogues"; import { logger } from "@/utils/core/Logger"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { parseSrt, parseSrtTime, parseSrtWithDiagnostics, } from "@/utils/subtitles/parseSrt"; interface SrtVoiceOption { id: DialogueVoiceId; label: DialogueSpeaker; } interface SrtDiagnostic { cueCount: number; expectedCueCount: number; errors: string[]; } interface TextRange { start: number; end: number; } interface DialogueValidationResult { valid: boolean; errors: string[]; warnings: string[]; } type CueTimeEdge = "start" | "end"; const CUE_NUDGE_SECONDS = 0.1; const SRT_VOICES: SrtVoiceOption[] = [ { id: "narrateur", label: "Narrateur" }, { id: "fermier", label: "Fermier" }, { id: "electricienne", label: "Electricienne" }, ]; const DEFAULT_SRT_VOICE: SrtVoiceOption = { id: "narrateur", label: "Narrateur", }; const SRT_LANGUAGES: SubtitleLanguage[] = ["fr", "en"]; const SRT_TIME_LINE_PATTERN = /^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}$/; function getSrtPath( voice: DialogueVoiceId, language: SubtitleLanguage, ): string { return `/sounds/dialogue/subtitles/${language}/${voice}.srt`; } function createSrtTemplate( speaker: DialogueSpeaker, expectedCueIndexes: number[], ): string { const cueIndexes = expectedCueIndexes.length > 0 ? expectedCueIndexes : [1]; return `${cueIndexes .map((cueIndex, index) => { const startTime = index * 3; const endTime = startTime + 2; return `${cueIndex}\n${formatSrtTime(startTime)} --> ${formatSrtTime(endTime)}\n${speaker}: Sous-titre ${cueIndex} a definir`; }) .join("\n\n")}\n`; } function formatSrtTime(totalSeconds: number): string { const safeSeconds = Math.max(0, totalSeconds); const totalMilliseconds = Math.round(safeSeconds * 1000); const milliseconds = totalMilliseconds % 1000; const totalWholeSeconds = Math.floor(totalMilliseconds / 1000); const hours = Math.floor(totalWholeSeconds / 3600); const minutes = Math.floor((totalWholeSeconds % 3600) / 60); const seconds = totalWholeSeconds % 60; return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)},${padMilliseconds(milliseconds)}`; } function formatPreviewTime(totalSeconds: number): string { return `${Math.max(0, totalSeconds).toFixed(1)}s`; } function padTime(value: number): string { return value.toString().padStart(2, "0"); } function padMilliseconds(value: number): string { return value.toString().padStart(3, "0"); } function getSrtDiagnostic( content: string, expectedCueIndexes: number[], ): SrtDiagnostic { const normalizedContent = content.replace(/^\uFEFF/, "").replace(/\r/g, ""); const blocks = normalizedContent .trim() .split(/\n{2,}/) .filter(Boolean); const { cues, diagnostics } = parseSrtWithDiagnostics(content); const errors: string[] = []; const indexes = new Set(); if (blocks.length === 0) { errors.push("Le fichier SRT est vide."); } blocks.forEach((block, blockIndex) => { const lines = block .split("\n") .map((line) => line.trim()) .filter(Boolean); const displayIndex = blockIndex + 1; const cueIndex = Number(lines[0]); if (lines.length < 3) { errors.push( `Bloc ${displayIndex}: il manque un index, un timecode ou un texte.`, ); return; } if (!Number.isInteger(cueIndex)) { errors.push(`Bloc ${displayIndex}: l'index doit etre un nombre entier.`); } else if (indexes.has(cueIndex)) { errors.push(`Bloc ${displayIndex}: l'index ${cueIndex} est duplique.`); } else { indexes.add(cueIndex); } if (!SRT_TIME_LINE_PATTERN.test(lines[1] ?? "")) { errors.push( `Bloc ${displayIndex}: le timecode doit utiliser HH:MM:SS,mmm --> HH:MM:SS,mmm.`, ); } }); if (blocks.length > 0 && cues.length !== blocks.length) { errors.push( "Un ou plusieurs blocs ont une duree invalide ou un timecode illisible.", ); } for (const diagnostic of diagnostics) { errors.push(`Bloc ${diagnostic.blockIndex + 1}: ${diagnostic.reason}.`); } const cueIndexes = new Set(cues.map((cue) => cue.index)); const missingCueIndexes = expectedCueIndexes.filter( (cueIndex) => !cueIndexes.has(cueIndex), ); if (missingCueIndexes.length > 0) { errors.push( `Cues attendues par le manifeste manquantes: ${missingCueIndexes.join(", ")}.`, ); } return { cueCount: cues.length, expectedCueCount: expectedCueIndexes.length, errors, }; } function getExpectedCueIndexes( manifest: DialogueManifest | null, voice: DialogueVoiceId, ): number[] { return getExpectedDialogues(manifest, voice) .map((dialogue) => dialogue.subtitleCueIndex) .filter( (cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index, ) .sort((a, b) => a - b); } function getExpectedDialogues( manifest: DialogueManifest | null, voice: DialogueVoiceId, ): DialogueDefinition[] { if (!manifest) return []; return [...manifest.dialogues] .filter((dialogue) => dialogue.voice === voice) .sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex); } function findCueBlockRange( content: string, cueIndex: number, ): TextRange | null { const normalizedContent = content.replace(/\r/g, ""); const cuePattern = new RegExp(`(^|\\n)${cueIndex}\\n`, "m"); const match = normalizedContent.match(cuePattern); if (!match || match.index === undefined) return null; const start = match.index + (match[1] ? 1 : 0); const nextBlockIndex = normalizedContent.indexOf("\n\n", start); const end = nextBlockIndex === -1 ? normalizedContent.length : nextBlockIndex; return { start, end }; } function updateCueTimecode( content: string, cueIndex: number, edge: CueTimeEdge, time: number, ): string | null { const range = findCueBlockRange(content, cueIndex); if (!range) return null; const block = content.slice(range.start, range.end); const lines = block.split("\n"); const timecodeLine = lines[1]; if (!timecodeLine) return null; const [start, end] = timecodeLine.split(" --> "); if (!start || !end) return null; lines[1] = edge === "start" ? `${formatSrtTime(time)} --> ${end}` : `${start} --> ${formatSrtTime(time)}`; return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`; } function nudgeCueTimecode( content: string, cueIndex: number, delta: number, ): string | null { const range = findCueBlockRange(content, cueIndex); if (!range) return null; const block = content.slice(range.start, range.end); const lines = block.split("\n"); const timecodeLine = lines[1]; if (!timecodeLine) return null; const [start, end] = timecodeLine.split(" --> "); if (!start || !end) return null; const startTime = parseSrtTime(start); const endTime = parseSrtTime(end); if (startTime === null || endTime === null) return null; const nextStartTime = Math.max(0, startTime + delta); const nextEndTime = Math.max(nextStartTime + 0.001, endTime + delta); lines[1] = `${formatSrtTime(nextStartTime)} --> ${formatSrtTime(nextEndTime)}`; return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`; } function downloadSrtFile( voice: DialogueVoiceId, language: SubtitleLanguage, content: string, ): void { const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${voice}.${language}.srt`; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 0); } async function saveSrtFile( voice: DialogueVoiceId, language: SubtitleLanguage, content: string, ): Promise { const response = await fetch("/api/save-srt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ voice, language, content }), }); if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string; } | null; throw new Error(body?.error ?? "Sauvegarde SRT impossible"); } } async function validateDialogueAssets(): Promise { const response = await fetch("/api/validate-dialogues"); const body = (await response.json().catch(() => null)) as | Partial | { error?: string } | null; if (!body) { throw new Error("Validation dialogues impossible"); } if ( "valid" in body && typeof body.valid === "boolean" && Array.isArray(body.errors) && Array.isArray(body.warnings) ) { return { valid: body.valid, errors: body.errors.filter((item) => typeof item === "string"), warnings: body.warnings.filter((item) => typeof item === "string"), }; } throw new Error( "error" in body && body.error ? body.error : "Validation dialogues impossible", ); } export function EditorSrtPanel(): React.JSX.Element { const textareaRef = useRef(null); const [voice, setVoice] = useState("narrateur"); const [language, setLanguage] = useState("fr"); const [content, setContent] = useState(""); const [status, setStatus] = useState("Chargement du SRT..."); const [isSaving, setIsSaving] = useState(false); const [isValidatingDialogues, setIsValidatingDialogues] = useState(false); const [dialogueValidationResult, setDialogueValidationResult] = useState(null); const [manifest, setManifest] = useState(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [selectedDialogueId, setSelectedDialogueId] = useState(""); const selectedVoice = SRT_VOICES.find((item) => item.id === voice) ?? DEFAULT_SRT_VOICE; const expectedDialogues = getExpectedDialogues(manifest, voice); const expectedCueIndexes = getExpectedCueIndexes(manifest, voice); const parsedCues = parseSrt(content); const activeCue = parsedCues.find( (cue) => audioCurrentTime >= cue.startTime && audioCurrentTime < cue.endTime, ) ?? null; const diagnostic = getSrtDiagnostic(content, expectedCueIndexes); const isSrtValid = diagnostic.errors.length === 0; const dialogueValidationClass = dialogueValidationResult ? dialogueValidationResult.valid ? "is-valid" : "is-invalid" : "is-idle"; const srtTemplate = createSrtTemplate( selectedVoice.label, expectedCueIndexes, ); const selectedDialogue = expectedDialogues.find((dialogue) => dialogue.id === selectedDialogueId) ?? expectedDialogues[0] ?? null; async function handleSave(): Promise { if (!isSrtValid) { setStatus("Corrige les erreurs SRT avant de sauvegarder."); return; } setIsSaving(true); setStatus("Sauvegarde du SRT..."); try { await saveSrtFile(voice, language, content); setStatus(`Sauvegarde dans ${getSrtPath(voice, language)}`); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(`${message}. Utilise Export SRT si le serveur dev est absent.`); } finally { setIsSaving(false); } } async function handleValidateDialogues(): Promise { setIsValidatingDialogues(true); setDialogueValidationResult(null); try { const result = await validateDialogueAssets(); setDialogueValidationResult(result); setStatus( result.valid ? "Validation dialogues terminee." : "Validation dialogues terminee avec erreurs.", ); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(`${message}. Verifie que le serveur Vite est lance.`); } finally { setIsValidatingDialogues(false); } } function handleJumpToCue(cueIndex: number): void { const range = findCueBlockRange(content, cueIndex); if (!range || !textareaRef.current) { setStatus(`Cue ${cueIndex} introuvable dans le SRT.`); return; } textareaRef.current.focus(); textareaRef.current.setSelectionRange(range.start, range.end); setStatus(`Cue ${cueIndex} selectionnee dans le SRT.`); } function handleSetCueTime(cueIndex: number, edge: CueTimeEdge): void { const updatedContent = updateCueTimecode( content, cueIndex, edge, audioCurrentTime, ); if (!updatedContent) { setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`); return; } setContent(updatedContent); setStatus( `Cue ${cueIndex}: ${edge === "start" ? "debut" : "fin"} place a ${formatSrtTime(audioCurrentTime)}.`, ); } function handleNudgeCue(cueIndex: number, delta: number): void { const updatedContent = nudgeCueTimecode(content, cueIndex, delta); if (!updatedContent) { setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`); return; } setContent(updatedContent); setStatus( `Cue ${cueIndex} decalee de ${delta > 0 ? "+" : ""}${delta.toFixed(1)}s.`, ); } useEffect(() => { let mounted = true; void loadDialogueManifest() .then((loadedManifest) => { if (mounted) setManifest(loadedManifest); }) .catch((error) => { if (!mounted) return; setManifest(null); setStatus("Erreur de chargement du manifeste dialogues"); logger.error("EditorSrt", "Failed to load dialogue manifest", { error: error instanceof Error ? error : String(error), }); }); return () => { mounted = false; }; }, []); useEffect(() => { let mounted = true; const srtPath = getSrtPath(voice, language); void fetch(srtPath) .then(async (response) => { if (!mounted) return; if (!response.ok) { setContent(srtTemplate); setStatus("Fichier absent, template local cree"); return; } setContent(await response.text()); setStatus(`Charge depuis ${srtPath}`); }) .catch((error: unknown) => { if (!mounted) return; setContent(srtTemplate); setStatus( `Erreur de chargement, template local cree: ${error instanceof Error ? error.message : "Erreur inconnue"}`, ); logger.warn("EditorSrt", "Falling back to local SRT template", { srtPath, error: error instanceof Error ? error : String(error), }); }); return () => { mounted = false; }; }, [language, selectedVoice.label, srtTemplate, voice]); return (

SRT

{language.toUpperCase()}
{selectedDialogue && (
Cue {selectedDialogue.subtitleCueIndex} {selectedDialogue.id}
)}