Merge branch 'develop' into feat/repair-game

This commit is contained in:
Tom Boullay
2026-05-11 17:31:14 +02:00
48 changed files with 5816 additions and 35 deletions
@@ -0,0 +1,665 @@
import { useEffect, useState } from "react";
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
import type {
CinematicCameraKeyframe,
CinematicDefinition,
CinematicDialogueCue,
CinematicManifest,
} from "@/types/cinematics/cinematics";
import type {
DialogueDefinition,
DialogueManifest,
} from "@/types/dialogues/dialogues";
import type { Vector3Tuple } from "@/types/three/three";
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
type CinematicPatch = Partial<Omit<CinematicDefinition, "timecode">> & {
timecode?: number | undefined;
};
type VectorAxis = 0 | 1 | 2;
const VECTOR_AXES: { label: "X" | "Y" | "Z"; axis: VectorAxis }[] = [
{ label: "X", axis: 0 },
{ label: "Y", axis: 1 },
{ label: "Z", axis: 2 },
];
function createCinematic(index: number): CinematicDefinition {
return {
id: `new_cinematic_${index}`,
cameraKeyframes: [
{ time: 0, position: [0, 3, 8], target: [0, 1.5, 0] },
{ time: 3, position: [6, 3, 8], target: [0, 1.5, 0] },
],
};
}
function createKeyframe(
previousKeyframe: CinematicCameraKeyframe,
): CinematicCameraKeyframe {
return {
time: previousKeyframe.time + 3,
position: [...previousKeyframe.position],
target: [...previousKeyframe.target],
};
}
function createDialogueCue(
dialogues: DialogueDefinition[],
previousCue: CinematicDialogueCue | null,
): CinematicDialogueCue {
return {
time: previousCue ? previousCue.time + 1 : 0,
dialogueId: dialogues[0]?.id ?? "",
};
}
function getManifestErrors(
manifest: CinematicManifest | null,
dialogueIds: Set<string>,
): string[] {
if (!manifest) return ["Manifeste absent."];
const errors: string[] = [];
const ids = new Set<string>();
manifest.cinematics.forEach((cinematic, cinematicIndex) => {
const label = cinematic.id || `Cinematique ${cinematicIndex + 1}`;
if (!cinematic.id.trim()) errors.push(`${label}: id obligatoire.`);
if (ids.has(cinematic.id)) errors.push(`${label}: id duplique.`);
ids.add(cinematic.id);
if (
cinematic.timecode !== undefined &&
(!Number.isFinite(cinematic.timecode) || cinematic.timecode < 0)
) {
errors.push(`${label}: timecode invalide.`);
}
if (cinematic.cameraKeyframes.length < 2) {
errors.push(`${label}: au moins deux keyframes camera sont requises.`);
}
cinematic.cameraKeyframes.forEach((keyframe, keyframeIndex) => {
const previousKeyframe = cinematic.cameraKeyframes[keyframeIndex - 1];
if (!Number.isFinite(keyframe.time) || keyframe.time < 0) {
errors.push(`${label}: keyframe ${keyframeIndex + 1} time invalide.`);
}
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
errors.push(`${label}: les temps des keyframes doivent augmenter.`);
}
});
cinematic.dialogueCues?.forEach((cue, cueIndex) => {
if (!Number.isFinite(cue.time) || cue.time < 0) {
errors.push(`${label}: dialogue cue ${cueIndex + 1} time invalide.`);
}
if (!cue.dialogueId.trim()) {
errors.push(`${label}: dialogue cue ${cueIndex + 1} id obligatoire.`);
} else if (dialogueIds.size > 0 && !dialogueIds.has(cue.dialogueId)) {
errors.push(`${label}: dialogue cue ${cueIndex + 1} dialogue inconnu.`);
}
});
});
return errors;
}
async function saveCinematicManifest(
manifest: CinematicManifest,
): Promise<void> {
const response = await fetch("/api/save-cinematics", {
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 des cinematics impossible");
}
}
function getPatchedCinematic(
cinematic: CinematicDefinition,
patch: CinematicPatch,
): CinematicDefinition {
const nextCinematic: CinematicDefinition = {
id: patch.id ?? cinematic.id,
cameraKeyframes: patch.cameraKeyframes ?? cinematic.cameraKeyframes,
};
const dialogueCues = patch.dialogueCues ?? cinematic.dialogueCues;
if (dialogueCues) {
nextCinematic.dialogueCues = dialogueCues;
}
if ("timecode" in patch) {
if (patch.timecode !== undefined) nextCinematic.timecode = patch.timecode;
} else if (cinematic.timecode !== undefined) {
nextCinematic.timecode = cinematic.timecode;
}
return nextCinematic;
}
function updateVector(
vector: Vector3Tuple,
axis: VectorAxis,
value: number,
): Vector3Tuple {
const nextVector: Vector3Tuple = [...vector];
nextVector[axis] = value;
return nextVector;
}
interface EditorCinematicManifestPanelProps {
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
}
export function EditorCinematicManifestPanel({
onPreviewCinematic,
}: EditorCinematicManifestPanelProps): React.JSX.Element {
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
const [dialogueManifest, setDialogueManifest] =
useState<DialogueManifest | null>(null);
const [selectedCinematicId, setSelectedCinematicId] = useState("");
const [status, setStatus] = useState("Chargement des cinematics...");
const [isSaving, setIsSaving] = useState(false);
const dialogueIds = new Set(
dialogueManifest?.dialogues.map((dialogue) => dialogue.id) ?? [],
);
const errors = getManifestErrors(manifest, dialogueIds);
const selectedCinematic =
manifest?.cinematics.find(
(cinematic) => cinematic.id === selectedCinematicId,
) ??
manifest?.cinematics[0] ??
null;
async function handleLoad(): Promise<void> {
setStatus("Chargement des cinematics...");
try {
const [loadedManifest, loadedDialogueManifest] = await Promise.all([
loadCinematicManifest(),
loadDialogueManifest(),
]);
setManifest(loadedManifest);
setDialogueManifest(loadedDialogueManifest);
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
setStatus(
loadedManifest
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
: "Manifeste cinematics 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 des cinematics...");
try {
await saveCinematicManifest(manifest);
setStatus("Manifeste sauvegarde dans public/cinematics.json.");
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
} finally {
setIsSaving(false);
}
}
function handleAddCinematic(): void {
if (!manifest) return;
const cinematic = createCinematic(manifest.cinematics.length + 1);
setManifest({
...manifest,
cinematics: [...manifest.cinematics, cinematic],
});
setSelectedCinematicId(cinematic.id);
setStatus("Nouvelle cinematic ajoutee localement.");
}
function handleRemoveCinematic(cinematicId: string): void {
if (!manifest) return;
const nextCinematics = manifest.cinematics.filter(
(cinematic) => cinematic.id !== cinematicId,
);
setManifest({ ...manifest, cinematics: nextCinematics });
setSelectedCinematicId(nextCinematics[0]?.id ?? "");
setStatus("Cinematic supprimee localement.");
}
function updateSelectedCinematic(
patch: CinematicPatch,
nextId = selectedCinematicId,
): void {
if (!manifest || !selectedCinematic) return;
setManifest({
...manifest,
cinematics: manifest.cinematics.map((cinematic) =>
cinematic.id === selectedCinematic.id
? getPatchedCinematic(cinematic, patch)
: cinematic,
),
});
setSelectedCinematicId(nextId);
}
function updateKeyframe(
keyframeIndex: number,
patch: Partial<CinematicCameraKeyframe>,
): void {
if (!selectedCinematic) return;
updateSelectedCinematic({
cameraKeyframes: selectedCinematic.cameraKeyframes.map(
(keyframe, index) =>
index === keyframeIndex ? { ...keyframe, ...patch } : keyframe,
),
});
}
function handleAddKeyframe(): void {
if (!selectedCinematic) return;
const previousKeyframe =
selectedCinematic.cameraKeyframes[
selectedCinematic.cameraKeyframes.length - 1
];
if (!previousKeyframe) return;
updateSelectedCinematic({
cameraKeyframes: [
...selectedCinematic.cameraKeyframes,
createKeyframe(previousKeyframe),
],
});
setStatus("Keyframe ajoutee localement.");
}
function handleRemoveKeyframe(keyframeIndex: number): void {
if (!selectedCinematic) return;
updateSelectedCinematic({
cameraKeyframes: selectedCinematic.cameraKeyframes.filter(
(_keyframe, index) => index !== keyframeIndex,
),
});
setStatus("Keyframe supprimee localement.");
}
function updateDialogueCue(
cueIndex: number,
patch: Partial<CinematicDialogueCue>,
): void {
if (!selectedCinematic) return;
const dialogueCues = selectedCinematic.dialogueCues ?? [];
updateSelectedCinematic({
dialogueCues: dialogueCues.map((cue, index) =>
index === cueIndex ? { ...cue, ...patch } : cue,
),
});
}
function handleAddDialogueCue(): void {
if (!selectedCinematic) return;
const dialogueCues = selectedCinematic.dialogueCues ?? [];
const previousCue = dialogueCues[dialogueCues.length - 1] ?? null;
updateSelectedCinematic({
dialogueCues: [
...dialogueCues,
createDialogueCue(dialogueManifest?.dialogues ?? [], previousCue),
],
});
setStatus("Dialogue cue ajoutee localement.");
}
function handleRemoveDialogueCue(cueIndex: number): void {
if (!selectedCinematic) return;
updateSelectedCinematic({
dialogueCues: (selectedCinematic.dialogueCues ?? []).filter(
(_cue, index) => index !== cueIndex,
),
});
setStatus("Dialogue cue supprimee localement.");
}
useEffect(() => {
let mounted = true;
void Promise.all([loadCinematicManifest(), loadDialogueManifest()])
.then(([loadedManifest, loadedDialogueManifest]) => {
if (!mounted) return;
setManifest(loadedManifest);
setDialogueManifest(loadedDialogueManifest);
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
setStatus(
loadedManifest
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
: "Manifeste cinematics 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-cinematic-manifest-section"
aria-labelledby="cinematic-manifest-heading"
>
<div className="editor-section-heading">
<h3 id="cinematic-manifest-heading">Cinematics</h3>
<span>{manifest?.cinematics.length ?? 0} items</span>
</div>
<div className="editor-cinematic-manifest-actions">
<button type="button" onClick={() => void handleLoad()}>
<RefreshCw size={14} aria-hidden="true" />
Reload
</button>
<button type="button" disabled={!manifest} onClick={handleAddCinematic}>
<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-cinematic-manifest-select">
Cinematic
<select
value={selectedCinematic?.id ?? ""}
onChange={(event) => setSelectedCinematicId(event.target.value)}
>
{manifest.cinematics.map((cinematic) => (
<option key={cinematic.id} value={cinematic.id}>
{cinematic.id || "Cinematic sans id"}
</option>
))}
</select>
</label>
)}
{selectedCinematic && (
<div className="editor-cinematic-manifest-form">
<label>
ID
<input
value={selectedCinematic.id}
onChange={(event) =>
updateSelectedCinematic(
{ id: event.target.value },
event.target.value,
)
}
/>
</label>
<label>
Timecode global optionnel
<input
type="number"
min="0"
step="0.1"
value={selectedCinematic.timecode ?? ""}
placeholder="Aucun"
onChange={(event) => {
const value = event.target.value.trim();
updateSelectedCinematic({
timecode: value === "" ? undefined : Number(value),
});
}}
/>
</label>
<div className="editor-cinematic-keyframes">
<div className="editor-cinematic-keyframes-heading">
<strong>Camera keyframes</strong>
<button type="button" onClick={handleAddKeyframe}>
<Plus size={13} aria-hidden="true" />
Add keyframe
</button>
</div>
{selectedCinematic.cameraKeyframes.map(
(keyframe, keyframeIndex) => (
<div
className="editor-cinematic-keyframe"
key={`${selectedCinematic.id}-${keyframeIndex}`}
>
<div className="editor-cinematic-keyframe-heading">
<strong>Keyframe {keyframeIndex + 1}</strong>
<button
type="button"
disabled={selectedCinematic.cameraKeyframes.length <= 2}
onClick={() => handleRemoveKeyframe(keyframeIndex)}
>
<Trash2 size={13} aria-hidden="true" />
Remove
</button>
</div>
<label>
Time
<input
type="number"
min="0"
step="0.1"
value={keyframe.time}
onChange={(event) =>
updateKeyframe(keyframeIndex, {
time: Number(event.target.value),
})
}
/>
</label>
<VectorInputs
label="Position"
value={keyframe.position}
onChange={(axis, value) =>
updateKeyframe(keyframeIndex, {
position: updateVector(keyframe.position, axis, value),
})
}
/>
<VectorInputs
label="Target"
value={keyframe.target}
onChange={(axis, value) =>
updateKeyframe(keyframeIndex, {
target: updateVector(keyframe.target, axis, value),
})
}
/>
</div>
),
)}
</div>
<div className="editor-cinematic-dialogue-cues">
<div className="editor-cinematic-dialogue-cues-heading">
<strong>Dialogue cues</strong>
<button type="button" onClick={handleAddDialogueCue}>
<Plus size={13} aria-hidden="true" />
Add dialogue
</button>
</div>
{(selectedCinematic.dialogueCues ?? []).length === 0 ? (
<p>Aucun dialogue synchronise avec cette cinematic.</p>
) : (
(selectedCinematic.dialogueCues ?? []).map((cue, cueIndex) => (
<div
className="editor-cinematic-dialogue-cue"
key={`${selectedCinematic.id}-dialogue-${cueIndex}`}
>
<div className="editor-cinematic-dialogue-cue-heading">
<strong>Dialogue {cueIndex + 1}</strong>
<button
type="button"
onClick={() => handleRemoveDialogueCue(cueIndex)}
>
<Trash2 size={13} aria-hidden="true" />
Remove
</button>
</div>
<label>
Time
<input
type="number"
min="0"
step="0.1"
value={cue.time}
onChange={(event) =>
updateDialogueCue(cueIndex, {
time: Number(event.target.value),
})
}
/>
</label>
<label>
Dialogue
<select
value={cue.dialogueId}
onChange={(event) =>
updateDialogueCue(cueIndex, {
dialogueId: event.target.value,
})
}
>
{dialogueManifest?.dialogues.length ? (
dialogueManifest.dialogues.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}>
{dialogue.id}
</option>
))
) : (
<option value={cue.dialogueId}>
{cue.dialogueId || "Aucun dialogue disponible"}
</option>
)}
</select>
</label>
</div>
))
)}
</div>
<button
className="editor-cinematic-manifest-preview"
type="button"
disabled={errors.length > 0 || !onPreviewCinematic}
onClick={() => onPreviewCinematic?.(selectedCinematic)}
>
<Play size={14} aria-hidden="true" />
Preview cinematic
</button>
<button
className="editor-cinematic-manifest-delete"
type="button"
onClick={() => handleRemoveCinematic(selectedCinematic.id)}
>
<Trash2 size={14} aria-hidden="true" />
Delete cinematic
</button>
</div>
)}
<p className="editor-cinematic-manifest-status">{status}</p>
<div
className={`editor-cinematic-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>
);
}
interface VectorInputsProps {
label: string;
value: Vector3Tuple;
onChange: (axis: VectorAxis, value: number) => void;
}
function VectorInputs({
label,
value,
onChange,
}: VectorInputsProps): React.JSX.Element {
return (
<div className="editor-cinematic-vector-inputs">
<span>{label}</span>
{VECTOR_AXES.map(({ label: axisLabel, axis }) => (
<label key={axisLabel}>
{axisLabel}
<input
type="number"
step="0.1"
value={value[axis]}
onChange={(event) => onChange(axis, Number(event.target.value))}
/>
</label>
))}
</div>
);
}
+10
View File
@@ -12,6 +12,10 @@ import {
Save,
Undo2,
} from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode } from "@/types/editor/editor";
interface EditorControlsProps {
@@ -28,6 +32,7 @@ interface EditorControlsProps {
onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined;
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
isPlayerMode?: boolean;
}
@@ -59,6 +64,7 @@ export function EditorControls({
onExportJson,
onSaveToServer,
onPlayerMode,
onPreviewCinematic,
isPlayerMode,
}: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
@@ -236,6 +242,10 @@ export function EditorControls({
: `Selected node ${selectedNodeIndex + 1} raw lines`}
</div>
</section>
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
<EditorDialogueManifestPanel />
<EditorSrtPanel />
</aside>
</>
);
@@ -0,0 +1,554 @@
import { useEffect, useRef, useState } from "react";
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
import type {
DialogueDefinition,
DialogueManifest,
DialogueSpeaker,
DialogueVoiceId,
} from "@/types/dialogues/dialogues";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import { parseSrt } from "@/utils/subtitles/parseSrt";
const DEFAULT_VOICE: DialogueVoiceId = "narrateur";
type DialoguePatch = Partial<Omit<DialogueDefinition, "timecode">> & {
timecode?: number | undefined;
};
function createDialogue(
index: number,
manifest: DialogueManifest,
voice: DialogueVoiceId,
): DialogueDefinition {
return {
id: `new_dialogue_${index}`,
voice,
audio: `/sounds/dialogue/new_dialogue_${index}.mp3`,
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[] {
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 previewAudioRef = useRef<HTMLAudioElement | null>(null);
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
const [selectedDialogueId, setSelectedDialogueId] = useState("");
const [status, setStatus] = useState("Chargement du manifeste...");
const [isSaving, setIsSaving] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isCreatingSrtCue, setIsCreatingSrtCue] = 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);
}
}
async function handleAddDialogue(): Promise<void> {
if (!manifest) return;
const voice = selectedDialogue?.voice ?? DEFAULT_VOICE;
const dialogue = createDialogue(
manifest.dialogues.length + 1,
manifest,
voice,
);
const nextManifest = {
...manifest,
dialogues: [...manifest.dialogues, dialogue],
};
setManifest(nextManifest);
setSelectedDialogueId(dialogue.id);
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 {
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);
}
async function handlePreviewDialogue(): Promise<void> {
if (!manifest || !selectedDialogue) return;
if (errors.length > 0) {
setStatus("Corrige les erreurs avant de lancer la preview.");
return;
}
previewAudioRef.current?.pause();
previewAudioRef.current = null;
setIsPreviewing(true);
setStatus(`Preview dialogue: ${selectedDialogue.id}`);
try {
const audio = await playDialogueById(manifest, selectedDialogue.id);
previewAudioRef.current = audio;
if (!audio) {
setStatus("Dialogue introuvable pour la preview.");
return;
}
const handleFinish = (): void => {
audio.removeEventListener("ended", handleFinish);
audio.removeEventListener("pause", handleFinish);
if (previewAudioRef.current === audio) previewAudioRef.current = null;
setIsPreviewing(false);
};
audio.addEventListener("ended", handleFinish);
audio.addEventListener("pause", handleFinish);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
setIsPreviewing(false);
}
}
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(() => {
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;
previewAudioRef.current?.pause();
previewAudioRef.current = null;
};
}, []);
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 || isCreatingSrtCue}
onClick={() => void handleAddDialogue()}
>
<Plus size={14} aria-hidden="true" />
{isCreatingSrtCue ? "Adding..." : "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-srt-cue"
type="button"
disabled={isCreatingSrtCue}
onClick={() => void handleCreateFrenchSrtCue()}
>
<Plus size={14} aria-hidden="true" />
{isCreatingSrtCue ? "Creating..." : "Create FR SRT cue"}
</button>
<button
className="editor-dialogue-manifest-preview"
type="button"
disabled={errors.length > 0 || isPreviewing}
onClick={() => void handlePreviewDialogue()}
>
<Play size={14} aria-hidden="true" />
{isPreviewing ? "Playing..." : "Preview dialogue"}
</button>
<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>
);
}
+743
View File
@@ -0,0 +1,743 @@
import { useEffect, useRef, useState } from "react";
import { Download, RefreshCw, Save } from "lucide-react";
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
import type {
DialogueDefinition,
DialogueManifest,
DialogueSpeaker,
DialogueVoiceId,
} from "@/types/dialogues/dialogues";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { parseSrt } from "@/utils/subtitles/parseSrt";
interface SrtVoiceOption {
id: DialogueVoiceId;
label: DialogueSpeaker;
}
interface SrtDiagnostic {
cueCount: number;
expectedCueCount: number;
errors: string[];
}
interface TextRange {
start: number;
end: number;
}
interface DialogueValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
type CueTimeEdge = "start" | "end";
const CUE_NUDGE_SECONDS = 0.1;
const SRT_VOICES: SrtVoiceOption[] = [
{ id: "narrateur", label: "Narrateur" },
{ id: "fermier", label: "Fermier" },
{ id: "electricienne", label: "Electricienne" },
];
const DEFAULT_SRT_VOICE: SrtVoiceOption = {
id: "narrateur",
label: "Narrateur",
};
const SRT_LANGUAGES: SubtitleLanguage[] = ["fr", "en"];
const SRT_TIME_LINE_PATTERN =
/^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}$/;
function getSrtPath(
voice: DialogueVoiceId,
language: SubtitleLanguage,
): string {
return `/sounds/dialogue/subtitles/${language}/${voice}.srt`;
}
function createSrtTemplate(
speaker: DialogueSpeaker,
expectedCueIndexes: number[],
): string {
const cueIndexes = expectedCueIndexes.length > 0 ? expectedCueIndexes : [1];
return `${cueIndexes
.map((cueIndex, index) => {
const startTime = index * 3;
const endTime = startTime + 2;
return `${cueIndex}\n${formatSrtTime(startTime)} --> ${formatSrtTime(endTime)}\n${speaker}: Sous-titre ${cueIndex} a definir`;
})
.join("\n\n")}\n`;
}
function formatSrtTime(totalSeconds: number): string {
const safeSeconds = Math.max(0, totalSeconds);
const totalMilliseconds = Math.round(safeSeconds * 1000);
const milliseconds = totalMilliseconds % 1000;
const totalWholeSeconds = Math.floor(totalMilliseconds / 1000);
const hours = Math.floor(totalWholeSeconds / 3600);
const minutes = Math.floor((totalWholeSeconds % 3600) / 60);
const seconds = totalWholeSeconds % 60;
return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)},${padMilliseconds(milliseconds)}`;
}
function formatPreviewTime(totalSeconds: number): string {
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
}
function parseSrtTime(value: string): number | null {
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
if (!match) return null;
const [, hours, minutes, seconds, milliseconds] = match;
if (!hours || !minutes || !seconds || !milliseconds) return null;
return (
Number(hours) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(milliseconds) / 1000
);
}
function padTime(value: number): string {
return value.toString().padStart(2, "0");
}
function padMilliseconds(value: number): string {
return value.toString().padStart(3, "0");
}
function getSrtDiagnostic(
content: string,
expectedCueIndexes: number[],
): SrtDiagnostic {
const normalizedContent = content.replace(/^\uFEFF/, "").replace(/\r/g, "");
const blocks = normalizedContent
.trim()
.split(/\n{2,}/)
.filter(Boolean);
const cues = parseSrt(content);
const errors: string[] = [];
const indexes = new Set<number>();
if (blocks.length === 0) {
errors.push("Le fichier SRT est vide.");
}
blocks.forEach((block, blockIndex) => {
const lines = block
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const displayIndex = blockIndex + 1;
const cueIndex = Number(lines[0]);
if (lines.length < 3) {
errors.push(
`Bloc ${displayIndex}: il manque un index, un timecode ou un texte.`,
);
return;
}
if (!Number.isInteger(cueIndex)) {
errors.push(`Bloc ${displayIndex}: l'index doit etre un nombre entier.`);
} else if (indexes.has(cueIndex)) {
errors.push(`Bloc ${displayIndex}: l'index ${cueIndex} est duplique.`);
} else {
indexes.add(cueIndex);
}
if (!SRT_TIME_LINE_PATTERN.test(lines[1] ?? "")) {
errors.push(
`Bloc ${displayIndex}: le timecode doit utiliser HH:MM:SS,mmm --> HH:MM:SS,mmm.`,
);
}
});
if (blocks.length > 0 && cues.length !== blocks.length) {
errors.push(
"Un ou plusieurs blocs ont une duree invalide ou un timecode illisible.",
);
}
const cueIndexes = new Set(cues.map((cue) => cue.index));
const missingCueIndexes = expectedCueIndexes.filter(
(cueIndex) => !cueIndexes.has(cueIndex),
);
if (missingCueIndexes.length > 0) {
errors.push(
`Cues attendues par le manifeste manquantes: ${missingCueIndexes.join(", ")}.`,
);
}
return {
cueCount: cues.length,
expectedCueCount: expectedCueIndexes.length,
errors,
};
}
function getExpectedCueIndexes(
manifest: DialogueManifest | null,
voice: DialogueVoiceId,
): number[] {
return getExpectedDialogues(manifest, voice)
.map((dialogue) => dialogue.subtitleCueIndex)
.filter(
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
)
.sort((a, b) => a - b);
}
function getExpectedDialogues(
manifest: DialogueManifest | null,
voice: DialogueVoiceId,
): DialogueDefinition[] {
if (!manifest) return [];
return [...manifest.dialogues]
.filter((dialogue) => dialogue.voice === voice)
.sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex);
}
function findCueBlockRange(
content: string,
cueIndex: number,
): TextRange | null {
const normalizedContent = content.replace(/\r/g, "");
const cuePattern = new RegExp(`(^|\\n)${cueIndex}\\n`, "m");
const match = normalizedContent.match(cuePattern);
if (!match || match.index === undefined) return null;
const start = match.index + (match[1] ? 1 : 0);
const nextBlockIndex = normalizedContent.indexOf("\n\n", start);
const end = nextBlockIndex === -1 ? normalizedContent.length : nextBlockIndex;
return { start, end };
}
function updateCueTimecode(
content: string,
cueIndex: number,
edge: CueTimeEdge,
time: number,
): string | null {
const range = findCueBlockRange(content, cueIndex);
if (!range) return null;
const block = content.slice(range.start, range.end);
const lines = block.split("\n");
const timecodeLine = lines[1];
if (!timecodeLine) return null;
const [start, end] = timecodeLine.split(" --> ");
if (!start || !end) return null;
lines[1] =
edge === "start"
? `${formatSrtTime(time)} --> ${end}`
: `${start} --> ${formatSrtTime(time)}`;
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
}
function nudgeCueTimecode(
content: string,
cueIndex: number,
delta: number,
): string | null {
const range = findCueBlockRange(content, cueIndex);
if (!range) return null;
const block = content.slice(range.start, range.end);
const lines = block.split("\n");
const timecodeLine = lines[1];
if (!timecodeLine) return null;
const [start, end] = timecodeLine.split(" --> ");
if (!start || !end) return null;
const startTime = parseSrtTime(start);
const endTime = parseSrtTime(end);
if (startTime === null || endTime === null) return null;
const nextStartTime = Math.max(0, startTime + delta);
const nextEndTime = Math.max(nextStartTime + 0.001, endTime + delta);
lines[1] = `${formatSrtTime(nextStartTime)} --> ${formatSrtTime(nextEndTime)}`;
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
}
function downloadSrtFile(
voice: DialogueVoiceId,
language: SubtitleLanguage,
content: string,
): void {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${voice}.${language}.srt`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function saveSrtFile(
voice: DialogueVoiceId,
language: SubtitleLanguage,
content: string,
): Promise<void> {
const response = await fetch("/api/save-srt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ voice, language, 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 validateDialogueAssets(): Promise<DialogueValidationResult> {
const response = await fetch("/api/validate-dialogues");
const body = (await response.json().catch(() => null)) as
| Partial<DialogueValidationResult>
| { error?: string }
| null;
if (!body) {
throw new Error("Validation dialogues impossible");
}
if (
"valid" in body &&
typeof body.valid === "boolean" &&
Array.isArray(body.errors) &&
Array.isArray(body.warnings)
) {
return {
valid: body.valid,
errors: body.errors.filter((item) => typeof item === "string"),
warnings: body.warnings.filter((item) => typeof item === "string"),
};
}
throw new Error(
"error" in body && body.error
? body.error
: "Validation dialogues impossible",
);
}
export function EditorSrtPanel(): React.JSX.Element {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [voice, setVoice] = useState<DialogueVoiceId>("narrateur");
const [language, setLanguage] = useState<SubtitleLanguage>("fr");
const [content, setContent] = useState("");
const [status, setStatus] = useState("Chargement du SRT...");
const [isSaving, setIsSaving] = useState(false);
const [isValidatingDialogues, setIsValidatingDialogues] = useState(false);
const [dialogueValidationResult, setDialogueValidationResult] =
useState<DialogueValidationResult | null>(null);
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [selectedDialogueId, setSelectedDialogueId] = useState("");
const selectedVoice =
SRT_VOICES.find((item) => item.id === voice) ?? DEFAULT_SRT_VOICE;
const expectedDialogues = getExpectedDialogues(manifest, voice);
const expectedCueIndexes = getExpectedCueIndexes(manifest, voice);
const parsedCues = parseSrt(content);
const activeCue =
parsedCues.find(
(cue) =>
audioCurrentTime >= cue.startTime && audioCurrentTime < cue.endTime,
) ?? null;
const diagnostic = getSrtDiagnostic(content, expectedCueIndexes);
const isSrtValid = diagnostic.errors.length === 0;
const dialogueValidationClass = dialogueValidationResult
? dialogueValidationResult.valid
? "is-valid"
: "is-invalid"
: "is-idle";
const srtTemplate = createSrtTemplate(
selectedVoice.label,
expectedCueIndexes,
);
const selectedDialogue =
expectedDialogues.find((dialogue) => dialogue.id === selectedDialogueId) ??
expectedDialogues[0] ??
null;
async function handleSave(): Promise<void> {
if (!isSrtValid) {
setStatus("Corrige les erreurs SRT avant de sauvegarder.");
return;
}
setIsSaving(true);
setStatus("Sauvegarde du SRT...");
try {
await saveSrtFile(voice, language, content);
setStatus(`Sauvegarde dans ${getSrtPath(voice, language)}`);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(`${message}. Utilise Export SRT si le serveur dev est absent.`);
} finally {
setIsSaving(false);
}
}
async function handleValidateDialogues(): Promise<void> {
setIsValidatingDialogues(true);
setDialogueValidationResult(null);
try {
const result = await validateDialogueAssets();
setDialogueValidationResult(result);
setStatus(
result.valid
? "Validation dialogues terminee."
: "Validation dialogues terminee avec erreurs.",
);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(`${message}. Verifie que le serveur Vite est lance.`);
} finally {
setIsValidatingDialogues(false);
}
}
function handleJumpToCue(cueIndex: number): void {
const range = findCueBlockRange(content, cueIndex);
if (!range || !textareaRef.current) {
setStatus(`Cue ${cueIndex} introuvable dans le SRT.`);
return;
}
textareaRef.current.focus();
textareaRef.current.setSelectionRange(range.start, range.end);
setStatus(`Cue ${cueIndex} selectionnee dans le SRT.`);
}
function handleSetCueTime(cueIndex: number, edge: CueTimeEdge): void {
const updatedContent = updateCueTimecode(
content,
cueIndex,
edge,
audioCurrentTime,
);
if (!updatedContent) {
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
return;
}
setContent(updatedContent);
setStatus(
`Cue ${cueIndex}: ${edge === "start" ? "debut" : "fin"} place a ${formatSrtTime(audioCurrentTime)}.`,
);
}
function handleNudgeCue(cueIndex: number, delta: number): void {
const updatedContent = nudgeCueTimecode(content, cueIndex, delta);
if (!updatedContent) {
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
return;
}
setContent(updatedContent);
setStatus(
`Cue ${cueIndex} decalee de ${delta > 0 ? "+" : ""}${delta.toFixed(1)}s.`,
);
}
useEffect(() => {
let mounted = true;
void loadDialogueManifest()
.then((loadedManifest) => {
if (mounted) setManifest(loadedManifest);
})
.catch(() => {
if (mounted) setManifest(null);
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
let mounted = true;
const srtPath = getSrtPath(voice, language);
void fetch(srtPath)
.then(async (response) => {
if (!mounted) return;
if (!response.ok) {
setContent(srtTemplate);
setStatus("Fichier absent, template local cree");
return;
}
setContent(await response.text());
setStatus(`Charge depuis ${srtPath}`);
})
.catch(() => {
if (!mounted) return;
setContent(srtTemplate);
setStatus("Erreur de chargement, template local cree");
});
return () => {
mounted = false;
};
}, [language, selectedVoice.label, srtTemplate, voice]);
return (
<section className="editor-srt-section" aria-labelledby="srt-heading">
<div className="editor-section-heading">
<h3 id="srt-heading">SRT</h3>
<span>{language.toUpperCase()}</span>
</div>
<div className="editor-srt-controls">
<label>
Voix
<select
value={voice}
onChange={(event) =>
setVoice(event.target.value as DialogueVoiceId)
}
>
{SRT_VOICES.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label>
Langue
<select
value={language}
onChange={(event) =>
setLanguage(event.target.value as SubtitleLanguage)
}
>
{SRT_LANGUAGES.map((item) => (
<option key={item} value={item}>
{item.toUpperCase()}
</option>
))}
</select>
</label>
</div>
<div className="editor-srt-preview">
<label>
Dialogue audio
<select
value={selectedDialogue?.id ?? ""}
onChange={(event) => setSelectedDialogueId(event.target.value)}
disabled={expectedDialogues.length === 0}
>
{expectedDialogues.length === 0 && (
<option value="">Aucun dialogue</option>
)}
{expectedDialogues.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}>
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
</option>
))}
</select>
</label>
{selectedDialogue && (
<div className="editor-srt-audio-card">
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
<strong>{selectedDialogue.id}</strong>
<audio
key={selectedDialogue.audio}
controls
src={selectedDialogue.audio}
onLoadedMetadata={() => setAudioCurrentTime(0)}
onTimeUpdate={(event) =>
setAudioCurrentTime(event.currentTarget.currentTime)
}
/>
<div className="editor-srt-active-cue">
<span>Temps audio: {formatPreviewTime(audioCurrentTime)}</span>
{activeCue ? (
<p>
<strong>Cue {activeCue.index}</strong> {activeCue.text}
</p>
) : (
<p>Aucune cue active a ce moment.</p>
)}
</div>
<div className="editor-srt-time-actions">
<button
type="button"
onClick={() =>
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
}
>
Set start
</button>
<button
type="button"
onClick={() =>
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
}
>
Set end
</button>
<button
type="button"
onClick={() =>
handleNudgeCue(
selectedDialogue.subtitleCueIndex,
-CUE_NUDGE_SECONDS,
)
}
>
-100ms
</button>
<button
type="button"
onClick={() =>
handleNudgeCue(
selectedDialogue.subtitleCueIndex,
CUE_NUDGE_SECONDS,
)
}
>
+100ms
</button>
</div>
<button
className="editor-srt-jump-button"
type="button"
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)}
>
Aller a la cue {selectedDialogue.subtitleCueIndex}
</button>
</div>
)}
</div>
<textarea
ref={textareaRef}
className="editor-srt-textarea"
value={content}
spellCheck={false}
onChange={(event) => setContent(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
aria-label="SRT content"
/>
<div className="editor-srt-actions">
<button
className="editor-action-button"
type="button"
onClick={() => setContent(srtTemplate)}
>
<RefreshCw size={15} aria-hidden="true" />
Template
</button>
<button
className="editor-action-button editor-action-button-primary"
type="button"
disabled={isSaving || !isSrtValid}
onClick={() => void handleSave()}
>
<Save size={15} aria-hidden="true" />
{isSaving ? "Saving..." : "Save SRT"}
</button>
<button
className="editor-action-button"
type="button"
onClick={() => downloadSrtFile(voice, language, content)}
>
<Download size={15} aria-hidden="true" />
Export SRT
</button>
</div>
<p className="editor-srt-status">{status}</p>
<div className={`editor-dialogue-validation ${dialogueValidationClass}`}>
<div className="editor-dialogue-validation__heading">
<div>
<strong>Manifeste dialogues</strong>
<span>Audio, SRT FR et cues references</span>
</div>
<button
type="button"
disabled={isValidatingDialogues}
onClick={() => void handleValidateDialogues()}
>
<RefreshCw size={14} aria-hidden="true" />
{isValidatingDialogues ? "Validation..." : "Validate"}
</button>
</div>
{dialogueValidationResult && (
<div className="editor-dialogue-validation__result">
<p>
{dialogueValidationResult.valid
? "Manifeste valide."
: `${dialogueValidationResult.errors.length} erreur${dialogueValidationResult.errors.length > 1 ? "s" : ""} detectee${dialogueValidationResult.errors.length > 1 ? "s" : ""}.`}
{dialogueValidationResult.warnings.length > 0 &&
` ${dialogueValidationResult.warnings.length} warning${dialogueValidationResult.warnings.length > 1 ? "s" : ""}.`}
</p>
{dialogueValidationResult.errors.length > 0 && (
<ul className="editor-dialogue-validation__errors">
{dialogueValidationResult.errors.map((error, index) => (
<li key={`${error}-${index}`}>{error}</li>
))}
</ul>
)}
{dialogueValidationResult.warnings.length > 0 && (
<ul className="editor-dialogue-validation__warnings">
{dialogueValidationResult.warnings.map((warning, index) => (
<li key={`${warning}-${index}`}>{warning}</li>
))}
</ul>
)}
</div>
)}
</div>
<div
className={`editor-srt-diagnostic ${isSrtValid ? "is-valid" : "is-invalid"}`}
>
<strong>
{isSrtValid
? `${diagnostic.cueCount} cue${diagnostic.cueCount > 1 ? "s" : ""} valide${diagnostic.cueCount > 1 ? "s" : ""} / ${diagnostic.expectedCueCount} attendue${diagnostic.expectedCueCount > 1 ? "s" : ""}`
: `${diagnostic.errors.length} erreur${diagnostic.errors.length > 1 ? "s" : ""} SRT`}
</strong>
{!isSrtValid && (
<ul>
{diagnostic.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
</section>
);
}
+96 -2
View File
@@ -1,9 +1,18 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
import { EditorMap } from "@/components/editor/scene/EditorMap";
import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
export interface EditorCinematicPreviewRequest {
id: string;
cinematic: CinematicDefinition;
}
interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
@@ -18,6 +27,8 @@ interface EditorSceneProps {
onUndo: () => void;
onRedo: () => void;
isPlayerMode?: boolean;
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
onCinematicPreviewComplete?: (() => void) | undefined;
}
export function EditorScene({
@@ -34,7 +45,11 @@ export function EditorScene({
onUndo,
onRedo,
isPlayerMode = false,
cinematicPreviewRequest = null,
onCinematicPreviewComplete,
}: EditorSceneProps): React.JSX.Element {
const isCinematicPreviewing = cinematicPreviewRequest !== null;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
@@ -74,10 +89,16 @@ export function EditorScene({
return (
<>
<EditorCinematicPreviewPlayer
request={cinematicPreviewRequest}
onComplete={onCinematicPreviewComplete}
/>
{isPlayerMode ? (
<FlyController disabled={false} />
<FlyController disabled={isCinematicPreviewing} />
) : (
<OrbitControls
enabled={!isCinematicPreviewing}
enableDamping
dampingFactor={0.05}
mouseButtons={{
@@ -106,3 +127,76 @@ export function EditorScene({
</>
);
}
interface EditorCinematicPreviewPlayerProps {
request: EditorCinematicPreviewRequest | null;
onComplete?: (() => void) | undefined;
}
function EditorCinematicPreviewPlayer({
request,
onComplete,
}: EditorCinematicPreviewPlayerProps): null {
const camera = useThree((state) => state.camera);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
useEffect(() => {
timelineRef.current?.kill();
timelineRef.current = null;
if (!request) return undefined;
const firstKeyframe = request.cinematic.cameraKeyframes[0];
if (!firstKeyframe) return undefined;
const target = new THREE.Vector3(...firstKeyframe.target);
camera.position.set(...firstKeyframe.position);
camera.lookAt(target);
const timeline = gsap.timeline({
onUpdate: () => camera.lookAt(target),
onComplete: () => {
timelineRef.current = null;
onComplete?.();
},
});
request.cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
const previousKeyframe = request.cinematic.cameraKeyframes[index];
if (!previousKeyframe) return;
const duration = keyframe.time - previousKeyframe.time;
timeline.to(
camera.position,
{
x: keyframe.position[0],
y: keyframe.position[1],
z: keyframe.position[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
timeline.to(
target,
{
x: keyframe.target[0],
y: keyframe.target[1],
z: keyframe.target[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
});
timelineRef.current = timeline;
return () => {
timeline.kill();
if (timelineRef.current === timeline) timelineRef.current = null;
};
}, [camera, onComplete, request]);
return null;
}
@@ -81,7 +81,9 @@ export function TriggerObject({
bodyRef={rbRef}
onPress={() => {
if (soundPath) {
AudioManager.getInstance().playSound(soundPath, soundVolume);
AudioManager.getInstance().playSound(soundPath, soundVolume, {
category: "sfx",
});
}
onTrigger?.();
+203
View File
@@ -0,0 +1,203 @@
import { useEffect } from "react";
import { X } from "lucide-react";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type {
RepairRuntime,
SubtitleLanguage,
} from "@/managers/stores/useSettingsStore";
function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
function clearCookies(): void {
document.cookie.split(";").forEach((cookie) => {
const cookieName = cookie.split("=")[0]?.trim();
if (!cookieName) return;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
});
}
interface VolumeSliderProps {
id: string;
label: string;
value: number;
onChange: (value: number) => void;
}
function VolumeSlider({
id,
label,
value,
onChange,
}: VolumeSliderProps): React.JSX.Element {
return (
<label className="game-settings-menu__slider" htmlFor={id}>
<span>
{label}
<strong>{formatPercent(value)}</strong>
</span>
<input
id={id}
type="range"
min="0"
max="1"
step="0.01"
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
export function GameSettingsMenu(): React.JSX.Element | null {
const {
isSettingsMenuOpen,
musicVolume,
sfxVolume,
dialogueVolume,
subtitlesEnabled,
subtitleLanguage,
repairRuntime,
setMusicVolume,
setSfxVolume,
setDialogueVolume,
setSettingsMenuOpen,
setSubtitlesEnabled,
setSubtitleLanguage,
setRepairRuntime,
} = useSettingsStore();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (!isSettingsMenuOpen) document.exitPointerLock();
setSettingsMenuOpen(!isSettingsMenuOpen);
return;
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [isSettingsMenuOpen, setSettingsMenuOpen]);
if (!isSettingsMenuOpen) return null;
const handleQuit = (): void => {
clearCookies();
window.location.assign("/");
};
return (
<div className="game-settings-menu" role="dialog" aria-modal="true">
<div className="game-settings-menu__panel">
<header className="game-settings-menu__header">
<div>
<span>Pause</span>
<h2>Options</h2>
</div>
<button
className="game-settings-menu__close"
type="button"
onClick={() => setSettingsMenuOpen(false)}
aria-label="Fermer le menu"
>
<X size={20} aria-hidden="true" />
</button>
</header>
<section
className="game-settings-menu__section"
aria-labelledby="audio-settings-heading"
>
<h3 id="audio-settings-heading">Audio</h3>
<VolumeSlider
id="music-volume"
label="Musique"
value={musicVolume}
onChange={setMusicVolume}
/>
<VolumeSlider
id="sfx-volume"
label="Sound effects"
value={sfxVolume}
onChange={setSfxVolume}
/>
<VolumeSlider
id="dialogue-volume"
label="Dialogue"
value={dialogueVolume}
onChange={setDialogueVolume}
/>
</section>
<section
className="game-settings-menu__section"
aria-labelledby="subtitle-settings-heading"
>
<h3 id="subtitle-settings-heading">Sous-titres</h3>
<label className="game-settings-menu__checkbox">
<input
type="checkbox"
checked={subtitlesEnabled}
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
/>
Afficher sous-titres
</label>
<div
className="game-settings-menu__choice-group"
aria-label="Langue des sous-titres"
>
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
<button
key={language}
type="button"
className={subtitleLanguage === language ? "active" : undefined}
onClick={() => setSubtitleLanguage(language)}
aria-pressed={subtitleLanguage === language}
>
{language === "fr" ? "Francais" : "English"}
</button>
))}
</div>
</section>
<section
className="game-settings-menu__section"
aria-labelledby="repair-settings-heading"
>
<h3 id="repair-settings-heading">Repair game</h3>
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
<button
key={runtime}
type="button"
className={repairRuntime === runtime ? "active" : undefined}
onClick={() => setRepairRuntime(runtime)}
aria-pressed={repairRuntime === runtime}
>
{runtime === "js"
? "Repair game en JS (local)"
: "Repair game en Python (server)"}
</button>
))}
</div>
</section>
<button
className="game-settings-menu__quit"
type="button"
onClick={handleQuit}
>
Quitter
</button>
</div>
</div>
);
}
+4
View File
@@ -1,8 +1,10 @@
import { Crosshair } from "@/components/ui/Crosshair";
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles";
export function GameUI(): React.JSX.Element {
return (
@@ -12,6 +14,8 @@ export function GameUI(): React.JSX.Element {
<RepairMovementLockIndicator />
<InteractPrompt />
<HandTrackingVisualizer />
<Subtitles />
<GameSettingsMenu />
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
export type SubtitleSpeaker = DialogueSpeaker;
interface SubtitlesProps {
speaker?: SubtitleSpeaker | null;
text?: string | null;
}
export function Subtitles({
speaker = null,
text = null,
}: SubtitlesProps): React.JSX.Element | null {
const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled);
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null;
const content = (text ?? activeSubtitle?.text)?.trim();
if (!subtitlesEnabled || !content) return null;
return (
<div className="subtitles" aria-live="polite">
<p>
{subtitleSpeaker ? (
<span
className={`subtitles__speaker subtitles__speaker--${subtitleSpeaker.toLowerCase()}`}
>
{subtitleSpeaker}:
</span>
) : null}
{content}
</p>
</div>
);
}