update: add dialogue manifest

This commit is contained in:
Tom Boullay
2026-05-11 11:48:05 +02:00
parent d059a9aa14
commit 6d73ffaccf
4 changed files with 552 additions and 1 deletions
+2
View File
@@ -12,6 +12,7 @@ import {
Save, Save,
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel"; import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
import type { MapNode, TransformMode } from "@/types/editor/editor"; import type { MapNode, TransformMode } from "@/types/editor/editor";
@@ -238,6 +239,7 @@ export function EditorControls({
</div> </div>
</section> </section>
<EditorDialogueManifestPanel />
<EditorSrtPanel /> <EditorSrtPanel />
</aside> </aside>
</> </>
@@ -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<Omit<DialogueDefinition, "timecode">> & {
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<string>();
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<void> {
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<DialogueManifest | null>(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<void> {
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<void> {
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 (
<section
className="editor-dialogue-manifest-section"
aria-labelledby="dialogue-manifest-heading"
>
<div className="editor-section-heading">
<h3 id="dialogue-manifest-heading">Dialogues</h3>
<span>{manifest?.dialogues.length ?? 0} items</span>
</div>
<div className="editor-dialogue-manifest-actions">
<button type="button" onClick={() => void handleLoad()}>
<RefreshCw size={14} aria-hidden="true" />
Reload
</button>
<button type="button" disabled={!manifest} onClick={handleAddDialogue}>
<Plus size={14} aria-hidden="true" />
Add
</button>
<button
type="button"
disabled={!manifest || errors.length > 0 || isSaving}
onClick={() => void handleSave()}
>
<Save size={14} aria-hidden="true" />
{isSaving ? "Saving..." : "Save"}
</button>
</div>
{manifest && (
<label className="editor-dialogue-manifest-select">
Dialogue
<select
value={selectedDialogue?.id ?? ""}
onChange={(event) => setSelectedDialogueId(event.target.value)}
>
{manifest.dialogues.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}>
{dialogue.id || "Dialogue sans id"}
</option>
))}
</select>
</label>
)}
{selectedDialogue && (
<div className="editor-dialogue-manifest-form">
<label>
ID
<input
value={selectedDialogue.id}
onChange={(event) =>
updateSelectedDialogue(
{ id: event.target.value },
event.target.value,
)
}
/>
</label>
<label>
Voix
<select
value={selectedDialogue.voice}
onChange={(event) =>
updateSelectedDialogue({
voice: event.target.value as DialogueVoiceId,
})
}
>
{voices.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.speaker}
</option>
))}
</select>
</label>
<label>
Audio
<input
value={selectedDialogue.audio}
onChange={(event) =>
updateSelectedDialogue({ audio: event.target.value })
}
/>
</label>
<label>
Cue SRT
<input
type="number"
min="1"
step="1"
value={selectedDialogue.subtitleCueIndex}
onChange={(event) =>
updateSelectedDialogue({
subtitleCueIndex: Math.max(1, Number(event.target.value)),
})
}
/>
</label>
<label>
Timecode global optionnel
<input
type="number"
min="0"
step="0.1"
value={selectedDialogue.timecode ?? ""}
placeholder="Aucun"
onChange={(event) => {
const value = event.target.value.trim();
updateSelectedDialogue({
timecode: value === "" ? undefined : Number(value),
});
}}
/>
</label>
<button
className="editor-dialogue-manifest-delete"
type="button"
onClick={() => handleRemoveDialogue(selectedDialogue.id)}
>
<Trash2 size={14} aria-hidden="true" />
Delete dialogue
</button>
</div>
)}
<p className="editor-dialogue-manifest-status">{status}</p>
<div
className={`editor-dialogue-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
>
<strong>
{errors.length === 0
? "Manifeste local valide."
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
</strong>
{errors.length > 0 && (
<ul>
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
</section>
);
}
+120
View File
@@ -1716,6 +1716,126 @@ canvas {
color: #fca5a5; 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 */ /* Editor responsive layout */
@media (max-width: 768px) { @media (max-width: 768px) {
.editor-error h2 { .editor-error h2 {
+65 -1
View File
@@ -12,6 +12,7 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024; const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024; const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
const JSON_HEADERS = { "Content-Type": "application/json" }; const JSON_HEADERS = { "Content-Type": "application/json" };
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
type JsonObject = { readonly [key: string]: JsonValue }; 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 { interface SrtPayload {
voice: string; voice: string;
language: string; language: string;
@@ -173,6 +220,7 @@ interface DialogueManifestData {
interface DialogueVoiceData { interface DialogueVoiceData {
id: string; id: string;
speaker: string;
subtitles: Partial<Record<"fr" | "en", string>>; subtitles: Partial<Record<"fr" | "en", string>>;
} }
@@ -181,6 +229,7 @@ interface DialogueData {
voice: string; voice: string;
audio: string; audio: string;
subtitleCueIndex: number; subtitleCueIndex: number;
timecode?: number;
} }
function isSrtPayload(data: unknown): data is SrtPayload { function isSrtPayload(data: unknown): data is SrtPayload {
@@ -302,6 +351,10 @@ function parseDialogueVoiceData(data: unknown): DialogueVoiceData {
throw new Error("Invalid dialogue voice"); 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)) { if (!isRecord(data.subtitles)) {
throw new Error(`Dialogue voice ${data.id} must define subtitles`); throw new Error(`Dialogue voice ${data.id} must define subtitles`);
} }
@@ -312,6 +365,7 @@ function parseDialogueVoiceData(data: unknown): DialogueVoiceData {
return { return {
id: data.id, id: data.id,
speaker: data.speaker,
subtitles, subtitles,
}; };
} }
@@ -332,12 +386,21 @@ function parseDialogueData(data: unknown, voiceIds: Set<string>): DialogueData {
throw new Error("Invalid dialogue definition"); throw new Error("Invalid dialogue definition");
} }
return { const dialogue: DialogueData = {
id: data.id, id: data.id,
voice: data.voice, voice: data.voice,
audio: data.audio, audio: data.audio,
subtitleCueIndex: data.subtitleCueIndex, 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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -383,6 +446,7 @@ export default defineConfig({
react(), react(),
saveMapPlugin(), saveMapPlugin(),
saveSrtPlugin(), saveSrtPlugin(),
saveDialogueManifestPlugin(),
validateDialoguesPlugin(), validateDialoguesPlugin(),
], ],
resolve: { resolve: {