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
🔍 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:
@@ -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)),
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user