From 9e41004a992acf83b696671cda08b7f4cfa02fc0 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 11 May 2026 12:11:58 +0200 Subject: [PATCH] update: add cinecmatic editor --- .../editor/EditorCinematicManifestPanel.tsx | 493 ++++++++++++++++++ src/components/editor/EditorControls.tsx | 2 + src/index.css | 180 +++++++ vite.config.ts | 135 +++++ 4 files changed, 810 insertions(+) create mode 100644 src/components/editor/EditorCinematicManifestPanel.tsx diff --git a/src/components/editor/EditorCinematicManifestPanel.tsx b/src/components/editor/EditorCinematicManifestPanel.tsx new file mode 100644 index 0000000..24d3ec1 --- /dev/null +++ b/src/components/editor/EditorCinematicManifestPanel.tsx @@ -0,0 +1,493 @@ +import { useEffect, useState } from "react"; +import { Plus, RefreshCw, Save, Trash2 } from "lucide-react"; +import type { + CinematicCameraKeyframe, + CinematicDefinition, + CinematicManifest, +} from "@/types/cinematics/cinematics"; +import type { Vector3Tuple } from "@/types/three/three"; +import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest"; + +type CinematicPatch = Partial> & { + 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 getManifestErrors(manifest: CinematicManifest | null): string[] { + if (!manifest) return ["Manifeste absent."]; + + const errors: string[] = []; + const ids = new Set(); + + 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.`); + } + }); + }); + + return errors; +} + +async function saveCinematicManifest( + manifest: CinematicManifest, +): Promise { + 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, + }; + + 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; +} + +export function EditorCinematicManifestPanel(): React.JSX.Element { + const [manifest, setManifest] = useState(null); + const [selectedCinematicId, setSelectedCinematicId] = useState(""); + const [status, setStatus] = useState("Chargement des cinematics..."); + const [isSaving, setIsSaving] = useState(false); + const errors = getManifestErrors(manifest); + const selectedCinematic = + manifest?.cinematics.find( + (cinematic) => cinematic.id === selectedCinematicId, + ) ?? + manifest?.cinematics[0] ?? + null; + + async function handleLoad(): Promise { + setStatus("Chargement des cinematics..."); + + try { + const loadedManifest = await loadCinematicManifest(); + setManifest(loadedManifest); + 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 { + 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, + ): 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."); + } + + useEffect(() => { + let mounted = true; + + void loadCinematicManifest() + .then((loadedManifest) => { + if (!mounted) return; + + setManifest(loadedManifest); + 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 ( +
+
+

Cinematics

+ {manifest?.cinematics.length ?? 0} items +
+ +
+ + + +
+ + {manifest && ( + + )} + + {selectedCinematic && ( +
+ + + + +
+
+ Camera keyframes + +
+ + {selectedCinematic.cameraKeyframes.map( + (keyframe, keyframeIndex) => ( +
+
+ Keyframe {keyframeIndex + 1} + +
+ + + + + updateKeyframe(keyframeIndex, { + position: updateVector(keyframe.position, axis, value), + }) + } + /> + + + updateKeyframe(keyframeIndex, { + target: updateVector(keyframe.target, axis, value), + }) + } + /> +
+ ), + )} +
+ + +
+ )} + +

{status}

+
+ + {errors.length === 0 + ? "Manifeste local valide." + : `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`} + + {errors.length > 0 && ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ )} +
+
+ ); +} + +interface VectorInputsProps { + label: string; + value: Vector3Tuple; + onChange: (axis: VectorAxis, value: number) => void; +} + +function VectorInputs({ + label, + value, + onChange, +}: VectorInputsProps): React.JSX.Element { + return ( +
+ {label} + {VECTOR_AXES.map(({ label: axisLabel, axis }) => ( + + ))} +
+ ); +} diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx index 633fbb6..a87f483 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -12,6 +12,7 @@ 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 { MapNode, TransformMode } from "@/types/editor/editor"; @@ -239,6 +240,7 @@ export function EditorControls({ + diff --git a/src/index.css b/src/index.css index b902f7b..5f9af62 100644 --- a/src/index.css +++ b/src/index.css @@ -1836,6 +1836,186 @@ canvas { padding-left: 16px; } +/* Editor cinematic manifest panel */ +.editor-cinematic-manifest-section { + display: grid; + gap: 10px; + padding: 14px 12px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.editor-cinematic-manifest-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.editor-cinematic-manifest-actions button, +.editor-cinematic-manifest-delete, +.editor-cinematic-keyframes-heading button, +.editor-cinematic-keyframe-heading button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 9px; + border: 1px solid #2f2f2f; + border-radius: 12px; + background: #151515; + color: #f2f2f2; + cursor: pointer; + font-size: 0.72rem; + font-weight: 800; +} + +.editor-cinematic-manifest-actions button:hover, +.editor-cinematic-manifest-delete:hover, +.editor-cinematic-keyframes-heading button:hover, +.editor-cinematic-keyframe-heading button:hover { + border-color: #ffffff; + background: #202020; +} + +.editor-cinematic-manifest-actions button:disabled, +.editor-cinematic-keyframe-heading button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.editor-cinematic-manifest-select, +.editor-cinematic-manifest-form label, +.editor-cinematic-vector-inputs label { + display: grid; + gap: 5px; + color: #8d8d8d; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.editor-cinematic-manifest-select select, +.editor-cinematic-manifest-form input, +.editor-cinematic-vector-inputs input { + width: 100%; + box-sizing: border-box; + padding: 9px 10px; + border: 1px solid #242424; + border-radius: 12px; + background: #101010; + color: #f2f2f2; +} + +.editor-cinematic-manifest-select select:focus, +.editor-cinematic-manifest-form input:focus, +.editor-cinematic-vector-inputs input:focus { + border-color: #ffffff; + outline: none; +} + +.editor-cinematic-manifest-form, +.editor-cinematic-keyframes, +.editor-cinematic-keyframe { + display: grid; + gap: 8px; +} + +.editor-cinematic-manifest-form { + padding: 10px; + border: 1px solid #1f1f1f; + border-radius: 16px; + background: #070707; +} + +.editor-cinematic-keyframes { + padding: 10px; + border: 1px solid #242424; + border-radius: 14px; + background: #101010; +} + +.editor-cinematic-keyframes-heading, +.editor-cinematic-keyframe-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.editor-cinematic-keyframes-heading strong, +.editor-cinematic-keyframe-heading strong { + color: #f2f2f2; + font-size: 0.76rem; + font-weight: 800; +} + +.editor-cinematic-keyframe { + padding: 9px; + border: 1px solid #1f1f1f; + border-radius: 12px; + background: #070707; +} + +.editor-cinematic-vector-inputs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.editor-cinematic-vector-inputs span { + grid-column: 1 / -1; + color: #8d8d8d; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.editor-cinematic-manifest-delete { + border-color: rgba(248, 113, 113, 0.32); + color: #fca5a5; +} + +.editor-cinematic-keyframe-heading button { + padding: 6px 8px; + color: #fca5a5; +} + +.editor-cinematic-manifest-status { + margin: 0; + color: #8d8d8d; + font-size: 0.72rem; + line-height: 1.4; +} + +.editor-cinematic-manifest-diagnostic { + display: grid; + gap: 6px; + padding: 9px 10px; + border: 1px solid #242424; + border-radius: 12px; + background: #101010; + font-size: 0.72rem; + line-height: 1.4; +} + +.editor-cinematic-manifest-diagnostic.is-valid { + border-color: rgba(134, 239, 172, 0.32); + color: #86efac; +} + +.editor-cinematic-manifest-diagnostic.is-invalid { + border-color: rgba(248, 113, 113, 0.38); + color: #fca5a5; +} + +.editor-cinematic-manifest-diagnostic ul { + display: grid; + gap: 4px; + margin: 0; + padding-left: 16px; +} + /* Editor responsive layout */ @media (max-width: 768px) { .editor-error h2 { diff --git a/vite.config.ts b/vite.config.ts index a0ecc3e..902df25 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url)); const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024; const MAX_SRT_PAYLOAD_BYTES = 256 * 1024; const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024; +const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024; const JSON_HEADERS = { "Content-Type": "application/json" }; type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; type JsonObject = { readonly [key: string]: JsonValue }; @@ -207,6 +208,49 @@ const saveDialogueManifestPlugin = (): Plugin => ({ }, }); +const saveCinematicManifestPlugin = (): Plugin => ({ + name: "save-cinematic-manifest-api", + configureServer(server) { + server.middlewares.use("/api/save-cinematics", async (req, res) => { + if (req.method !== "POST") { + sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" }); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES) { + sendJson(res, 413, { error: "Payload too large" }); + req.destroy(); + return; + } + chunks.push(buffer); + } + + try { + const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown; + parseCinematicManifestData(data); + + const manifestPath = path.resolve(__dirname, "public/cinematics.json"); + await fs.promises.writeFile( + manifestPath, + `${JSON.stringify(data, null, 2)}\n`, + "utf8", + ); + sendJson(res, 200, { success: true }); + } catch (err) { + const status = err instanceof SyntaxError ? 400 : 500; + const message = err instanceof Error ? err.message : "Unknown error"; + sendJson(res, status, { error: message }); + } + }); + }, +}); + interface SrtPayload { voice: string; language: string; @@ -232,6 +276,22 @@ interface DialogueData { timecode?: number; } +interface CinematicManifestData { + cinematics: CinematicData[]; +} + +interface CinematicData { + id: string; + timecode?: number; + cameraKeyframes: CinematicKeyframeData[]; +} + +interface CinematicKeyframeData { + time: number; + position: [number, number, number]; + target: [number, number, number]; +} + function isSrtPayload(data: unknown): data is SrtPayload { if (!data || typeof data !== "object") return false; @@ -403,6 +463,80 @@ function parseDialogueData(data: unknown, voiceIds: Set): DialogueData { return dialogue; } +function parseCinematicManifestData(data: unknown): CinematicManifestData { + if (!isRecord(data) || data.version !== 1) { + throw new Error("Invalid cinematic manifest"); + } + + if (!Array.isArray(data.cinematics)) { + throw new Error("Cinematic manifest requires a cinematics array"); + } + + return { + cinematics: data.cinematics.map(parseCinematicData), + }; +} + +function parseCinematicData(data: unknown): CinematicData { + if (!isRecord(data) || typeof data.id !== "string") { + throw new Error("Invalid cinematic definition"); + } + + if (!Array.isArray(data.cameraKeyframes)) { + throw new Error(`Cinematic ${data.id} requires cameraKeyframes`); + } + + const cameraKeyframes = data.cameraKeyframes.map(parseCinematicKeyframeData); + if (cameraKeyframes.length < 2) { + throw new Error(`Cinematic ${data.id} requires at least two keyframes`); + } + + cameraKeyframes.forEach((keyframe, index) => { + const previousKeyframe = cameraKeyframes[index - 1]; + if (previousKeyframe && keyframe.time <= previousKeyframe.time) { + throw new Error(`Cinematic ${data.id} keyframe times must increase`); + } + }); + + const cinematic: CinematicData = { + id: data.id, + cameraKeyframes, + }; + + if (data.timecode !== undefined) { + if (typeof data.timecode !== "number") { + throw new Error(`Cinematic ${data.id} has an invalid timecode`); + } + cinematic.timecode = data.timecode; + } + + return cinematic; +} + +function parseCinematicKeyframeData(data: unknown): CinematicKeyframeData { + if (!isRecord(data) || typeof data.time !== "number") { + throw new Error("Invalid cinematic camera keyframe"); + } + + return { + time: data.time, + position: parseCinematicVector(data.position), + target: parseCinematicVector(data.target), + }; +} + +function parseCinematicVector(value: unknown): [number, number, number] { + if ( + !Array.isArray(value) || + value.length !== 3 || + value.some((item) => typeof item !== "number") + ) { + throw new Error("Invalid cinematic vector"); + } + + return [value[0], value[1], value[2]]; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -447,6 +581,7 @@ export default defineConfig({ saveMapPlugin(), saveSrtPlugin(), saveDialogueManifestPlugin(), + saveCinematicManifestPlugin(), validateDialoguesPlugin(), ], resolve: {