From bd14042ca0dde3730d0680bd724a2b6290c473fb Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 11 May 2026 11:48:05 +0200 Subject: [PATCH] update: add dialogue manifest --- src/components/editor/EditorControls.tsx | 2 + .../editor/EditorDialogueManifestPanel.tsx | 365 ++++++++++++++++++ src/index.css | 120 ++++++ vite.config.ts | 66 +++- 4 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 src/components/editor/EditorDialogueManifestPanel.tsx diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx index e441db0..633fbb6 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -12,6 +12,7 @@ import { Save, Undo2, } from "lucide-react"; +import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel"; import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel"; import type { MapNode, TransformMode } from "@/types/editor/editor"; @@ -238,6 +239,7 @@ export function EditorControls({ + diff --git a/src/components/editor/EditorDialogueManifestPanel.tsx b/src/components/editor/EditorDialogueManifestPanel.tsx new file mode 100644 index 0000000..47df10b --- /dev/null +++ b/src/components/editor/EditorDialogueManifestPanel.tsx @@ -0,0 +1,365 @@ +import { useEffect, useState } from "react"; +import { Plus, RefreshCw, Save, Trash2 } from "lucide-react"; +import type { + DialogueDefinition, + DialogueManifest, + DialogueVoiceId, +} from "@/types/dialogues/dialogues"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; + +const DEFAULT_VOICE: DialogueVoiceId = "narrateur"; +type DialoguePatch = Partial> & { + timecode?: number | undefined; +}; + +function createDialogue(index: number): DialogueDefinition { + return { + id: `new_dialogue_${index}`, + voice: DEFAULT_VOICE, + audio: "/sounds/dialogue/new_dialogue.mp3", + subtitleCueIndex: 1, + }; +} + +function getManifestErrors(manifest: DialogueManifest | null): string[] { + if (!manifest) return ["Manifeste absent."]; + + const errors: string[] = []; + const ids = new Set(); + + manifest.dialogues.forEach((dialogue, index) => { + const label = dialogue.id || `Dialogue ${index + 1}`; + + if (!dialogue.id.trim()) errors.push(`${label}: id obligatoire.`); + if (ids.has(dialogue.id)) errors.push(`${label}: id duplique.`); + ids.add(dialogue.id); + + if (!dialogue.audio.startsWith("/sounds/dialogue/")) { + errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`); + } + + if (!Number.isInteger(dialogue.subtitleCueIndex)) { + errors.push(`${label}: cue SRT invalide.`); + } + + if ( + dialogue.timecode !== undefined && + (!Number.isFinite(dialogue.timecode) || dialogue.timecode < 0) + ) { + errors.push(`${label}: timecode invalide.`); + } + }); + + return errors; +} + +async function saveDialogueManifest(manifest: DialogueManifest): Promise { + const response = await fetch("/api/save-dialogues", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manifest), + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error ?? "Sauvegarde du manifeste impossible"); + } +} + +function getPatchedDialogue( + dialogue: DialogueDefinition, + patch: DialoguePatch, +): DialogueDefinition { + const nextDialogue: DialogueDefinition = { + id: patch.id ?? dialogue.id, + voice: patch.voice ?? dialogue.voice, + audio: patch.audio ?? dialogue.audio, + subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex, + }; + + if ("timecode" in patch) { + if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode; + } else if (dialogue.timecode !== undefined) { + nextDialogue.timecode = dialogue.timecode; + } + + return nextDialogue; +} + +export function EditorDialogueManifestPanel(): React.JSX.Element { + const [manifest, setManifest] = useState(null); + const [selectedDialogueId, setSelectedDialogueId] = useState(""); + const [status, setStatus] = useState("Chargement du manifeste..."); + const [isSaving, setIsSaving] = useState(false); + const errors = getManifestErrors(manifest); + const selectedDialogue = + manifest?.dialogues.find( + (dialogue) => dialogue.id === selectedDialogueId, + ) ?? + manifest?.dialogues[0] ?? + null; + const voices = manifest?.voices ?? []; + + async function handleLoad(): Promise { + setStatus("Chargement du manifeste..."); + + try { + const loadedManifest = await loadDialogueManifest(); + setManifest(loadedManifest); + setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? ""); + setStatus( + loadedManifest + ? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.` + : "Manifeste introuvable ou invalide.", + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Erreur inconnue"; + setStatus(message); + setManifest(null); + } + } + + async function handleSave(): Promise { + if (!manifest) return; + if (errors.length > 0) { + setStatus("Corrige les erreurs avant de sauvegarder."); + return; + } + + setIsSaving(true); + setStatus("Sauvegarde du manifeste..."); + + try { + await saveDialogueManifest(manifest); + setStatus( + "Manifeste sauvegarde dans public/sounds/dialogue/dialogues.json.", + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Erreur inconnue"; + setStatus(message); + } finally { + setIsSaving(false); + } + } + + function handleAddDialogue(): void { + if (!manifest) return; + + const dialogue = createDialogue(manifest.dialogues.length + 1); + setManifest({ + ...manifest, + dialogues: [...manifest.dialogues, dialogue], + }); + setSelectedDialogueId(dialogue.id); + setStatus("Nouveau dialogue ajoute localement."); + } + + function handleRemoveDialogue(dialogueId: string): void { + if (!manifest) return; + + const nextDialogues = manifest.dialogues.filter( + (dialogue) => dialogue.id !== dialogueId, + ); + setManifest({ ...manifest, dialogues: nextDialogues }); + setSelectedDialogueId(nextDialogues[0]?.id ?? ""); + setStatus("Dialogue supprime localement."); + } + + function updateSelectedDialogue( + patch: DialoguePatch, + nextId = selectedDialogueId, + ): void { + if (!manifest || !selectedDialogue) return; + + setManifest({ + ...manifest, + dialogues: manifest.dialogues.map((dialogue) => + dialogue.id === selectedDialogue.id + ? getPatchedDialogue(dialogue, patch) + : dialogue, + ), + }); + setSelectedDialogueId(nextId); + } + + useEffect(() => { + let mounted = true; + + void loadDialogueManifest() + .then((loadedManifest) => { + if (!mounted) return; + + setManifest(loadedManifest); + setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? ""); + setStatus( + loadedManifest + ? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.` + : "Manifeste introuvable ou invalide.", + ); + }) + .catch((err: unknown) => { + if (!mounted) return; + + const message = err instanceof Error ? err.message : "Erreur inconnue"; + setStatus(message); + setManifest(null); + }); + + return () => { + mounted = false; + }; + }, []); + + return ( +
+
+

Dialogues

+ {manifest?.dialogues.length ?? 0} items +
+ +
+ + + +
+ + {manifest && ( + + )} + + {selectedDialogue && ( +
+ + + + + + + + + + + +
+ )} + +

{status}

+
+ + {errors.length === 0 + ? "Manifeste local valide." + : `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`} + + {errors.length > 0 && ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/index.css b/src/index.css index 6a55483..b902f7b 100644 --- a/src/index.css +++ b/src/index.css @@ -1716,6 +1716,126 @@ canvas { color: #fca5a5; } +/* Editor dialogue manifest panel */ +.editor-dialogue-manifest-section { + display: grid; + gap: 10px; + padding: 14px 12px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.editor-dialogue-manifest-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.editor-dialogue-manifest-actions button, +.editor-dialogue-manifest-delete { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 9px; + border: 1px solid #2f2f2f; + border-radius: 12px; + background: #151515; + color: #f2f2f2; + cursor: pointer; + font-size: 0.72rem; + font-weight: 800; +} + +.editor-dialogue-manifest-actions button:hover, +.editor-dialogue-manifest-delete:hover { + border-color: #ffffff; + background: #202020; +} + +.editor-dialogue-manifest-actions button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.editor-dialogue-manifest-select, +.editor-dialogue-manifest-form label { + display: grid; + gap: 5px; + color: #8d8d8d; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.editor-dialogue-manifest-select select, +.editor-dialogue-manifest-form input, +.editor-dialogue-manifest-form select { + width: 100%; + box-sizing: border-box; + padding: 9px 10px; + border: 1px solid #242424; + border-radius: 12px; + background: #101010; + color: #f2f2f2; +} + +.editor-dialogue-manifest-select select:focus, +.editor-dialogue-manifest-form input:focus, +.editor-dialogue-manifest-form select:focus { + border-color: #ffffff; + outline: none; +} + +.editor-dialogue-manifest-form { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid #1f1f1f; + border-radius: 16px; + background: #070707; +} + +.editor-dialogue-manifest-delete { + border-color: rgba(248, 113, 113, 0.32); + color: #fca5a5; +} + +.editor-dialogue-manifest-status { + margin: 0; + color: #8d8d8d; + font-size: 0.72rem; + line-height: 1.4; +} + +.editor-dialogue-manifest-diagnostic { + display: grid; + gap: 6px; + padding: 9px 10px; + border: 1px solid #242424; + border-radius: 12px; + background: #101010; + font-size: 0.72rem; + line-height: 1.4; +} + +.editor-dialogue-manifest-diagnostic.is-valid { + border-color: rgba(134, 239, 172, 0.32); + color: #86efac; +} + +.editor-dialogue-manifest-diagnostic.is-invalid { + border-color: rgba(248, 113, 113, 0.38); + color: #fca5a5; +} + +.editor-dialogue-manifest-diagnostic ul { + display: grid; + gap: 4px; + margin: 0; + padding-left: 16px; +} + /* Editor responsive layout */ @media (max-width: 768px) { .editor-error h2 { diff --git a/vite.config.ts b/vite.config.ts index 020a984..a0ecc3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,7 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url)); const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024; const MAX_SRT_PAYLOAD_BYTES = 256 * 1024; +const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024; const JSON_HEADERS = { "Content-Type": "application/json" }; type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; type JsonObject = { readonly [key: string]: JsonValue }; @@ -160,6 +161,52 @@ const validateDialoguesPlugin = (): Plugin => ({ }, }); +const saveDialogueManifestPlugin = (): Plugin => ({ + name: "save-dialogue-manifest-api", + configureServer(server) { + server.middlewares.use("/api/save-dialogues", async (req, res) => { + if (req.method !== "POST") { + sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" }); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES) { + sendJson(res, 413, { error: "Payload too large" }); + req.destroy(); + return; + } + chunks.push(buffer); + } + + try { + const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown; + parseDialogueManifestData(data); + + const manifestPath = path.resolve( + __dirname, + "public/sounds/dialogue/dialogues.json", + ); + await fs.promises.writeFile( + manifestPath, + `${JSON.stringify(data, null, 2)}\n`, + "utf8", + ); + sendJson(res, 200, { success: true }); + } catch (err) { + const status = err instanceof SyntaxError ? 400 : 500; + const message = err instanceof Error ? err.message : "Unknown error"; + sendJson(res, status, { error: message }); + } + }); + }, +}); + interface SrtPayload { voice: string; language: string; @@ -173,6 +220,7 @@ interface DialogueManifestData { interface DialogueVoiceData { id: string; + speaker: string; subtitles: Partial>; } @@ -181,6 +229,7 @@ interface DialogueData { voice: string; audio: string; subtitleCueIndex: number; + timecode?: number; } function isSrtPayload(data: unknown): data is SrtPayload { @@ -302,6 +351,10 @@ function parseDialogueVoiceData(data: unknown): DialogueVoiceData { throw new Error("Invalid dialogue voice"); } + if (typeof data.speaker !== "string") { + throw new Error(`Dialogue voice ${data.id} must define a speaker`); + } + if (!isRecord(data.subtitles)) { throw new Error(`Dialogue voice ${data.id} must define subtitles`); } @@ -312,6 +365,7 @@ function parseDialogueVoiceData(data: unknown): DialogueVoiceData { return { id: data.id, + speaker: data.speaker, subtitles, }; } @@ -332,12 +386,21 @@ function parseDialogueData(data: unknown, voiceIds: Set): DialogueData { throw new Error("Invalid dialogue definition"); } - return { + const dialogue: DialogueData = { id: data.id, voice: data.voice, audio: data.audio, subtitleCueIndex: data.subtitleCueIndex, }; + + if (data.timecode !== undefined) { + if (typeof data.timecode !== "number") { + throw new Error("Invalid dialogue definition"); + } + dialogue.timecode = data.timecode; + } + + return dialogue; } function isRecord(value: unknown): value is Record { @@ -383,6 +446,7 @@ export default defineConfig({ react(), saveMapPlugin(), saveSrtPlugin(), + saveDialogueManifestPlugin(), validateDialoguesPlugin(), ], resolve: {