Merge branch 'develop' into feat/repair-game
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user