update: add cinecmatic editor
This commit is contained in:
@@ -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<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 getManifestErrors(manifest: CinematicManifest | null): 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.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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<CinematicManifest | null>(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<void> {
|
||||||
|
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<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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Undo2,
|
Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||||
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
@@ -239,6 +240,7 @@ export function EditorControls({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<EditorCinematicManifestPanel />
|
||||||
<EditorDialogueManifestPanel />
|
<EditorDialogueManifestPanel />
|
||||||
<EditorSrtPanel />
|
<EditorSrtPanel />
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+180
@@ -1836,6 +1836,186 @@ canvas {
|
|||||||
padding-left: 16px;
|
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 */
|
/* Editor responsive layout */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.editor-error h2 {
|
.editor-error h2 {
|
||||||
|
|||||||
+135
@@ -13,6 +13,7 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|||||||
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
|
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
|
||||||
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
|
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
|
||||||
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
|
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
const JSON_HEADERS = { "Content-Type": "application/json" };
|
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||||
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
||||||
type JsonObject = { readonly [key: string]: JsonValue };
|
type JsonObject = { readonly [key: string]: JsonValue };
|
||||||
@@ -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 {
|
interface SrtPayload {
|
||||||
voice: string;
|
voice: string;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -232,6 +276,22 @@ interface DialogueData {
|
|||||||
timecode?: number;
|
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 {
|
function isSrtPayload(data: unknown): data is SrtPayload {
|
||||||
if (!data || typeof data !== "object") return false;
|
if (!data || typeof data !== "object") return false;
|
||||||
|
|
||||||
@@ -403,6 +463,80 @@ function parseDialogueData(data: unknown, voiceIds: Set<string>): DialogueData {
|
|||||||
return dialogue;
|
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<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null;
|
return typeof value === "object" && value !== null;
|
||||||
}
|
}
|
||||||
@@ -447,6 +581,7 @@ export default defineConfig({
|
|||||||
saveMapPlugin(),
|
saveMapPlugin(),
|
||||||
saveSrtPlugin(),
|
saveSrtPlugin(),
|
||||||
saveDialogueManifestPlugin(),
|
saveDialogueManifestPlugin(),
|
||||||
|
saveCinematicManifestPlugin(),
|
||||||
validateDialoguesPlugin(),
|
validateDialoguesPlugin(),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user