feat(dialogues): support multi-cue subtitles
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-05-30 04:00:25 +02:00
parent ce5dc8ada0
commit 02c1fb33d0
8 changed files with 192 additions and 53 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
"id": "narrateur_intro_prenom", "id": "narrateur_intro_prenom",
"voice": "narrateur", "voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3", "audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
"subtitleCueIndex": 2 "subtitleCueIndices": [1, 2]
}, },
{ {
"id": "narrateur_intro_apresprenom", "id": "narrateur_intro_apresprenom",
@@ -1,9 +1,9 @@
1 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. 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 2
00:00:00,000 --> 00:00:11,592 00:00:09,000 --> 00:00:11,592
Avant de commencer, comment tu t'appelles ? Avant de commencer, comment tu t'appelles ?
3 3
@@ -6,6 +6,10 @@ import type {
DialogueSpeaker, DialogueSpeaker,
DialogueVoiceId, DialogueVoiceId,
} from "@/types/dialogues/dialogues"; } from "@/types/dialogues/dialogues";
import {
getDialogueCueIndices,
getDialogueFirstCueIndex,
} from "@/types/dialogues/dialogues";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { playDialogueById } from "@/utils/dialogues/playDialogue";
import { parseSrt } from "@/utils/subtitles/parseSrt"; import { parseSrt } from "@/utils/subtitles/parseSrt";
@@ -34,7 +38,7 @@ function getNextCueIndex(
): number { ): number {
const cueIndexes = manifest.dialogues const cueIndexes = manifest.dialogues
.filter((dialogue) => dialogue.voice === voice) .filter((dialogue) => dialogue.voice === voice)
.map((dialogue) => dialogue.subtitleCueIndex); .flatMap((dialogue) => getDialogueCueIndices(dialogue));
return Math.max(0, ...cueIndexes) + 1; return Math.max(0, ...cueIndexes) + 1;
} }
@@ -93,12 +97,15 @@ async function createFrenchSrtCue(
manifest: DialogueManifest, manifest: DialogueManifest,
dialogue: DialogueDefinition, dialogue: DialogueDefinition,
): Promise<void> { ): Promise<void> {
const firstCueIndex = getDialogueFirstCueIndex(dialogue);
if (firstCueIndex === undefined) return;
const srtPath = getFrenchSrtPath(dialogue.voice); const srtPath = getFrenchSrtPath(dialogue.voice);
const response = await fetch(srtPath); const response = await fetch(srtPath);
const content = response.ok ? await response.text() : ""; const content = response.ok ? await response.text() : "";
const nextContent = appendSrtCueIfMissing( const nextContent = appendSrtCueIfMissing(
content, content,
dialogue.subtitleCueIndex, firstCueIndex,
getVoiceSpeaker(manifest, dialogue.voice), getVoiceSpeaker(manifest, dialogue.voice),
); );
@@ -122,7 +129,8 @@ function getManifestErrors(manifest: DialogueManifest | null): string[] {
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`); 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.`); errors.push(`${label}: cue SRT invalide.`);
} }
@@ -160,9 +168,18 @@ function getPatchedDialogue(
id: patch.id ?? dialogue.id, id: patch.id ?? dialogue.id,
voice: patch.voice ?? dialogue.voice, voice: patch.voice ?? dialogue.voice,
audio: patch.audio ?? dialogue.audio, 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 ("timecode" in patch) {
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode; if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
} else if (dialogue.timecode !== undefined) { } else if (dialogue.timecode !== undefined) {
@@ -252,8 +269,9 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
try { try {
await createFrenchSrtCue(nextManifest, dialogue); await createFrenchSrtCue(nextManifest, dialogue);
const cueIndex = getDialogueFirstCueIndex(dialogue) ?? "?";
setStatus( 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) { } catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue"; const message = err instanceof Error ? err.message : "Erreur inconnue";
@@ -333,12 +351,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
async function handleCreateFrenchSrtCue(): Promise<void> { async function handleCreateFrenchSrtCue(): Promise<void> {
if (!manifest || !selectedDialogue) return; if (!manifest || !selectedDialogue) return;
const cueIndex = getDialogueFirstCueIndex(selectedDialogue) ?? "?";
setIsCreatingSrtCue(true); setIsCreatingSrtCue(true);
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`); setStatus(`Creation de la cue FR ${cueIndex}...`);
try { try {
await createFrenchSrtCue(manifest, selectedDialogue); await createFrenchSrtCue(manifest, selectedDialogue);
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`); setStatus(`Cue FR ${cueIndex} prete.`);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue"; const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message); setStatus(message);
@@ -478,7 +497,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
type="number" type="number"
min="1" min="1"
step="1" step="1"
value={selectedDialogue.subtitleCueIndex} value={getDialogueFirstCueIndex(selectedDialogue) ?? ""}
onChange={(event) => onChange={(event) =>
updateSelectedDialogue({ updateSelectedDialogue({
subtitleCueIndex: Math.max(1, Number(event.target.value)), subtitleCueIndex: Math.max(1, Number(event.target.value)),
+47 -20
View File
@@ -7,6 +7,10 @@ import type {
DialogueSpeaker, DialogueSpeaker,
DialogueVoiceId, DialogueVoiceId,
} from "@/types/dialogues/dialogues"; } from "@/types/dialogues/dialogues";
import {
getDialogueCueIndices,
getDialogueFirstCueIndex,
} from "@/types/dialogues/dialogues";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { import {
@@ -181,7 +185,7 @@ function getExpectedCueIndexes(
voice: DialogueVoiceId, voice: DialogueVoiceId,
): number[] { ): number[] {
return getExpectedDialogues(manifest, voice) return getExpectedDialogues(manifest, voice)
.map((dialogue) => dialogue.subtitleCueIndex) .flatMap((dialogue) => getDialogueCueIndices(dialogue))
.filter( .filter(
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index, (cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
) )
@@ -196,7 +200,11 @@ function getExpectedDialogues(
return [...manifest.dialogues] return [...manifest.dialogues]
.filter((dialogue) => dialogue.voice === voice) .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( function findCueBlockRange(
@@ -577,7 +585,7 @@ export function EditorSrtPanel(): React.JSX.Element {
)} )}
{expectedDialogues.map((dialogue) => ( {expectedDialogues.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}> <option key={dialogue.id} value={dialogue.id}>
Cue {dialogue.subtitleCueIndex} - {dialogue.id} Cue {getDialogueFirstCueIndex(dialogue) ?? "?"} - {dialogue.id}
</option> </option>
))} ))}
</select> </select>
@@ -585,7 +593,7 @@ export function EditorSrtPanel(): React.JSX.Element {
{selectedDialogue && ( {selectedDialogue && (
<div className="editor-srt-audio-card"> <div className="editor-srt-audio-card">
<span>Cue {selectedDialogue.subtitleCueIndex}</span> <span>Cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"}</span>
<strong>{selectedDialogue.id}</strong> <strong>{selectedDialogue.id}</strong>
<audio <audio
key={selectedDialogue.audio} key={selectedDialogue.audio}
@@ -609,39 +617,52 @@ export function EditorSrtPanel(): React.JSX.Element {
<div className="editor-srt-time-actions"> <div className="editor-srt-time-actions">
<button <button
type="button" type="button"
onClick={() => disabled={
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start") getDialogueFirstCueIndex(selectedDialogue) === undefined
} }
onClick={() => {
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
if (cueIndex !== undefined)
handleSetCueTime(cueIndex, "start");
}}
> >
Set start Set start
</button> </button>
<button <button
type="button" type="button"
onClick={() => disabled={
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end") getDialogueFirstCueIndex(selectedDialogue) === undefined
} }
onClick={() => {
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
if (cueIndex !== undefined) handleSetCueTime(cueIndex, "end");
}}
> >
Set end Set end
</button> </button>
<button <button
type="button" type="button"
onClick={() => disabled={
handleNudgeCue( getDialogueFirstCueIndex(selectedDialogue) === undefined
selectedDialogue.subtitleCueIndex,
-CUE_NUDGE_SECONDS,
)
} }
onClick={() => {
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
if (cueIndex !== undefined)
handleNudgeCue(cueIndex, -CUE_NUDGE_SECONDS);
}}
> >
-100ms -100ms
</button> </button>
<button <button
type="button" type="button"
onClick={() => disabled={
handleNudgeCue( getDialogueFirstCueIndex(selectedDialogue) === undefined
selectedDialogue.subtitleCueIndex,
CUE_NUDGE_SECONDS,
)
} }
onClick={() => {
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
if (cueIndex !== undefined)
handleNudgeCue(cueIndex, CUE_NUDGE_SECONDS);
}}
> >
+100ms +100ms
</button> </button>
@@ -649,9 +670,15 @@ export function EditorSrtPanel(): React.JSX.Element {
<button <button
className="editor-srt-jump-button" className="editor-srt-jump-button"
type="button" type="button"
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)} disabled={
getDialogueFirstCueIndex(selectedDialogue) === undefined
}
onClick={() => {
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
if (cueIndex !== undefined) handleJumpToCue(cueIndex);
}}
> >
Aller a la cue {selectedDialogue.subtitleCueIndex} Aller a la cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"}
</button> </button>
</div> </div>
)} )}
+19 -1
View File
@@ -13,7 +13,8 @@ export interface DialogueDefinition {
id: string; id: string;
voice: DialogueVoiceId; voice: DialogueVoiceId;
audio: string; audio: string;
subtitleCueIndex: number; subtitleCueIndex?: number;
subtitleCueIndices?: number[];
timecode?: number; timecode?: number;
} }
@@ -22,3 +23,20 @@ export interface DialogueManifest {
voices: DialogueVoice[]; voices: DialogueVoice[];
dialogues: DialogueDefinition[]; 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];
}
@@ -93,13 +93,26 @@ function parseDialogueDefinition(
throw new Error(`Dialogue ${data.id} has an invalid audio path`); throw new Error(`Dialogue ${data.id} has an invalid audio path`);
} }
// Support both subtitleCueIndex (legacy) and subtitleCueIndices (new)
const subtitleCueIndex = data.subtitleCueIndex; const subtitleCueIndex = data.subtitleCueIndex;
if ( const subtitleCueIndices = data.subtitleCueIndices;
typeof subtitleCueIndex !== "number" ||
!Number.isInteger(subtitleCueIndex) || const hasLegacyIndex =
subtitleCueIndex < 1 typeof subtitleCueIndex === "number" &&
) { Number.isInteger(subtitleCueIndex) &&
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`); 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; const timecode = data.timecode;
@@ -111,9 +124,14 @@ function parseDialogueDefinition(
id: data.id, id: data.id,
voice: data.voice, voice: data.voice,
audio: data.audio, audio: data.audio,
subtitleCueIndex,
}; };
if (hasNewIndices) {
dialogue.subtitleCueIndices = subtitleCueIndices as number[];
} else if (hasLegacyIndex) {
dialogue.subtitleCueIndex = subtitleCueIndex;
}
if (timecode !== undefined) dialogue.timecode = timecode; if (timecode !== undefined) dialogue.timecode = timecode;
return dialogue; return dialogue;
+34 -5
View File
@@ -3,6 +3,7 @@ import type {
DialogueManifest, DialogueManifest,
DialogueVoice, DialogueVoice,
} from "@/types/dialogues/dialogues"; } from "@/types/dialogues/dialogues";
import { getDialogueCueIndices } from "@/types/dialogues/dialogues";
import type { SubtitleLanguage } from "@/types/settings/settings"; import type { SubtitleLanguage } from "@/types/settings/settings";
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation"; import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
import { parseSrt } from "@/utils/subtitles/parseSrt"; import { parseSrt } from "@/utils/subtitles/parseSrt";
@@ -17,6 +18,15 @@ export interface DialogueSubtitleCue {
subtitlePath: string; subtitlePath: string;
} }
/**
* Multiple subtitle cues for a single dialogue
*/
export interface DialogueSubtitleCues {
voice: DialogueVoice;
cues: SubtitleCue[];
subtitlePath: string;
}
export async function loadDialogueManifest(): Promise<DialogueManifest | null> { export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
const response = await fetch(DIALOGUE_MANIFEST_PATH); const response = await fetch(DIALOGUE_MANIFEST_PATH);
@@ -39,21 +49,40 @@ export async function loadDialogueSubtitleCue(
dialogue: DialogueDefinition, dialogue: DialogueDefinition,
language: SubtitleLanguage, language: SubtitleLanguage,
): Promise<DialogueSubtitleCue | null> { ): Promise<DialogueSubtitleCue | null> {
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<DialogueSubtitleCues | null> {
const voice = getDialogueVoice(manifest, dialogue.voice); const voice = getDialogueVoice(manifest, dialogue.voice);
if (!voice) return null; if (!voice) return null;
const subtitles = await loadVoiceSubtitleCues(voice, language); const subtitles = await loadVoiceSubtitleCues(voice, language);
if (!subtitles) return null; if (!subtitles) return null;
const cue = subtitles.cues.find( const cueIndices = getDialogueCueIndices(dialogue);
(item) => item.index === dialogue.subtitleCueIndex, 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 { return {
voice, voice,
cue, cues,
subtitlePath: subtitles.path, subtitlePath: subtitles.path,
}; };
} }
+37 -9
View File
@@ -3,7 +3,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueManifest } from "@/types/dialogues/dialogues"; import type { DialogueManifest } from "@/types/dialogues/dialogues";
import { logger } from "@/utils/core/Logger"; 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 { interface QueuedDialogueRequest {
manifest: DialogueManifest; manifest: DialogueManifest;
@@ -15,6 +16,8 @@ const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
const dialogueQueue: QueuedDialogueRequest[] = []; const dialogueQueue: QueuedDialogueRequest[] = [];
let isDialogueQueuePlaying = false; let isDialogueQueuePlaying = false;
let currentDialogueAudio: HTMLAudioElement | null = null;
export function queueDialogueById( export function queueDialogueById(
manifest: DialogueManifest, manifest: DialogueManifest,
dialogueId: string, 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( export async function playDialogueById(
manifest: DialogueManifest, manifest: DialogueManifest,
dialogueId: string, dialogueId: string,
): Promise<HTMLAudioElement | null> { ): Promise<HTMLAudioElement | null> {
stopCurrentDialogue();
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId); const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
if (!dialogue) return null; if (!dialogue) return null;
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage; const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
const subtitle = await loadDialogueSubtitleCue( const subtitleData = await loadDialogueSubtitleCues(
manifest, manifest,
dialogue, dialogue,
subtitleLanguage, subtitleLanguage,
@@ -48,7 +62,11 @@ export async function playDialogueById(
category: "dialogue", category: "dialogue",
}); });
if (!subtitle) return audio; currentDialogueAudio = audio;
if (!subtitleData || subtitleData.cues.length === 0) return audio;
const { voice, cues } = subtitleData;
const clearSubtitle = (): void => { const clearSubtitle = (): void => {
useSubtitleStore.getState().clearActiveSubtitle(); useSubtitleStore.getState().clearActiveSubtitle();
@@ -60,18 +78,28 @@ export async function playDialogueById(
audio.removeEventListener("ended", cleanup); audio.removeEventListener("ended", cleanup);
audio.removeEventListener("pause", cleanup); audio.removeEventListener("pause", cleanup);
clearSubtitle(); 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 syncSubtitle = (): void => {
const currentTime = audio.currentTime; const currentTime = audio.currentTime;
const shouldShowSubtitle = const activeCue = findActiveCue(currentTime);
currentTime >= subtitle.cue.startTime &&
currentTime <= subtitle.cue.endTime;
if (shouldShowSubtitle) { if (activeCue) {
useSubtitleStore.getState().setActiveSubtitle({ useSubtitleStore.getState().setActiveSubtitle({
speaker: subtitle.voice.speaker, speaker: voice.speaker,
text: subtitle.cue.text, text: activeCue.text,
}); });
return; return;
} }