diff --git a/.gitignore b/.gitignore index 52dcec6..6786024 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Build dist/ dist-ssr/ +.vite/ *.local # Environment diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index ff32f2d..b9c508c 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -44,8 +44,44 @@ Keep the player and map octree outside the Rapier provider until there is a deli ## Audio -- `src/managers/AudioManager.ts` currently provides pooled one-shot sound playback and looped music playback. -- Trigger interactions may play audio directly through `AudioManager`. +- `src/managers/AudioManager.ts` provides pooled one-shot playback, looped music playback, category volumes, and optional stereo pan for one-shot sounds. +- Supported audio categories are `music`, `sfx`, and `dialogue`. +- Trigger interactions may play SFX directly through `AudioManager`. + +## Settings Menu + +- `src/managers/stores/useSettingsStore.ts` stores settings for music volume, SFX volume, dialogue volume, subtitle visibility, subtitle language, repair runtime, and menu visibility. +- `src/components/ui/GameSettingsMenu.tsx` renders the in-game options menu. +- `src/components/ui/GameUI.tsx` mounts the settings menu as an HTML overlay outside the canvas. +- `Esc` opens and closes the menu, and `src/world/player/PlayerController.tsx` ignores player input while the menu is open. +- Volume changes are forwarded to `AudioManager` by category. + +## Dialogues And Subtitles + +- `public/sounds/dialogue/dialogues.json` is the runtime dialogue manifest. +- Dialogue audio files live under `public/sounds/dialogue/`. +- Subtitle files live under `public/sounds/dialogue/subtitles/{fr|en}/`. +- The current subtitle model is one SRT file per voice and language. +- `src/types/dialogues/dialogues.ts` contains the dialogue manifest types. +- `src/utils/dialogues/dialogueManifestValidation.ts` validates manifest shape at runtime. +- `src/utils/dialogues/loadDialogueManifest.ts` loads the manifest and SRT cues, with French fallback when the selected language is missing. +- `src/utils/subtitles/parseSrt.ts` parses SRT blocks and timecodes. +- `src/utils/dialogues/playDialogue.ts` plays dialogue audio and synchronizes the active subtitle against the audio element time. +- `src/managers/stores/useSubtitleStore.ts` stores the currently displayed subtitle cue. +- `src/components/ui/Subtitles.tsx` renders the subtitle overlay. +- `src/world/GameDialogues.tsx` currently triggers dialogue entries that define a `timecode`. +- Dialogue playback is queued so multiple dialogue requests do not overlap. + +## Cinematics + +- `public/cinematics.json` is the runtime cinematic manifest. +- `src/types/cinematics/cinematics.ts` contains cinematic manifest types. +- `src/utils/cinematics/cinematicManifestValidation.ts` validates manifest shape at runtime. +- `src/utils/cinematics/loadCinematicManifest.ts` loads `/cinematics.json`. +- `src/world/GameCinematics.tsx` triggers cinematics that define a global `timecode`. +- Cinematics use GSAP timelines to animate the active camera position and look target. +- `dialogueCues` on a cinematic trigger dialogue IDs at times relative to the cinematic start. +- `src/managers/stores/useGameStore.ts` exposes `isCinematicPlaying`, used to lock player input during cinematics. ## Debug System @@ -74,6 +110,9 @@ Keep the player and map octree outside the Rapier provider until there is a deli - `src/pages/editor/page.tsx` is the route-level editor page for `/editor`. - `src/components/editor/EditorControls.tsx` renders the HTML editor control panel. +- `src/components/editor/EditorDialogueManifestPanel.tsx` edits `public/sounds/dialogue/dialogues.json`. +- `src/components/editor/EditorCinematicManifestPanel.tsx` edits `public/cinematics.json`. +- `src/components/editor/EditorSrtPanel.tsx` renders the dialogue SRT editor inside the editor control panel. - `src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, shortcuts, and map rendering. - `src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. - `src/controls/editor/FlyController.tsx` provides player-style editor navigation. @@ -97,6 +136,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli - The repository is a prototype, not the full intended game runtime. - `src/world/debug/TestMap.tsx` is part of the active scene composition. - There is no central gameplay orchestrator such as `GameManager`. -- The mission state exists in Zustand, but zones, cinematics, dialogue, and the full repair sequence are not implemented. +- Mission state exists in Zustand and the repair flow is implemented as a prototype for the current repair missions. +- Cinematics and dialogues exist as prototype timecode-driven systems; dialogue branching and broader gameplay orchestration are still limited. - The player uses octree collision and simple movement rules, not a complete gameplay physics stack. - Editor save-to-server is implemented as a Vite dev-server plugin, not a production backend API. diff --git a/docs/technical/editor.md b/docs/technical/editor.md index 6a0a4c0..0e6c9b1 100644 --- a/docs/technical/editor.md +++ b/docs/technical/editor.md @@ -23,6 +23,9 @@ src/ ├── components/ │ └── editor/ │ ├── EditorControls.tsx +│ ├── EditorCinematicManifestPanel.tsx +│ ├── EditorDialogueManifestPanel.tsx +│ ├── EditorSrtPanel.tsx │ └── scene/ │ ├── EditorMap.tsx │ └── EditorScene.tsx @@ -37,10 +40,14 @@ src/ │ └── editor/ │ └── editor.ts └── utils/ + ├── dialogues/ + │ └── loadDialogueManifest.ts ├── editor/ │ └── loadEditorScene.ts - └── map/ - └── loadMapSceneData.ts + ├── map/ + │ └── loadMapSceneData.ts + └── subtitles/ + └── parseSrt.ts ``` ## Responsibilities @@ -57,6 +64,12 @@ src/ `src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. +`src/components/editor/EditorDialogueManifestPanel.tsx` renders the dialogue manifest editor. It loads `dialogues.json`, edits dialogue entries, previews selected dialogue playback, creates missing French SRT cues, and saves the manifest through a dev-server endpoint. + +`src/components/editor/EditorCinematicManifestPanel.tsx` renders the cinematic manifest editor. It loads `cinematics.json`, edits camera keyframes and dialogue cues, previews selected cinematics in the editor canvas, and saves the manifest through a dev-server endpoint. + +`src/components/editor/EditorSrtPanel.tsx` renders the dialogue subtitle editor inside the control panel. It loads the dialogue manifest, loads one SRT file per voice/language, validates cue structure, previews dialogue audio, and can save SRT files through a dev-server endpoint. + `src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation. `src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json` and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`. @@ -134,6 +147,78 @@ The editor supports two output paths: The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size. +## Dialogue SRT Editing + +Dialogue subtitle editing is part of the `/editor` side panel. + +Runtime dialogue files are grouped under `public/sounds/dialogue/`: + +```txt +public/ +└── sounds/ + └── dialogue/ + ├── dialogues.json + └── subtitles/ + ├── fr/ + │ ├── narrateur.srt + │ ├── fermier.srt + │ └── electricienne.srt + └── en/ + └── ... +``` + +The current model is one SRT file per voice and language. A dialogue entry references the cue it needs through `subtitleCueIndex`; it does not own a dedicated SRT file. + +`EditorSrtPanel` uses: + +- `loadDialogueManifest()` to read `/sounds/dialogue/dialogues.json` +- `parseSrt()` to validate local textarea content and find active cues during audio preview +- `/api/save-srt` to write edited SRT files during local development +- `/api/validate-dialogues` to validate the manifest, linked audio, French SRT files, and referenced cue indexes + +SRT timecodes are relative to the dialogue audio file being previewed, not to the global game timeline. + +Missing English SRT files are warnings, not errors, because runtime loading falls back to French subtitles when the selected language is not available. Keep this behavior until the English translation workflow is ready. + +## Dialogue Manifest Editing + +`EditorDialogueManifestPanel` edits `public/sounds/dialogue/dialogues.json` in memory and persists it through `/api/save-dialogues`. + +The panel supports: + +- adding a dialogue entry +- deleting a dialogue entry +- editing `id`, `voice`, `audio`, `subtitleCueIndex`, and optional `timecode` +- previewing the selected dialogue through `playDialogueById()` +- creating a missing French SRT cue through `/api/save-srt` + +When a dialogue is added, the editor computes the next `subtitleCueIndex` for the selected voice from the manifest. The generated SRT cue is a valid placeholder block and should be edited later in the SRT panel. + +`/api/save-dialogues` is implemented in `vite.config.ts`. It validates manifest shape before writing to `public/sounds/dialogue/dialogues.json`. + +## Cinematic Manifest Editing + +`EditorCinematicManifestPanel` edits `public/cinematics.json` in memory and persists it through `/api/save-cinematics`. + +The manifest shape is: + +```ts +interface CinematicDefinition { + id: string; + timecode?: number; + cameraKeyframes: CinematicCameraKeyframe[]; + dialogueCues?: CinematicDialogueCue[]; +} +``` + +`cameraKeyframes` are relative to the cinematic start. At least two keyframes are required and keyframe times must increase. + +`dialogueCues` are also relative to the cinematic start and reference dialogue IDs from `dialogues.json`. They are used by `GameCinematics` to synchronize dialogue playback with camera timelines. A dialogue synchronized this way should not also define a global `timecode` in `dialogues.json`. + +The editor preview sends the selected `CinematicDefinition` to `EditorScene`, where GSAP animates the current editor camera. Orbit and fly controls are disabled during preview. + +`/api/save-cinematics` is implemented in `vite.config.ts`. It validates manifest shape before writing to `public/cinematics.json`. + ## Styling Editor styles are in `src/index.css` under the `/* Editor page */` section. Classes are prefixed with `editor-` to avoid collisions with the game UI. @@ -144,3 +229,6 @@ Editor styles are in `src/index.css` under the `/* Editor page */` section. Clas - Large `map.json` files are not virtualized, culled, or LOD-managed. - There is no snap-to-grid, duplication, material editing, or object creation workflow. - Save to Server is a Vite dev-server helper, not a production backend API. +- SRT Save is also a Vite dev-server helper, not a production backend API. +- Dialogue and cinematic manifest saves are Vite dev-server helpers, not production backend APIs. +- Dialogue creation still uses placeholder audio paths until real MP3 files are added. diff --git a/docs/user/editor.md b/docs/user/editor.md index d98ba2a..af2dd5c 100644 --- a/docs/user/editor.md +++ b/docs/user/editor.md @@ -74,6 +74,87 @@ This is useful for checking numeric transform values before saving or exporting. The button is hidden in production builds because production persistence is not implemented. +## Editing Dialogue Subtitles + +The side panel also includes dialogue tools for the dialogue manifest and SRT subtitles. + +### Dialogue Manifest + +Use the `Dialogues` panel to edit `public/sounds/dialogue/dialogues.json` without opening the JSON file manually. + +Available actions: + +- `Reload` reloads the manifest from disk. +- `Add` creates a local dialogue entry for the current voice and assigns the next available SRT cue index. +- `Save` writes the manifest through the local Vite dev server. +- `Preview dialogue` plays the selected dialogue and shows subtitles in the editor overlay. +- `Create FR SRT cue` creates the matching French SRT cue if it is missing. +- `Delete dialogue` removes the selected entry locally. + +After using `Add`, save the manifest to keep the new dialogue entry. The generated SRT cue is written immediately to the French SRT file, but the dialogue manifest is still only local until `Save` is clicked. + +New dialogue audio paths start as placeholders such as `/sounds/dialogue/new_dialogue_24.mp3`. Replace them with real MP3 paths before validating the final asset set. + +### SRT Editor + +Use the `SRT` panel to edit one subtitle file at a time. + +1. Choose a voice: `narrateur`, `fermier`, or `electricienne`. +2. Choose a language: `FR` or `EN`. +3. Edit the SRT text directly in the textarea. +4. Use the audio preview to check the selected dialogue. +5. Use `Set start`, `Set end`, `-100ms`, and `+100ms` to adjust the selected cue timing against the audio. +6. Use `Save SRT` during local development, or `Export SRT` to download the file manually. + +Each SRT file belongs to one voice, not one dialogue. Cue indexes must match the `subtitleCueIndex` values referenced by the dialogue manifest. + +## Validating Dialogue Assets + +Use `Validate` in the SRT panel to check the dialogue manifest and linked assets. + +The validation checks: + +- `public/sounds/dialogue/dialogues.json` +- referenced dialogue audio files +- French SRT files +- subtitle cue indexes referenced by the manifest + +Missing English SRT files are warnings, not errors, because the runtime falls back to French subtitles. This is intentional until the English translation workflow is ready. + +## Editing Cinematics + +Use the `Cinematics` panel to edit `public/cinematics.json`. + +Each cinematic contains: + +- an `id` +- an optional global `timecode` +- two or more camera keyframes +- optional dialogue cues synchronized to the cinematic timeline + +Camera keyframes define: + +- `time`: seconds relative to the cinematic start +- `position`: camera position `[x, y, z]` +- `target`: point the camera looks at `[x, y, z]` + +Dialogue cues define: + +- `time`: seconds relative to the cinematic start +- `dialogueId`: an entry from `public/sounds/dialogue/dialogues.json` + +Available actions: + +- `Reload` reloads the cinematic manifest from disk. +- `Add` creates a new local cinematic with two camera keyframes. +- `Save` writes `public/cinematics.json` through the local Vite dev server. +- `Preview cinematic` plays the selected camera animation in the editor canvas. +- `Add keyframe` and `Remove` edit the camera path. +- `Add dialogue` and `Remove` edit dialogue cues linked to the cinematic. +- `Delete cinematic` removes the selected cinematic locally. + +Cinematic dialogue cues are the preferred way to synchronize a dialogue with a cinematic. Avoid also giving the same dialogue a global `timecode`, or it can be triggered twice. + ## Current Limitations - The editor only modifies existing nodes. @@ -81,3 +162,5 @@ The button is hidden in production builds because production persistence is not - It does not edit model files or textures. - It does not provide production persistence. - Fallback cubes indicate missing models; they are editor placeholders, not exported assets. +- SRT saving is a local Vite dev-server helper, not a production backend feature. +- Dialogue and cinematic saves are local Vite dev-server helpers, not production backend features. diff --git a/docs/user/features.md b/docs/user/features.md index 37175fb..ed7b982 100644 --- a/docs/user/features.md +++ b/docs/user/features.md @@ -38,8 +38,37 @@ This document lists features that are implemented in the current codebase. ## Audio -- One-shot sound playback for trigger interactions -- Simple per-sound pooling through `AudioManager` +- Category-based volumes for music, SFX, and dialogue +- Looped background music playback through `AudioManager` +- One-shot sound playback for SFX and dialogue, with simple per-sound pooling +- Optional stereo pan for one-shot sounds + +## Dialogue And Subtitles + +- Dialogue manifest in `public/sounds/dialogue/dialogues.json` +- Dialogue audio loaded from `public/sounds/dialogue/` +- One SRT subtitle file per voice and language +- French subtitle fallback when the selected language file is missing +- Runtime subtitle overlay with speaker-specific colors +- Timecoded dialogue trigger support for dialogue entries that define `timecode` +- Dialogue queueing to avoid overlapping dialogue playback + +## Cinematics + +- Cinematic manifest in `public/cinematics.json` +- Timecoded cinematic trigger support +- GSAP camera keyframe playback +- Optional dialogue cues synchronized to cinematic timelines +- Player input lock while a cinematic is active + +## Game Options Menu + +- `Esc` opens and closes the in-game options menu +- Music, SFX, and dialogue volume sliders +- Subtitle visibility toggle +- Subtitle language choice between French and English +- Repair runtime choice between local JavaScript and Python server mode +- Quit action that clears browser-accessible cookies and returns to `/` ## Debug Tooling @@ -64,13 +93,20 @@ This document lists features that are implemented in the current codebase. - Player-style navigation mode with `WASD`, `ZQSD`, arrow keys, `Space`, and `Shift` - JSON export for downloading the edited map - Dev-server save endpoint for writing changes back to `public/map.json` +- SRT editor for dialogue subtitles +- Audio preview and timing helpers for SRT cues +- Dev-server save endpoint for SRT files +- Dialogue manifest editor with preview and assisted French SRT cue creation +- Cinematic manifest editor with camera keyframes, dialogue cues, and canvas preview +- Dialogue manifest validation from the editor UI ## Not Implemented Yet - complete mission system - zone system -- cinematic system -- dialogue system +- full cinematic system beyond current timecode prototype +- gameplay-triggered dialogue branches beyond current prototype triggers +- loading flow - minimap and mission HUD - full production separation between gameplay and debug scenes - production backend persistence for editor saves diff --git a/public/cinematics.json b/public/cinematics.json new file mode 100644 index 0000000..c05949a --- /dev/null +++ b/public/cinematics.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "cinematics": [ + { + "id": "intro_overview", + "timecode": 0, + "dialogueCues": [ + { + "time": 0, + "dialogueId": "narrateur_bienvenueaaltera" + } + ], + "cameraKeyframes": [ + { + "time": 0, + "position": [8, 5, 12], + "target": [0, 2, 0] + }, + { + "time": 4, + "position": [12, 4, -6], + "target": [10, 1.4, -8] + } + ] + } + ] +} diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json new file mode 100644 index 0000000..ff0d23d --- /dev/null +++ b/public/sounds/dialogue/dialogues.json @@ -0,0 +1,187 @@ +{ + "version": 1, + "voices": [ + { + "id": "narrateur", + "speaker": "Narrateur", + "subtitles": { + "fr": "/sounds/dialogue/subtitles/fr/narrateur.srt", + "en": "/sounds/dialogue/subtitles/en/narrateur.srt" + } + }, + { + "id": "fermier", + "speaker": "Fermier", + "subtitles": { + "fr": "/sounds/dialogue/subtitles/fr/fermier.srt", + "en": "/sounds/dialogue/subtitles/en/fermier.srt" + } + }, + { + "id": "electricienne", + "speaker": "Electricienne", + "subtitles": { + "fr": "/sounds/dialogue/subtitles/fr/electricienne.srt", + "en": "/sounds/dialogue/subtitles/en/electricienne.srt" + } + } + ], + "dialogues": [ + { + "id": "narrateur_bienvenueaaltera", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3", + "subtitleCueIndex": 1 + }, + { + "id": "narrateur_intro_prenom", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_intro_prenom.mp3", + "subtitleCueIndex": 2 + }, + { + "id": "narrateur_intro_apresprenom", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_intro_apresprenom.mp3", + "subtitleCueIndex": 3 + }, + { + "id": "narrateur_ordreebike", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ordreebike.mp3", + "subtitleCueIndex": 4 + }, + { + "id": "narrateur_ebikecasse", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ebikecassé.mp3", + "subtitleCueIndex": 5 + }, + { + "id": "narrateur_galetscan", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_galetscan.mp3", + "subtitleCueIndex": 6 + }, + { + "id": "narrateur_ebikerepare", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ebikeréparé.mp3", + "subtitleCueIndex": 7 + }, + { + "id": "narrateur_ordredemandedelaide", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ordredemandedelaide.mp3", + "subtitleCueIndex": 8 + }, + { + "id": "narrateur_coupureelec", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_coupureélec.mp3", + "subtitleCueIndex": 9 + }, + { + "id": "narrateur_poteaueleccasse", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3", + "subtitleCueIndex": 10 + }, + { + "id": "narrateur_courantrepare", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_courantréparé.mp3", + "subtitleCueIndex": 11 + }, + { + "id": "narrateur_routeversferme", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_routeversferme.mp3", + "subtitleCueIndex": 12 + }, + { + "id": "narrateur_arriveferme", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_arrivéferme.mp3", + "subtitleCueIndex": 13 + }, + { + "id": "narrateur_fouillelecentre", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_fouillelecentre.mp3", + "subtitleCueIndex": 14 + }, + { + "id": "narrateur_interactiontuyauxlac", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_interactiontuyauxlac.mp3", + "subtitleCueIndex": 15 + }, + { + "id": "narrateur_interactionrefroidisseur", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_interactionrefroidisseur.mp3", + "subtitleCueIndex": 16 + }, + { + "id": "narrateur_refroidisseurcasse", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_refroidisseurcassé.mp3", + "subtitleCueIndex": 17 + }, + { + "id": "narrateur_createurdepluiecree", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_createurdepluiecréé.mp3", + "subtitleCueIndex": 18 + }, + { + "id": "narrateur_remerciement", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_remerciement.mp3", + "subtitleCueIndex": 19 + }, + { + "id": "narrateur_bonnechance", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_bonnechance.mp3", + "subtitleCueIndex": 20 + }, + { + "id": "narrateur_presentationatelier", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_présentationatelier.mp3", + "subtitleCueIndex": 21 + }, + { + "id": "narrateur_presentationoutils", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_présentationoutils.mp3", + "subtitleCueIndex": 22 + }, + { + "id": "narrateur_histoireelectricienne", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3", + "subtitleCueIndex": 23 + }, + { + "id": "fermier_coupdemain", + "voice": "fermier", + "audio": "/sounds/dialogue/fermier_coupdemain.mp3", + "subtitleCueIndex": 1 + }, + { + "id": "fermier_coupdemain_2", + "voice": "fermier", + "audio": "/sounds/dialogue/fermier_coupdemain_2.mp3", + "subtitleCueIndex": 2 + }, + { + "id": "fermier_findemission", + "voice": "fermier", + "audio": "/sounds/dialogue/fermier_findemission.mp3", + "subtitleCueIndex": 3 + } + ] +} diff --git a/public/sounds/dialogue/narrateur_histoireleonie.mp3 b/public/sounds/dialogue/narrateur_histoireelectricienne.mp3 similarity index 100% rename from public/sounds/dialogue/narrateur_histoireleonie.mp3 rename to public/sounds/dialogue/narrateur_histoireelectricienne.mp3 diff --git a/public/sounds/dialogue/subtitles/en/README.md b/public/sounds/dialogue/subtitles/en/README.md new file mode 100644 index 0000000..04648de --- /dev/null +++ b/public/sounds/dialogue/subtitles/en/README.md @@ -0,0 +1,11 @@ +# English Subtitle Fallback + +English SRT files are intentionally optional for now. + +The dialogue runtime first tries the selected subtitle language, then falls back to French. Missing English files should therefore remain validation warnings, not blocking errors, until the English translation workflow is ready. + +Expected future files: + +- `narrateur.srt` +- `fermier.srt` +- `electricienne.srt` diff --git a/public/sounds/dialogue/subtitles/en/electricienne.srt b/public/sounds/dialogue/subtitles/en/electricienne.srt new file mode 100644 index 0000000..6965992 --- /dev/null +++ b/public/sounds/dialogue/subtitles/en/electricienne.srt @@ -0,0 +1,11 @@ +1 +00:00:00,000 --> 00:00:08,000 +Hey!! How are you? Do you need help placing the rollers? + +2 +00:00:00,000 --> 00:00:08,000 +Don't hesitate if you need anything else! + +3 +00:00:00,000 --> 00:00:08,000 +See you next time! diff --git a/public/sounds/dialogue/subtitles/en/fermier.srt b/public/sounds/dialogue/subtitles/en/fermier.srt new file mode 100644 index 0000000..a46afaa --- /dev/null +++ b/public/sounds/dialogue/subtitles/en/fermier.srt @@ -0,0 +1,11 @@ +1 +00:00:00,000 --> 00:00:04,032 +Wait, wait, young man! I'll give you a hand. + +2 +00:00:00,000 --> 00:00:03,744 +I did puzzles all through my youth. Try this! + +3 +00:00:00,000 --> 00:00:07,104 +If you need anything else, don't hesitate, my boy. I'm getting old, but my mind is still sharp, hehehe! diff --git a/public/sounds/dialogue/subtitles/en/narrateur.srt b/public/sounds/dialogue/subtitles/en/narrateur.srt new file mode 100644 index 0000000..b6f3ffd --- /dev/null +++ b/public/sounds/dialogue/subtitles/en/narrateur.srt @@ -0,0 +1,91 @@ +1 +00:00:00,000 --> 00:00:02,760 +Hello there, future resident of Altera! Today, you are going to discover the technician role at La Fabrik, which handles Low-Tech technologies and repairs. + +2 +00:00:00,000 --> 00:00:11,592 +Before we start, what's your name? + +3 +00:00:00,000 --> 00:00:10,824 +Very good! We'll begin step by step to show you how the workshop works. Then you'll start your day and see the positive impact La Fabrik has on the community and the neighborhood. + +4 +00:00:00,000 --> 00:00:06,072 +Let's go! You need to head to the farm, we're looking to improve something! Hop on your E-Bike. + +5 +00:00:00,000 --> 00:00:12,720 +What? Your E-Bike is broken? Well, that's not too serious, it happens! Use the two rollers on your gloves. They're real technological gems. Place one under the bike, and one above it. + +6 +00:00:00,000 --> 00:00:08,064 +So? Pretty amazing, right? Anyway, these rollers will scan the components to find out what we need to repair and/or replace. + +7 +00:00:00,000 --> 00:00:04,992 +Perfect! The cooler gave out, you can replace it with one of the components from your pack. Aaaand there we go! It runs like clockwork! Go on, hurry! + +8 +00:00:00,000 --> 00:00:04,512 +Don't hesitate to ask for help if you need it, everyone is super welcoming here. + +9 +00:00:00,000 --> 00:00:08,880 +Oh woooow!! Did you see that???? All the traffic lights, computers and lights went out!! Hurry to the Energy Center, we can't even send repaired devices back out! + +10 +00:00:00,000 --> 00:00:09,840 +Ah! A power pole fell down! Damn! Aaah, those little moles, they cause so much trouble... But they're so cuuuute! + +11 +00:00:00,000 --> 00:00:07,632 +Woohoo! Great! Power is back across the whole neighborhood! Well done! Now head to the farm. + +12 +00:00:00,000 --> 00:00:05,352 +Well, thanks to you I was able to finish my emergency! Oh, you're almost at the farm! + +13 +00:00:00,000 --> 00:00:11,760 +Okay, enough of the emotional moment haha! For the farm, as I told you, we need to change the irrigation here. During drought periods, residents complain about an issue. See what you can do. + +14 +00:00:00,000 --> 00:00:04,560 +Okay, perfect, you're there! Search the Center to find where the problem is coming from. + +15 +00:00:00,000 --> 00:00:06,864 +Yeees! That's it! We'd like to stop pumping water from the lake, otherwise we'll drain all its reserves. What do you suggest? + +16 +00:00:00,000 --> 00:00:10,944 +The old cooler from your E-Bike?? Yes!! Hahaha, great idea! Combined with the old lake pipes, we'll be able to make something cool! Put all that between your pads! + +17 +00:00:00,000 --> 00:00:05,712 +The cooler from your E-Bike is broken, but we can still make something useful out of it. + +18 +00:00:00,000 --> 00:00:10,032 +Ma-gni-fi-cent! I can see Gilbert helped you haha, he's such a sweetheart! You did a great job! And thanks to you, the neighborhood has been improved. + +19 +00:00:00,000 --> 00:00:11,520 +Thank you so much for what you've brought to the community. The electrician and Gilbert really enjoyed helping you and told me they're looking forward to the next village party to get to know you better. + +20 +00:00:00,000 --> 00:00:02,352 +Good luck! I've got work to do! + +21 +00:00:00,000 --> 00:00:33,600 +Welcome to your workshop!! So? Pretty impressive, right? Okay, quick tour of what's here: this is your workbench. In the pipes are items from neighborhood residents that broke down and are waiting to be repaired. Once repaired, you put the item in this pipe and it goes back to the right person. + +22 +00:00:00,000 --> 00:00:14,760 +Here, this is a dashboard. You can imagine that if your fridge or oven breaks down, you won't be able to put it in the pipe haha! So here, it tells you when residents have a bulky item that broke down, or when there's a problem in the city. Uh oh... I've got an emergency, I'll have to leave you soon! So here, take your tools to repair most things: a mini 3D printer powered by electronic waste, Push-Parts gloves to disassemble objects, and a Relaunch pack! + +23 +00:00:00,000 --> 00:00:54,000 +The electrician helped you at the Power Plant? Aaaaah, that's what I love here: everyone helps each other, nobody judges anyone, it's like a real little family. You should know the electrician has quite a special story. She was born in the north of the continent, in the city of Kalska. She grew up happily with her mother Edith, her father Jordan, and her two little brothers, Malo and Justin. A few years ago, as you know, the northern countries were, quite unexpectedly, the first ones forced to migrate. So they began their journey, country by country, city by city, village by village. On a day of walking like so many others after several months, a climate storm caught them off guard. Having split up to find food in the village, her father and one of her two brothers sadly disappeared. It's tragic. But one day, they happened upon this place during their journey. We welcomed them with open arms, and they were slowly able to rebuild their lives among us. Today, they are an integral part of the community. diff --git a/public/sounds/dialogue/subtitles/fr/electricienne.srt b/public/sounds/dialogue/subtitles/fr/electricienne.srt new file mode 100644 index 0000000..a1ed9dd --- /dev/null +++ b/public/sounds/dialogue/subtitles/fr/electricienne.srt @@ -0,0 +1,11 @@ +1 +00:00:00,000 --> 00:00:08,000 +Hey !! Comment ça va ? Tu as besoin d'aide pour poser les galets ? + +2 +00:00:00,000 --> 00:00:08,000 +N'hésite pas, si tu as besoin d'autre chose ! + +3 +00:00:00,000 --> 00:00:08,000 +À la prochaine ! diff --git a/public/sounds/dialogue/subtitles/fr/fermier.srt b/public/sounds/dialogue/subtitles/fr/fermier.srt new file mode 100644 index 0000000..763d4f1 --- /dev/null +++ b/public/sounds/dialogue/subtitles/fr/fermier.srt @@ -0,0 +1,11 @@ +1 +00:00:00,000 --> 00:00:04,032 +Attendez attendez jeune homme ! Je vais vous filer un coup de main. + +2 +00:00:00,000 --> 00:00:03,744 +J'ai fait des puzzles toute ma jeunesse. Essayez donc ça ! + +3 +00:00:00,000 --> 00:00:07,104 +Si vous avez besoin d'autre chose hésitez pas mon grand. Je me fais vieux mais j'ai encore toute ma tête hehehe ! diff --git a/public/sounds/dialogue/subtitles/fr/narrateur.srt b/public/sounds/dialogue/subtitles/fr/narrateur.srt new file mode 100644 index 0000000..ab3b902 --- /dev/null +++ b/public/sounds/dialogue/subtitles/fr/narrateur.srt @@ -0,0 +1,91 @@ +1 +00:00:00,000 --> 00:00:02,760 +Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech. + +2 +00:00:00,000 --> 00:00:11,592 +Avant de commencer, comment tu t'appelles ? + +3 +00:00:00,000 --> 00:00:10,824 +Très bien ! On va commencer pas à pas pour te montrer comment fonctionne l'atelier. Ensuite, tu commenceras ta journée et tu pourras te rendre compte de l'impact positif qu'a la Fabrik sur la communauté et le quartier. + +4 +00:00:00,000 --> 00:00:06,072 +Allez go ! Il faudrait que tu ailles à la ferme, on cherche à améliorer quelque chose ! Monte sur ton E-Bike. + +5 +00:00:00,000 --> 00:00:12,720 +Quoi ? Ton E-Bike est cassé ? Bon c'est pas très grave, ça arrive ! Utilise les deux galets qui sont sur tes gants. Ce sont de véritables bijoux technologiques. Poses en un en-dessous du vélo, et un au-dessus. + +6 +00:00:00,000 --> 00:00:08,064 +Alors ? Pas magnifique ça ? Enfin bref, ces galets vont scanner les composants pour savoir ce qu'on doit réparer et / ou changer. + +7 +00:00:00,000 --> 00:00:04,992 +Parfait ! C'est le refroidisseur qui a lâché, tu peux le remplacer avec un des composants de ton pack. Eeeet voilà ! Il fonctionne comme une horloge ! Allez fonce ! + +8 +00:00:00,000 --> 00:00:04,512 +N'hésite pas à aller demander de l'aide si tu as besoin, tout le monde est super accueillant ici. + +9 +00:00:00,000 --> 00:00:08,880 +Oh woooow !! T'as vu ça ???? Tous les feux, ordinateurs et lumières se sont éteints !! Faut vite que t'aille au Centre de l'Énergie, on ne peut même plus renvoyer les appareils réparés ! + +10 +00:00:00,000 --> 00:00:09,840 +Ah ! C'est un poteau d'alimentation qui est tombé ! Mince ! Alalaaa, ces petites taupes, elles en font des bêtises... Mais elles sont si chouuuu ! + +11 +00:00:00,000 --> 00:00:07,632 +Wouuuuhouuu ! Super ! Le courant est revenu dans tout le quartier ! Bien joué ! Allez, fonce à la ferme. + +12 +00:00:00,000 --> 00:00:05,352 +Booon, grâce à toi j'ai pu finir mon urgence ! Oh mais t'arrives bientôt à la ferme ! + +13 +00:00:00,000 --> 00:00:11,760 +Bon, fini le moment émotion haha ! Pour la ferme, comme je te l'ai dis, ici, il faut qu'on change l'irrigation. Durant les périodes de sécheresse, les habitants se plaignent d'un souci. Vois ce que tu peux faire. + +14 +00:00:00,000 --> 00:00:04,560 +Ok parfait tu y es ! Fouille le Centre pour voir d'où vient le problème. + +15 +00:00:00,000 --> 00:00:06,864 +Ouiii ! C'est ça ! On aimerait ne plus pomper l'eau dans le lac, sinon on va épuiser toutes ses réserves. Qu'est-ce que tu proposes ? + +16 +00:00:00,000 --> 00:00:10,944 +L'ancien refroidisseur de ton E-Bike ?? Mais oui !! Hahaha, très bonne idée ! Combiné aux anciens tuyaux du lac, on va pouvoir faire quelque chose de cool ! Met tout ça entre tes pads ! + +17 +00:00:00,000 --> 00:00:05,712 +Le refroidisseur de ton E-Bike est cassé, mais on peut encore en faire quelque chose d'utile. + +18 +00:00:00,000 --> 00:00:10,032 +Ma-gni-fique ! Je vois que Gilbert t'as aidé haha, il est adorable celui-là ! Tu as fait du super boulot ! Et grâce à toi, le quartier est amélioré. + +19 +00:00:00,000 --> 00:00:11,520 +Merci beaucoup pour ce que tu as apporté à la communauté. L'électricienne et Gilbert ont beaucoup apprécié t'aider et m'ont dit qu'ils avaient hâte de la prochaine fête de village pour mieux apprendre à te connaître. + +20 +00:00:00,000 --> 00:00:02,352 +Allez bonne chance ! J'ai du boulot ! + +21 +00:00:00,000 --> 00:00:33,600 +Bienvenue dans ton atelier !! Alors ? Ça claque hein ? Bon je te présente en rapide tout ce qu'il y a : ici c'est ton plan de travail. Dans les tuyaux, ce sont des objets des résidents du quartier qui sont tombés en panne qui attendent d'être réparés. Une fois réparé, tu mets l'objet dans ce tuyau et ça repart chez la bonne personne. + +22 +00:00:00,000 --> 00:00:14,760 +Ici, c'est un tableau de bord. T'imagines bien que si ton frigo ou ton four tombe en panne, tu ne vas pas pouvoir le mettre dans le tuyau haha ! Donc ici, ça te signale quand des résidents ont un objet volumineux tombé en panne, ou quand il y a un problème dans la ville. Oh oh... j'ai une urgence, il va bientôt falloir que je te laisse ! Donc tiens, tes outils pour pouvoir réparer la plupart des choses : une mini imprimante 3D à base de déchets électroniques, des gants Pousse Pièces pour désassembler les objets, ainsi qu'un pack de Relance ! + +23 +00:00:00,000 --> 00:00:54,000 +L'électricienne t'a aidé à la Centrale ? Aaaaah c'est ça que j'adore ici, tout le monde s'entraide, personne se juge, une vraie petite famille. Sache que l'électricienne a une histoire assez particulière. Elle est née au nord du continent, dans la ville de Kalska. Elle a grandit heureuse, avec sa mère Edith, son père Jordan et ses deux petits frères Malo et Justin. Il y a quelques années de ça, comme tu le sais, c'est les pays du Nord, qui par grande surprise, ont été obligés de migrer en premier. Ils ont alors entamé leur périple, pays par pays, ville par ville, village par village. Un jour de marche comme les autres depuis plusieurs mois, une tempête climatique les a pris de court. S'étant séparés pour trouver des vivres dans le village, le père et un des deux frères sont malheureusement partis. C'est tragique. Mais un beau jour, ils sont tombés ici, par hasard dans leur périple. On les a accueillis les bras ouverts et ils ont pu se reconstruire doucement parmi nous et font partie intégrante de la communauté aujourd'hui. diff --git a/src/components/editor/EditorCinematicManifestPanel.tsx b/src/components/editor/EditorCinematicManifestPanel.tsx new file mode 100644 index 0000000..c380cb1 --- /dev/null +++ b/src/components/editor/EditorCinematicManifestPanel.tsx @@ -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> & { + 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[] { + 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.`); + } + }); + + 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 { + 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(null); + const [dialogueManifest, setDialogueManifest] = + useState(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 { + 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 { + 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."); + } + + function updateDialogueCue( + cueIndex: number, + patch: Partial, + ): 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 ( +
+
+

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), + }) + } + /> +
+ ), + )} +
+ +
+
+ Dialogue cues + +
+ + {(selectedCinematic.dialogueCues ?? []).length === 0 ? ( +

Aucun dialogue synchronise avec cette cinematic.

+ ) : ( + (selectedCinematic.dialogueCues ?? []).map((cue, cueIndex) => ( +
+
+ Dialogue {cueIndex + 1} + +
+ + + + +
+ )) + )} +
+ + + + +
+ )} + +

