add: cinematic preview

This commit is contained in:
Tom Boullay
2026-05-11 12:53:18 +02:00
parent f9e7243659
commit 0b58b9aeef
5 changed files with 147 additions and 5 deletions
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, RefreshCw, Save, Trash2 } from "lucide-react"; import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
import type { import type {
CinematicCameraKeyframe, CinematicCameraKeyframe,
CinematicDefinition, CinematicDefinition,
@@ -124,7 +124,13 @@ function updateVector(
return nextVector; return nextVector;
} }
export function EditorCinematicManifestPanel(): React.JSX.Element { interface EditorCinematicManifestPanelProps {
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
}
export function EditorCinematicManifestPanel({
onPreviewCinematic,
}: EditorCinematicManifestPanelProps): React.JSX.Element {
const [manifest, setManifest] = useState<CinematicManifest | null>(null); const [manifest, setManifest] = useState<CinematicManifest | null>(null);
const [selectedCinematicId, setSelectedCinematicId] = useState(""); const [selectedCinematicId, setSelectedCinematicId] = useState("");
const [status, setStatus] = useState("Chargement des cinematics..."); const [status, setStatus] = useState("Chargement des cinematics...");
@@ -431,6 +437,16 @@ export function EditorCinematicManifestPanel(): React.JSX.Element {
)} )}
</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 <button
className="editor-cinematic-manifest-delete" className="editor-cinematic-manifest-delete"
type="button" type="button"
+4 -1
View File
@@ -15,6 +15,7 @@ import {
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel"; 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 { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode } from "@/types/editor/editor"; import type { MapNode, TransformMode } from "@/types/editor/editor";
interface EditorControlsProps { interface EditorControlsProps {
@@ -31,6 +32,7 @@ interface EditorControlsProps {
onExportJson: () => void; onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined; onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined; onPlayerMode?: (() => void) | undefined;
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
isPlayerMode?: boolean; isPlayerMode?: boolean;
} }
@@ -62,6 +64,7 @@ export function EditorControls({
onExportJson, onExportJson,
onSaveToServer, onSaveToServer,
onPlayerMode, onPlayerMode,
onPreviewCinematic,
isPlayerMode, isPlayerMode,
}: EditorControlsProps): React.JSX.Element { }: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view"; const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
@@ -240,7 +243,7 @@ export function EditorControls({
</div> </div>
</section> </section>
<EditorCinematicManifestPanel /> <EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
<EditorDialogueManifestPanel /> <EditorDialogueManifestPanel />
<EditorSrtPanel /> <EditorSrtPanel />
</aside> </aside>
+96 -2
View File
@@ -1,9 +1,18 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei"; 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 { EditorMap } from "@/components/editor/scene/EditorMap";
import { FlyController } from "@/controls/editor/FlyController"; import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor"; import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
export interface EditorCinematicPreviewRequest {
id: string;
cinematic: CinematicDefinition;
}
interface EditorSceneProps { interface EditorSceneProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
@@ -18,6 +27,8 @@ interface EditorSceneProps {
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
isPlayerMode?: boolean; isPlayerMode?: boolean;
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
onCinematicPreviewComplete?: (() => void) | undefined;
} }
export function EditorScene({ export function EditorScene({
@@ -34,7 +45,11 @@ export function EditorScene({
onUndo, onUndo,
onRedo, onRedo,
isPlayerMode = false, isPlayerMode = false,
cinematicPreviewRequest = null,
onCinematicPreviewComplete,
}: EditorSceneProps): React.JSX.Element { }: EditorSceneProps): React.JSX.Element {
const isCinematicPreviewing = cinematicPreviewRequest !== null;
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
@@ -74,10 +89,16 @@ export function EditorScene({
return ( return (
<> <>
<EditorCinematicPreviewPlayer
request={cinematicPreviewRequest}
onComplete={onCinematicPreviewComplete}
/>
{isPlayerMode ? ( {isPlayerMode ? (
<FlyController disabled={false} /> <FlyController disabled={isCinematicPreviewing} />
) : ( ) : (
<OrbitControls <OrbitControls
enabled={!isCinematicPreviewing}
enableDamping enableDamping
dampingFactor={0.05} dampingFactor={0.05}
mouseButtons={{ 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;
}
+8
View File
@@ -1863,6 +1863,7 @@ canvas {
} }
.editor-cinematic-manifest-actions button, .editor-cinematic-manifest-actions button,
.editor-cinematic-manifest-preview,
.editor-cinematic-manifest-delete, .editor-cinematic-manifest-delete,
.editor-cinematic-keyframes-heading button, .editor-cinematic-keyframes-heading button,
.editor-cinematic-keyframe-heading button { .editor-cinematic-keyframe-heading button {
@@ -1881,6 +1882,7 @@ canvas {
} }
.editor-cinematic-manifest-actions button:hover, .editor-cinematic-manifest-actions button:hover,
.editor-cinematic-manifest-preview:hover,
.editor-cinematic-manifest-delete:hover, .editor-cinematic-manifest-delete:hover,
.editor-cinematic-keyframes-heading button:hover, .editor-cinematic-keyframes-heading button:hover,
.editor-cinematic-keyframe-heading button:hover { .editor-cinematic-keyframe-heading button:hover {
@@ -1889,6 +1891,7 @@ canvas {
} }
.editor-cinematic-manifest-actions button:disabled, .editor-cinematic-manifest-actions button:disabled,
.editor-cinematic-manifest-preview:disabled,
.editor-cinematic-keyframe-heading button:disabled { .editor-cinematic-keyframe-heading button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.45; opacity: 0.45;
@@ -1988,6 +1991,11 @@ canvas {
color: #fca5a5; color: #fca5a5;
} }
.editor-cinematic-manifest-preview {
border-color: rgba(125, 211, 252, 0.24);
color: #bae6fd;
}
.editor-cinematic-keyframe-heading button { .editor-cinematic-keyframe-heading button {
padding: 6px 8px; padding: 6px 8px;
color: #fca5a5; color: #fca5a5;
+21
View File
@@ -2,8 +2,10 @@ import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import { EditorControls } from "@/components/editor/EditorControls"; import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene"; import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
import { Subtitles } from "@/components/ui/Subtitles"; import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory"; import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData"; import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor"; import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
@@ -29,6 +31,8 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] = const [transformMode, setTransformMode] =
useState<TransformMode>("translate"); useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false); const [isPlayerMode, setIsPlayerMode] = useState(false);
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
useState<EditorCinematicPreviewRequest | null>(null);
const { const {
undoCount, undoCount,
@@ -89,6 +93,20 @@ export function EditorPage(): React.JSX.Element {
setIsPlayerMode((prev) => !prev); setIsPlayerMode((prev) => !prev);
}, []); }, []);
const handlePreviewCinematic = useCallback(
(cinematic: CinematicDefinition) => {
setCinematicPreviewRequest({
id: window.crypto.randomUUID(),
cinematic,
});
},
[],
);
const handleCinematicPreviewComplete = useCallback(() => {
setCinematicPreviewRequest(null);
}, []);
const handleNodeTransform = useCallback( const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => { (nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => { setSceneData((prev) => {
@@ -172,6 +190,8 @@ export function EditorPage(): React.JSX.Element {
onUndo={handleUndo} onUndo={handleUndo}
onRedo={handleRedo} onRedo={handleRedo}
isPlayerMode={isPlayerMode} isPlayerMode={isPlayerMode}
cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete}
/> />
</Canvas> </Canvas>
@@ -194,6 +214,7 @@ export function EditorPage(): React.JSX.Element {
onExportJson={handleExportJson} onExportJson={handleExportJson}
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined} onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
onPlayerMode={handlePlayerMode} onPlayerMode={handlePlayerMode}
onPreviewCinematic={handlePreviewCinematic}
isPlayerMode={isPlayerMode} isPlayerMode={isPlayerMode}
/> />
)} )}