diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json index af596ef..0fd116f 100644 --- a/public/sounds/dialogue/dialogues.json +++ b/public/sounds/dialogue/dialogues.json @@ -38,7 +38,7 @@ "id": "narrateur_intro_prenom", "voice": "narrateur", "audio": "/sounds/dialogue/narrateur_intro_prenom.mp3", - "subtitleCueIndex": 2 + "subtitleCueIndices": [1, 2] }, { "id": "narrateur_intro_apresprenom", diff --git a/public/sounds/dialogue/subtitles/fr/narrateur.srt b/public/sounds/dialogue/subtitles/fr/narrateur.srt index ab3b902..cbb89c2 100644 --- a/public/sounds/dialogue/subtitles/fr/narrateur.srt +++ b/public/sounds/dialogue/subtitles/fr/narrateur.srt @@ -1,9 +1,9 @@ 1 -00:00:00,000 --> 00:00:02,760 +00:00:00,000 --> 00:00:09,000 Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech. 2 -00:00:00,000 --> 00:00:11,592 +00:00:09,000 --> 00:00:11,592 Avant de commencer, comment tu t'appelles ? 3 diff --git a/src/components/editor/EditorDialogueManifestPanel.tsx b/src/components/editor/EditorDialogueManifestPanel.tsx index 3dcb9bf..132e232 100644 --- a/src/components/editor/EditorDialogueManifestPanel.tsx +++ b/src/components/editor/EditorDialogueManifestPanel.tsx @@ -6,6 +6,10 @@ import type { DialogueSpeaker, DialogueVoiceId, } from "@/types/dialogues/dialogues"; +import { + getDialogueCueIndices, + getDialogueFirstCueIndex, +} from "@/types/dialogues/dialogues"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { parseSrt } from "@/utils/subtitles/parseSrt"; @@ -34,7 +38,7 @@ function getNextCueIndex( ): number { const cueIndexes = manifest.dialogues .filter((dialogue) => dialogue.voice === voice) - .map((dialogue) => dialogue.subtitleCueIndex); + .flatMap((dialogue) => getDialogueCueIndices(dialogue)); return Math.max(0, ...cueIndexes) + 1; } @@ -93,12 +97,15 @@ async function createFrenchSrtCue( manifest: DialogueManifest, dialogue: DialogueDefinition, ): Promise { + const firstCueIndex = getDialogueFirstCueIndex(dialogue); + if (firstCueIndex === undefined) return; + const srtPath = getFrenchSrtPath(dialogue.voice); const response = await fetch(srtPath); const content = response.ok ? await response.text() : ""; const nextContent = appendSrtCueIfMissing( content, - dialogue.subtitleCueIndex, + firstCueIndex, getVoiceSpeaker(manifest, dialogue.voice), ); @@ -122,7 +129,8 @@ function getManifestErrors(manifest: DialogueManifest | null): string[] { errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`); } - if (!Number.isInteger(dialogue.subtitleCueIndex)) { + const cueIndices = getDialogueCueIndices(dialogue); + if (cueIndices.length === 0) { errors.push(`${label}: cue SRT invalide.`); } @@ -160,9 +168,18 @@ function getPatchedDialogue( id: patch.id ?? dialogue.id, voice: patch.voice ?? dialogue.voice, audio: patch.audio ?? dialogue.audio, - subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex, }; + if (patch.subtitleCueIndex !== undefined) { + nextDialogue.subtitleCueIndex = patch.subtitleCueIndex; + } else if (dialogue.subtitleCueIndex !== undefined) { + nextDialogue.subtitleCueIndex = dialogue.subtitleCueIndex; + } + + if (dialogue.subtitleCueIndices !== undefined) { + nextDialogue.subtitleCueIndices = dialogue.subtitleCueIndices; + } + if ("timecode" in patch) { if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode; } else if (dialogue.timecode !== undefined) { @@ -252,8 +269,9 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { try { await createFrenchSrtCue(nextManifest, dialogue); + const cueIndex = getDialogueFirstCueIndex(dialogue) ?? "?"; setStatus( - `Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`, + `Nouveau dialogue ajoute avec cue FR ${cueIndex}. Sauvegarde le manifeste pour le garder.`, ); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; @@ -333,12 +351,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { async function handleCreateFrenchSrtCue(): Promise { if (!manifest || !selectedDialogue) return; + const cueIndex = getDialogueFirstCueIndex(selectedDialogue) ?? "?"; setIsCreatingSrtCue(true); - setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`); + setStatus(`Creation de la cue FR ${cueIndex}...`); try { await createFrenchSrtCue(manifest, selectedDialogue); - setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`); + setStatus(`Cue FR ${cueIndex} prete.`); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); @@ -478,7 +497,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { type="number" min="1" step="1" - value={selectedDialogue.subtitleCueIndex} + value={getDialogueFirstCueIndex(selectedDialogue) ?? ""} onChange={(event) => updateSelectedDialogue({ subtitleCueIndex: Math.max(1, Number(event.target.value)), diff --git a/src/components/editor/EditorSrtPanel.tsx b/src/components/editor/EditorSrtPanel.tsx index 78950ef..9ff26ec 100644 --- a/src/components/editor/EditorSrtPanel.tsx +++ b/src/components/editor/EditorSrtPanel.tsx @@ -7,6 +7,10 @@ import type { DialogueSpeaker, DialogueVoiceId, } from "@/types/dialogues/dialogues"; +import { + getDialogueCueIndices, + getDialogueFirstCueIndex, +} from "@/types/dialogues/dialogues"; import { logger } from "@/utils/core/Logger"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { @@ -181,7 +185,7 @@ function getExpectedCueIndexes( voice: DialogueVoiceId, ): number[] { return getExpectedDialogues(manifest, voice) - .map((dialogue) => dialogue.subtitleCueIndex) + .flatMap((dialogue) => getDialogueCueIndices(dialogue)) .filter( (cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index, ) @@ -196,7 +200,11 @@ function getExpectedDialogues( return [...manifest.dialogues] .filter((dialogue) => dialogue.voice === voice) - .sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex); + .sort((a, b) => { + const aIndex = getDialogueFirstCueIndex(a) ?? 0; + const bIndex = getDialogueFirstCueIndex(b) ?? 0; + return aIndex - bIndex; + }); } function findCueBlockRange( @@ -577,7 +585,7 @@ export function EditorSrtPanel(): React.JSX.Element { )} {expectedDialogues.map((dialogue) => ( ))} @@ -585,7 +593,7 @@ export function EditorSrtPanel(): React.JSX.Element { {selectedDialogue && (
- Cue {selectedDialogue.subtitleCueIndex} + Cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"} {selectedDialogue.id}
)} diff --git a/src/types/dialogues/dialogues.ts b/src/types/dialogues/dialogues.ts index b95ef73..4a4785e 100644 --- a/src/types/dialogues/dialogues.ts +++ b/src/types/dialogues/dialogues.ts @@ -13,7 +13,8 @@ export interface DialogueDefinition { id: string; voice: DialogueVoiceId; audio: string; - subtitleCueIndex: number; + subtitleCueIndex?: number; + subtitleCueIndices?: number[]; timecode?: number; } @@ -22,3 +23,20 @@ export interface DialogueManifest { voices: DialogueVoice[]; dialogues: DialogueDefinition[]; } + +export function getDialogueCueIndices(dialogue: DialogueDefinition): number[] { + if (dialogue.subtitleCueIndices && dialogue.subtitleCueIndices.length > 0) { + return dialogue.subtitleCueIndices; + } + if (dialogue.subtitleCueIndex !== undefined) { + return [dialogue.subtitleCueIndex]; + } + return []; +} + +export function getDialogueFirstCueIndex( + dialogue: DialogueDefinition, +): number | undefined { + const indices = getDialogueCueIndices(dialogue); + return indices[0]; +} diff --git a/src/utils/dialogues/dialogueManifestValidation.ts b/src/utils/dialogues/dialogueManifestValidation.ts index 2a43f08..3ed8822 100644 --- a/src/utils/dialogues/dialogueManifestValidation.ts +++ b/src/utils/dialogues/dialogueManifestValidation.ts @@ -93,13 +93,26 @@ function parseDialogueDefinition( throw new Error(`Dialogue ${data.id} has an invalid audio path`); } + // Support both subtitleCueIndex (legacy) and subtitleCueIndices (new) const subtitleCueIndex = data.subtitleCueIndex; - if ( - typeof subtitleCueIndex !== "number" || - !Number.isInteger(subtitleCueIndex) || - subtitleCueIndex < 1 - ) { - throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`); + const subtitleCueIndices = data.subtitleCueIndices; + + const hasLegacyIndex = + typeof subtitleCueIndex === "number" && + Number.isInteger(subtitleCueIndex) && + subtitleCueIndex >= 1; + + const hasNewIndices = + Array.isArray(subtitleCueIndices) && + subtitleCueIndices.length > 0 && + subtitleCueIndices.every( + (idx) => typeof idx === "number" && Number.isInteger(idx) && idx >= 1, + ); + + if (!hasLegacyIndex && !hasNewIndices) { + throw new Error( + `Dialogue ${data.id} must have subtitleCueIndex or subtitleCueIndices`, + ); } const timecode = data.timecode; @@ -111,9 +124,14 @@ function parseDialogueDefinition( id: data.id, voice: data.voice, audio: data.audio, - subtitleCueIndex, }; + if (hasNewIndices) { + dialogue.subtitleCueIndices = subtitleCueIndices as number[]; + } else if (hasLegacyIndex) { + dialogue.subtitleCueIndex = subtitleCueIndex; + } + if (timecode !== undefined) dialogue.timecode = timecode; return dialogue; diff --git a/src/utils/dialogues/loadDialogueManifest.ts b/src/utils/dialogues/loadDialogueManifest.ts index 718f4d9..e791bb5 100644 --- a/src/utils/dialogues/loadDialogueManifest.ts +++ b/src/utils/dialogues/loadDialogueManifest.ts @@ -3,6 +3,7 @@ import type { DialogueManifest, DialogueVoice, } from "@/types/dialogues/dialogues"; +import { getDialogueCueIndices } from "@/types/dialogues/dialogues"; import type { SubtitleLanguage } from "@/types/settings/settings"; import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation"; import { parseSrt } from "@/utils/subtitles/parseSrt"; @@ -17,6 +18,15 @@ export interface DialogueSubtitleCue { subtitlePath: string; } +/** + * Multiple subtitle cues for a single dialogue + */ +export interface DialogueSubtitleCues { + voice: DialogueVoice; + cues: SubtitleCue[]; + subtitlePath: string; +} + export async function loadDialogueManifest(): Promise { const response = await fetch(DIALOGUE_MANIFEST_PATH); @@ -39,21 +49,40 @@ export async function loadDialogueSubtitleCue( dialogue: DialogueDefinition, language: SubtitleLanguage, ): Promise { + const result = await loadDialogueSubtitleCues(manifest, dialogue, language); + const firstCue = result?.cues[0]; + if (!result || !firstCue) return null; + + return { + voice: result.voice, + cue: firstCue, + subtitlePath: result.subtitlePath, + }; +} + +export async function loadDialogueSubtitleCues( + manifest: DialogueManifest, + dialogue: DialogueDefinition, + language: SubtitleLanguage, +): Promise { const voice = getDialogueVoice(manifest, dialogue.voice); if (!voice) return null; const subtitles = await loadVoiceSubtitleCues(voice, language); if (!subtitles) return null; - const cue = subtitles.cues.find( - (item) => item.index === dialogue.subtitleCueIndex, - ); + const cueIndices = getDialogueCueIndices(dialogue); + if (cueIndices.length === 0) return null; - if (!cue) return null; + const cues = cueIndices + .map((index) => subtitles.cues.find((item) => item.index === index)) + .filter((cue): cue is SubtitleCue => cue !== undefined); + + if (cues.length === 0) return null; return { voice, - cue, + cues, subtitlePath: subtitles.path, }; } diff --git a/src/utils/dialogues/playDialogue.ts b/src/utils/dialogues/playDialogue.ts index fd850d8..6943a17 100644 --- a/src/utils/dialogues/playDialogue.ts +++ b/src/utils/dialogues/playDialogue.ts @@ -3,7 +3,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import type { DialogueManifest } from "@/types/dialogues/dialogues"; import { logger } from "@/utils/core/Logger"; -import { loadDialogueSubtitleCue } from "@/utils/dialogues/loadDialogueManifest"; +import { loadDialogueSubtitleCues } from "@/utils/dialogues/loadDialogueManifest"; +import type { SubtitleCue } from "@/utils/subtitles/parseSrt"; interface QueuedDialogueRequest { manifest: DialogueManifest; @@ -15,6 +16,8 @@ const DIALOGUE_PLAY_START_TIMEOUT_MS = 800; const dialogueQueue: QueuedDialogueRequest[] = []; let isDialogueQueuePlaying = false; +let currentDialogueAudio: HTMLAudioElement | null = null; + export function queueDialogueById( manifest: DialogueManifest, dialogueId: string, @@ -31,15 +34,26 @@ export function clearQueuedDialogues(): void { } } +export function stopCurrentDialogue(): void { + if (currentDialogueAudio && !currentDialogueAudio.paused) { + currentDialogueAudio.pause(); + currentDialogueAudio.currentTime = 0; + } + currentDialogueAudio = null; + useSubtitleStore.getState().clearActiveSubtitle(); +} + export async function playDialogueById( manifest: DialogueManifest, dialogueId: string, ): Promise { + stopCurrentDialogue(); + const dialogue = manifest.dialogues.find((item) => item.id === dialogueId); if (!dialogue) return null; const subtitleLanguage = useSettingsStore.getState().subtitleLanguage; - const subtitle = await loadDialogueSubtitleCue( + const subtitleData = await loadDialogueSubtitleCues( manifest, dialogue, subtitleLanguage, @@ -48,7 +62,11 @@ export async function playDialogueById( category: "dialogue", }); - if (!subtitle) return audio; + currentDialogueAudio = audio; + + if (!subtitleData || subtitleData.cues.length === 0) return audio; + + const { voice, cues } = subtitleData; const clearSubtitle = (): void => { useSubtitleStore.getState().clearActiveSubtitle(); @@ -60,18 +78,28 @@ export async function playDialogueById( audio.removeEventListener("ended", cleanup); audio.removeEventListener("pause", cleanup); clearSubtitle(); + if (currentDialogueAudio === audio) { + currentDialogueAudio = null; + } + }; + + const findActiveCue = (currentTime: number): SubtitleCue | null => { + for (const cue of cues) { + if (currentTime >= cue.startTime && currentTime <= cue.endTime) { + return cue; + } + } + return null; }; const syncSubtitle = (): void => { const currentTime = audio.currentTime; - const shouldShowSubtitle = - currentTime >= subtitle.cue.startTime && - currentTime <= subtitle.cue.endTime; + const activeCue = findActiveCue(currentTime); - if (shouldShowSubtitle) { + if (activeCue) { useSubtitleStore.getState().setActiveSubtitle({ - speaker: subtitle.voice.speaker, - text: subtitle.cue.text, + speaker: voice.speaker, + text: activeCue.text, }); return; }