{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 143d666..b5cf0ee 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -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) | 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`} + + + + ); diff --git a/src/components/editor/EditorDialogueManifestPanel.tsx b/src/components/editor/EditorDialogueManifestPanel.tsx new file mode 100644 index 0000000..5ea1915 --- /dev/null +++ b/src/components/editor/EditorDialogueManifestPanel.tsx @@ -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> & { + 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 { + 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 { + 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(); + + 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 { + 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(null); + const [manifest, setManifest] = useState(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 { + 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 { + 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 { + 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 { + 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 { + 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 ( +
+
+

Dialogues

+ {manifest?.dialogues.length ?? 0} items +
+ +
+ + + +
+ + {manifest && ( + + )} + + {selectedDialogue && ( +
+ + + + + + + + + + + + + + + +
+ )} + +

{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}
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/editor/EditorSrtPanel.tsx b/src/components/editor/EditorSrtPanel.tsx new file mode 100644 index 0000000..88c5f15 --- /dev/null +++ b/src/components/editor/EditorSrtPanel.tsx @@ -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(); + + 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 { + 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 { + const response = await fetch("/api/validate-dialogues"); + const body = (await response.json().catch(() => null)) as + | Partial + | { 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(null); + const [voice, setVoice] = useState("narrateur"); + const [language, setLanguage] = useState("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(null); + const [manifest, setManifest] = useState(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 { + 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 { + 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 ( +
+
+

SRT

+ {language.toUpperCase()} +
+ +
+ + + +
+ +
+ + + {selectedDialogue && ( +
+ Cue {selectedDialogue.subtitleCueIndex} + {selectedDialogue.id} +
+ )} +
+ +