update: assit dialogue and srt creation
This commit is contained in:
@@ -3,25 +3,108 @@ import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
|||||||
import type {
|
import type {
|
||||||
DialogueDefinition,
|
DialogueDefinition,
|
||||||
DialogueManifest,
|
DialogueManifest,
|
||||||
|
DialogueSpeaker,
|
||||||
DialogueVoiceId,
|
DialogueVoiceId,
|
||||||
} from "@/types/dialogues/dialogues";
|
} 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";
|
||||||
|
|
||||||
const DEFAULT_VOICE: DialogueVoiceId = "narrateur";
|
const DEFAULT_VOICE: DialogueVoiceId = "narrateur";
|
||||||
type DialoguePatch = Partial<Omit<DialogueDefinition, "timecode">> & {
|
type DialoguePatch = Partial<Omit<DialogueDefinition, "timecode">> & {
|
||||||
timecode?: number | undefined;
|
timecode?: number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDialogue(index: number): DialogueDefinition {
|
function createDialogue(
|
||||||
|
index: number,
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): DialogueDefinition {
|
||||||
return {
|
return {
|
||||||
id: `new_dialogue_${index}`,
|
id: `new_dialogue_${index}`,
|
||||||
voice: DEFAULT_VOICE,
|
voice,
|
||||||
audio: "/sounds/dialogue/new_dialogue.mp3",
|
audio: `/sounds/dialogue/new_dialogue_${index}.mp3`,
|
||||||
subtitleCueIndex: 1,
|
subtitleCueIndex: getNextCueIndex(manifest, voice),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNextCueIndex(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): number {
|
||||||
|
const cueIndexes = manifest.dialogues
|
||||||
|
.filter((dialogue) => dialogue.voice === voice)
|
||||||
|
.map((dialogue) => dialogue.subtitleCueIndex);
|
||||||
|
|
||||||
|
return Math.max(0, ...cueIndexes) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVoiceSpeaker(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): DialogueSpeaker {
|
||||||
|
return (
|
||||||
|
manifest.voices.find((item) => item.id === voice)?.speaker ?? "Narrateur"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrenchSrtPath(voice: DialogueVoiceId): string {
|
||||||
|
return `/sounds/dialogue/subtitles/fr/${voice}.srt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSrtCueBlock(cueIndex: number, speaker: DialogueSpeaker): string {
|
||||||
|
return `${cueIndex}\n00:00:00,000 --> 00:00:02,000\n${speaker}: Nouveau sous-titre ${cueIndex} a definir`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSrtCueIfMissing(
|
||||||
|
content: string,
|
||||||
|
cueIndex: number,
|
||||||
|
speaker: DialogueSpeaker,
|
||||||
|
): string {
|
||||||
|
const cues = parseSrt(content);
|
||||||
|
if (cues.some((cue) => cue.index === cueIndex)) return content;
|
||||||
|
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
const cueBlock = createSrtCueBlock(cueIndex, speaker);
|
||||||
|
return trimmedContent
|
||||||
|
? `${trimmedContent}\n\n${cueBlock}\n`
|
||||||
|
: `${cueBlock}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSrtFile(
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch("/api/save-srt", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ voice, language: "fr", 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 createFrenchSrtCue(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
): Promise<void> {
|
||||||
|
const srtPath = getFrenchSrtPath(dialogue.voice);
|
||||||
|
const response = await fetch(srtPath);
|
||||||
|
const content = response.ok ? await response.text() : "";
|
||||||
|
const nextContent = appendSrtCueIfMissing(
|
||||||
|
content,
|
||||||
|
dialogue.subtitleCueIndex,
|
||||||
|
getVoiceSpeaker(manifest, dialogue.voice),
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveSrtFile(dialogue.voice, nextContent);
|
||||||
|
}
|
||||||
|
|
||||||
function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
||||||
if (!manifest) return ["Manifeste absent."];
|
if (!manifest) return ["Manifeste absent."];
|
||||||
|
|
||||||
@@ -96,6 +179,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
const [status, setStatus] = useState("Chargement du manifeste...");
|
const [status, setStatus] = useState("Chargement du manifeste...");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||||
|
const [isCreatingSrtCue, setIsCreatingSrtCue] = useState(false);
|
||||||
const errors = getManifestErrors(manifest);
|
const errors = getManifestErrors(manifest);
|
||||||
const selectedDialogue =
|
const selectedDialogue =
|
||||||
manifest?.dialogues.find(
|
manifest?.dialogues.find(
|
||||||
@@ -147,16 +231,38 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddDialogue(): void {
|
async function handleAddDialogue(): Promise<void> {
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
|
|
||||||
const dialogue = createDialogue(manifest.dialogues.length + 1);
|
const voice = selectedDialogue?.voice ?? DEFAULT_VOICE;
|
||||||
setManifest({
|
const dialogue = createDialogue(
|
||||||
|
manifest.dialogues.length + 1,
|
||||||
|
manifest,
|
||||||
|
voice,
|
||||||
|
);
|
||||||
|
const nextManifest = {
|
||||||
...manifest,
|
...manifest,
|
||||||
dialogues: [...manifest.dialogues, dialogue],
|
dialogues: [...manifest.dialogues, dialogue],
|
||||||
});
|
};
|
||||||
|
|
||||||
|
setManifest(nextManifest);
|
||||||
setSelectedDialogueId(dialogue.id);
|
setSelectedDialogueId(dialogue.id);
|
||||||
setStatus("Nouveau dialogue ajoute localement.");
|
setIsCreatingSrtCue(true);
|
||||||
|
setStatus("Nouveau dialogue ajoute localement. Creation de la cue FR...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFrenchSrtCue(nextManifest, dialogue);
|
||||||
|
setStatus(
|
||||||
|
`Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(
|
||||||
|
`Dialogue ajoute localement, mais cue FR non creee: ${message}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingSrtCue(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveDialogue(dialogueId: string): void {
|
function handleRemoveDialogue(dialogueId: string): void {
|
||||||
@@ -224,6 +330,23 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateFrenchSrtCue(): Promise<void> {
|
||||||
|
if (!manifest || !selectedDialogue) return;
|
||||||
|
|
||||||
|
setIsCreatingSrtCue(true);
|
||||||
|
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFrenchSrtCue(manifest, selectedDialogue);
|
||||||
|
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingSrtCue(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
@@ -269,9 +392,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
<RefreshCw size={14} aria-hidden="true" />
|
<RefreshCw size={14} aria-hidden="true" />
|
||||||
Reload
|
Reload
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={!manifest} onClick={handleAddDialogue}>
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!manifest || isCreatingSrtCue}
|
||||||
|
onClick={() => void handleAddDialogue()}
|
||||||
|
>
|
||||||
<Plus size={14} aria-hidden="true" />
|
<Plus size={14} aria-hidden="true" />
|
||||||
Add
|
{isCreatingSrtCue ? "Adding..." : "Add"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -374,6 +501,16 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="editor-dialogue-manifest-srt-cue"
|
||||||
|
type="button"
|
||||||
|
disabled={isCreatingSrtCue}
|
||||||
|
onClick={() => void handleCreateFrenchSrtCue()}
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden="true" />
|
||||||
|
{isCreatingSrtCue ? "Creating..." : "Create FR SRT cue"}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="editor-dialogue-manifest-preview"
|
className="editor-dialogue-manifest-preview"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1731,6 +1731,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-dialogue-manifest-actions button,
|
.editor-dialogue-manifest-actions button,
|
||||||
|
.editor-dialogue-manifest-srt-cue,
|
||||||
.editor-dialogue-manifest-preview,
|
.editor-dialogue-manifest-preview,
|
||||||
.editor-dialogue-manifest-delete {
|
.editor-dialogue-manifest-delete {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1748,6 +1749,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-dialogue-manifest-actions button:hover,
|
.editor-dialogue-manifest-actions button:hover,
|
||||||
|
.editor-dialogue-manifest-srt-cue:hover,
|
||||||
.editor-dialogue-manifest-preview:hover,
|
.editor-dialogue-manifest-preview:hover,
|
||||||
.editor-dialogue-manifest-delete:hover {
|
.editor-dialogue-manifest-delete:hover {
|
||||||
border-color: #ffffff;
|
border-color: #ffffff;
|
||||||
@@ -1759,6 +1761,7 @@ canvas {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-srt-cue:disabled,
|
||||||
.editor-dialogue-manifest-preview:disabled {
|
.editor-dialogue-manifest-preview:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
@@ -1813,6 +1816,11 @@ canvas {
|
|||||||
color: #bae6fd;
|
color: #bae6fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-srt-cue {
|
||||||
|
border-color: rgba(134, 239, 172, 0.24);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-dialogue-manifest-status {
|
.editor-dialogue-manifest-status {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #8d8d8d;
|
color: #8d8d8d;
|
||||||
|
|||||||
Reference in New Issue
Block a user