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:
@@ -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;
|
||||
|
||||
@@ -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<DialogueManifest | null> {
|
||||
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||
|
||||
@@ -39,21 +49,40 @@ export async function loadDialogueSubtitleCue(
|
||||
dialogue: DialogueDefinition,
|
||||
language: SubtitleLanguage,
|
||||
): 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);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HTMLAudioElement | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user