v1
This commit is contained in:
20
.claude/settings.local.json
Normal file
20
.claude/settings.local.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npx electron-builder:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(npm start)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(\"/c/Users/pc-valentin/Documents/GitHub/multi_render_blender/dist/win-unpacked/Multi Render Blender.exe\")",
|
||||||
|
"Bash(taskkill:*)",
|
||||||
|
"Bash(powershell -Command \"Get-Process | Where-Object {$_Path -like ''*multi_render*'' -or $_Path -like ''*Multi Render*''} | Stop-Process -Force\")",
|
||||||
|
"Bash(powershell -Command:*)",
|
||||||
|
"Bash(powershell.exe -NoProfile -Command \"Get-Process -Name ''*electron*'',''*Multi*'',''*blender*'' -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 2; Remove-Item -Recurse -Force ''C:\\\\Users\\\\pc-valentin\\\\Documents\\\\GitHub\\\\multi_render_blender\\\\dist''; Write-Host ''Cleaned''\")",
|
||||||
|
"Bash(powershell.exe -NoProfile -Command \"Get-Process | Where-Object { $_.ProcessName -match ''electron|Blender|Multi'' } | Select-Object Id, ProcessName | Format-Table\")",
|
||||||
|
"Bash(tasklist:*)",
|
||||||
|
"Bash(\"/c/Users/pc-valentin/Documents/GitHub/multi_render_blender/dist2/win-unpacked/Multi Render Blender.exe\")",
|
||||||
|
"Bash(npm run build:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
config/saves/
|
||||||
|
blender/
|
||||||
|
*.log
|
||||||
293
CLAUDE.md
Normal file
293
CLAUDE.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# CLAUDE.md — Multi Render Blender
|
||||||
|
|
||||||
|
## Projet
|
||||||
|
|
||||||
|
Application **Electron** qui pilote **Blender en mode background** (`blender -b`) pour effectuer des rendus multi-cameras.
|
||||||
|
Chaque camera possede ses propres parametres (resolution, frames, format, dossier de sortie).
|
||||||
|
L'application construit une file de rendu (render queue) et execute les commandes Blender une par une,
|
||||||
|
avec affichage de l'image rendue apres chaque etape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Couche | Technologie |
|
||||||
|
|--------------|--------------------------------------|
|
||||||
|
| Application | **Electron** (main + renderer) |
|
||||||
|
| Backend | **Node.js** (`child_process.spawn`) |
|
||||||
|
| Frontend | **HTML / CSS / JS vanilla** |
|
||||||
|
| CSS | **Bootstrap 5** via CDN |
|
||||||
|
| Icones | **Material Design Icons (MDI)** CDN |
|
||||||
|
| Scripts | **Python** (executes par Blender) |
|
||||||
|
| Rendu | **Blender CLI** (`blender -b`) |
|
||||||
|
|
||||||
|
> Pas de React, pas de framework JS lourd. HTML/CSS/JS natif uniquement (conforme a la Norme).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture des fichiers
|
||||||
|
|
||||||
|
```
|
||||||
|
multi_render_blender/
|
||||||
|
|-- package.json
|
||||||
|
|-- main.js # Process principal Electron
|
||||||
|
|-- preload.js # Bridge IPC (contextBridge)
|
||||||
|
|-- src/
|
||||||
|
| |-- renderer/
|
||||||
|
| | |-- index.html # Page principale
|
||||||
|
| | |-- styles/
|
||||||
|
| | | |-- Main.css
|
||||||
|
| | |-- scripts/
|
||||||
|
| | |-- App.js # Point d'entree renderer
|
||||||
|
| | |-- CameraList.js # Gestion liste cameras
|
||||||
|
| | |-- CameraConfig.js # Formulaires config par camera
|
||||||
|
| | |-- RenderQueue.js # Affichage file de rendu
|
||||||
|
| | |-- PreviewPanel.js # Affichage image rendue
|
||||||
|
| | |-- ProgressBar.js # Barre de progression
|
||||||
|
| | |-- ConsoleLog.js # Console logs UI
|
||||||
|
| |-- main/
|
||||||
|
| | |-- BlenderProcess.js # Spawn et gestion process Blender
|
||||||
|
| | |-- QueueManager.js # File d'attente de rendu
|
||||||
|
| | |-- CameraParser.js # Extraction cameras depuis .blend
|
||||||
|
| | |-- ConfigManager.js # Sauvegarde/chargement config JSON
|
||||||
|
| | |-- PathResolver.js # Gestion chemins dynamiques
|
||||||
|
| |-- python/
|
||||||
|
| |-- list_cameras.py # Script : lister les cameras
|
||||||
|
| |-- setup_render.py # Script : configurer scene avant rendu
|
||||||
|
|-- config/
|
||||||
|
| |-- default_config.json # Config par defaut
|
||||||
|
|-- CLAUDE.md
|
||||||
|
|-- Norme.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conventions de nommage fichiers
|
||||||
|
- **Fichiers JS** : `PascalCase.js` (ex: `QueueManager.js`)
|
||||||
|
- **Fichiers Python** : `snake_case.py` (ex: `list_cameras.py`)
|
||||||
|
- **Fichiers CSS** : `PascalCase.css` (ex: `Main.css`)
|
||||||
|
- **Fichiers HTML** : `snake_case.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions de code (resume Norme.md)
|
||||||
|
|
||||||
|
### JavaScript / Node.js
|
||||||
|
|
||||||
|
| Regle | Detail |
|
||||||
|
|------------------------------|--------------------------------------------------|
|
||||||
|
| Variables | `snake_case` avec prefixes (`nb_`, `is_`, `list_`, `str_`, `obj_`, `has_`) |
|
||||||
|
| Classes | `PascalCase`, un seul mot si possible |
|
||||||
|
| Constantes globales | `MAJUSCULES` |
|
||||||
|
| Indentation | 4 espaces |
|
||||||
|
| Point-virgule | Obligatoire |
|
||||||
|
| Fonctions | Flechees preferees (`() => {}`) |
|
||||||
|
| `async/await` | **INTERDIT** — utiliser `.then()` / `.catch()` |
|
||||||
|
| `forEach()` | **INTERDIT** — utiliser `for...of` |
|
||||||
|
| Accolades | Meme ligne que l'instruction |
|
||||||
|
| Variables | Declarees en debut de fonction |
|
||||||
|
| Frameworks | Aucun (JS natif uniquement) |
|
||||||
|
|
||||||
|
### Python (scripts Blender)
|
||||||
|
|
||||||
|
| Regle | Detail |
|
||||||
|
|------------------------------|--------------------------------------------------|
|
||||||
|
| Variables | `snake_case` avec memes prefixes que JS |
|
||||||
|
| Type hints | **Obligatoires** sur toutes les signatures |
|
||||||
|
| Docstrings | **Obligatoires** en francais sur fonctions publiques |
|
||||||
|
| `print()` | Interdit en production, utiliser `logging` |
|
||||||
|
| Constantes globales | `MAJUSCULES` |
|
||||||
|
| Imports | Standard > Tiers > Projet |
|
||||||
|
| Erreurs | `try/except` doit loguer ET remonter |
|
||||||
|
|
||||||
|
### Gestion des erreurs
|
||||||
|
|
||||||
|
- Toute erreur doit etre **loguee** (console technique) ET **affichee a l'utilisateur** (UI).
|
||||||
|
- Aucune erreur ne doit etre ignoree silencieusement.
|
||||||
|
- En JS : toujours un `.catch()` sur chaque chaine de promesse.
|
||||||
|
- En Python : tout `try/except` doit loguer l'erreur ET la remonter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Electron (IPC)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Renderer Process] <--IPC--> [Main Process] <--spawn--> [Blender CLI]
|
||||||
|
(UI) preload (Node.js) (blender -b)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Communication IPC
|
||||||
|
|
||||||
|
Le `preload.js` expose une API via `contextBridge` :
|
||||||
|
|
||||||
|
```js
|
||||||
|
// preload.js — API exposee au renderer
|
||||||
|
contextBridge.exposeInMainWorld("api", {
|
||||||
|
select_blend_file: () => ipcRenderer.invoke("select-blend-file"),
|
||||||
|
get_cameras: (str_path) => ipcRenderer.invoke("get-cameras", str_path),
|
||||||
|
start_render: (obj_config) => ipcRenderer.invoke("start-render", obj_config),
|
||||||
|
pause_render: () => ipcRenderer.invoke("pause-render"),
|
||||||
|
stop_render: () => ipcRenderer.invoke("stop-render"),
|
||||||
|
on_render_progress: (fn_callback) => ipcRenderer.on("render-progress", fn_callback),
|
||||||
|
on_render_complete: (fn_callback) => ipcRenderer.on("render-complete", fn_callback),
|
||||||
|
on_render_error: (fn_callback) => ipcRenderer.on("render-error", fn_callback),
|
||||||
|
on_preview_update: (fn_callback) => ipcRenderer.on("preview-update", fn_callback),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> Toute communication entre renderer et main passe par IPC. Jamais d'acces direct a `child_process` ou `fs` depuis le renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnalites principales
|
||||||
|
|
||||||
|
### 1. Chargement fichier .blend
|
||||||
|
- Selection via dialog natif Electron (`dialog.showOpenDialog`)
|
||||||
|
- Extraction des cameras via :
|
||||||
|
```
|
||||||
|
blender -b fichier.blend --python-expr "import bpy; print([o.name for o in bpy.data.objects if o.type=='CAMERA'])"
|
||||||
|
```
|
||||||
|
- Parsing du stdout pour recuperer la liste
|
||||||
|
|
||||||
|
### 2. Configuration par camera
|
||||||
|
Chaque camera possede :
|
||||||
|
- `is_enabled` : activer/desactiver
|
||||||
|
- `nb_resolution_x` : resolution horizontale
|
||||||
|
- `nb_resolution_y` : resolution verticale
|
||||||
|
- `nb_frame_start` : premiere frame
|
||||||
|
- `nb_frame_end` : derniere frame
|
||||||
|
- `str_format` : format de sortie (PNG, JPEG, EXR)
|
||||||
|
- `str_output_path` : dossier de sortie
|
||||||
|
|
||||||
|
### 3. Modes de rendu
|
||||||
|
- **Mode 1 — Camera par camera** : toutes les frames d'une camera avant de passer a la suivante
|
||||||
|
- **Mode 2 — Frame par frame** : toutes les cameras pour une frame avant de passer a la suivante
|
||||||
|
|
||||||
|
### 4. Commande Blender generee
|
||||||
|
```bash
|
||||||
|
blender -b fichier.blend \
|
||||||
|
--python-expr "
|
||||||
|
import bpy;
|
||||||
|
scene=bpy.context.scene;
|
||||||
|
scene.camera=bpy.data.objects['NomCamera'];
|
||||||
|
scene.render.resolution_x=1920;
|
||||||
|
scene.render.resolution_y=1080
|
||||||
|
" \
|
||||||
|
-o ./renders/NomCamera/frame_##### \
|
||||||
|
-F PNG \
|
||||||
|
-f 120
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. File d'attente (Render Queue)
|
||||||
|
- Construction d'une queue interne (tableau d'objets commande)
|
||||||
|
- Execution sequentielle : attendre la fin du process avant le suivant
|
||||||
|
- Controles : **Start** / **Pause** / **Stop**
|
||||||
|
|
||||||
|
### 6. Preview de l'image rendue
|
||||||
|
Workflow apres chaque rendu :
|
||||||
|
```
|
||||||
|
Render termine -> Process exit -> Lire image -> Afficher preview -> Lancer suivant
|
||||||
|
```
|
||||||
|
- Detection du fichier genere via le chemin de sortie
|
||||||
|
- Affichage dans un panneau preview (balise `<img>` avec rechargement)
|
||||||
|
|
||||||
|
### 7. Indicateurs UI
|
||||||
|
- Camera en cours
|
||||||
|
- Frame en cours
|
||||||
|
- Progression (X / total)
|
||||||
|
- Image preview
|
||||||
|
- Console logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modeles de donnees
|
||||||
|
|
||||||
|
### Configuration camera
|
||||||
|
```js
|
||||||
|
let obj_camera_config = {
|
||||||
|
str_name: "Camera.001",
|
||||||
|
is_enabled: true,
|
||||||
|
nb_resolution_x: 1920,
|
||||||
|
nb_resolution_y: 1080,
|
||||||
|
nb_frame_start: 1,
|
||||||
|
nb_frame_end: 250,
|
||||||
|
str_format: "PNG",
|
||||||
|
str_output_path: "./renders/Camera.001/"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Element de la queue
|
||||||
|
```js
|
||||||
|
let obj_queue_item = {
|
||||||
|
str_camera_name: "Camera.001",
|
||||||
|
nb_frame: 120,
|
||||||
|
nb_resolution_x: 1920,
|
||||||
|
nb_resolution_y: 1080,
|
||||||
|
str_format: "PNG",
|
||||||
|
str_output_path: "./renders/Camera.001/frame_00120.png",
|
||||||
|
str_status: "pending" // "pending" | "rendering" | "done" | "error" | "skipped"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration globale
|
||||||
|
```js
|
||||||
|
let obj_render_config = {
|
||||||
|
str_blend_file: "/chemin/vers/fichier.blend",
|
||||||
|
str_render_mode: "camera_by_camera", // "camera_by_camera" | "frame_by_frame"
|
||||||
|
list_cameras: [ /* obj_camera_config */ ]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regles pour l'IA
|
||||||
|
|
||||||
|
1. **Respecter strictement la Norme.md** — c'est la reference absolue pour le style de code.
|
||||||
|
2. **Jamais de `async/await`** — utiliser `.then()` / `.catch()` avec retour de promesse.
|
||||||
|
3. **Jamais de `forEach()`** — utiliser `for...of`.
|
||||||
|
4. **Nommage strict** — `snake_case` avec prefixes, `PascalCase` pour classes et fichiers JS.
|
||||||
|
5. **Separation IPC** — le renderer ne touche jamais directement Node.js APIs.
|
||||||
|
6. **Une fonction = une responsabilite** — extraire la logique repetee.
|
||||||
|
7. **Gestion d'erreurs systematique** — `.catch()` sur chaque promesse, message utilisateur visible.
|
||||||
|
8. **Pas de dependance externe inutile** — seulement Bootstrap 5 et MDI en CDN.
|
||||||
|
9. **Python : type hints + docstrings** obligatoires sur les scripts Blender.
|
||||||
|
10. **Pas de `print()` en Python** — utiliser `logging` (sauf stdout pour communication avec Node.js).
|
||||||
|
11. **Tester chaque commande Blender** avant de la considerer fonctionnelle.
|
||||||
|
12. **Commenter rarement** — le code doit etre autoportant via le nommage.
|
||||||
|
13. **4 espaces d'indentation**, point-virgule obligatoire en JS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonus (a implementer progressivement)
|
||||||
|
|
||||||
|
- [ ] Skip si image deja existante
|
||||||
|
- [ ] Resume apres crash
|
||||||
|
- [ ] Sauvegarde / chargement config JSON
|
||||||
|
- [ ] Drag & drop du fichier .blend
|
||||||
|
- [ ] Multi-thread (plusieurs Blender en parallele)
|
||||||
|
- [ ] Detection automatique du chemin Blender installe
|
||||||
|
- [ ] Estimation du temps restant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer l'app en dev
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Lister les cameras d'un .blend
|
||||||
|
blender -b fichier.blend --python-expr "import bpy; print([o.name for o in bpy.data.objects if o.type=='CAMERA'])"
|
||||||
|
|
||||||
|
# Render une frame specifique
|
||||||
|
blender -b fichier.blend --python-expr "import bpy; scene=bpy.context.scene; scene.camera=bpy.data.objects['Camera']" -o //output_##### -F PNG -f 1
|
||||||
|
|
||||||
|
# Packager l'app
|
||||||
|
npx electron-builder
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- Un commit = une fonctionnalite complete.
|
||||||
|
- Aucun commit casse ou incomplet.
|
||||||
|
- Messages de commit clairs et descriptifs.
|
||||||
325
Norme.md
Normal file
325
Norme.md
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
# 🧭 Norme de Développement
|
||||||
|
|
||||||
|
*Version 2.0 – Pour IA et nouveaux collaborateurs*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Philosophie de développement
|
||||||
|
|
||||||
|
Le but de cette norme est de garantir un code **clair, cohérent et maintenable**, que ce soit par un développeur humain ou une IA d’assistance.
|
||||||
|
Chaque ligne doit être **compréhensible au premier regard**, chaque fonction doit **faire une seule chose**, et la structure du projet doit **rester intuitive** sans dépendre de frameworks lourds ou de bibliothèques inutiles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Structure générale
|
||||||
|
|
||||||
|
* Le code doit rester **simple, explicite et lisible**.
|
||||||
|
* On privilégie la **cohérence** à la créativité syntaxique.
|
||||||
|
* Le code doit être **autoportant** : bien nommé plutôt que sur-commenté.
|
||||||
|
* Le style doit être uniforme entre tous les fichiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐍 Nommage
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
* Toujours en **snake_case** : `ma_variable`, `list_users`, `is_valid`.
|
||||||
|
* Pas de chiffres dans les noms.
|
||||||
|
* Le nom doit **refléter le rôle et le type** de la donnée.
|
||||||
|
* Préfixes recommandés :
|
||||||
|
|
||||||
|
* `nb_` → quantité (ex: `nb_utilisateurs`)
|
||||||
|
* `is_` / `has_` → booléen (ex: `is_active`, `has_error`)
|
||||||
|
* `list_` → tableau ou collection
|
||||||
|
* `str_` → chaîne de caractères
|
||||||
|
* `obj_` → objet ou structure complexe
|
||||||
|
|
||||||
|
✅ **Correct :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
let nb_chats = 5;
|
||||||
|
let list_chats = [];
|
||||||
|
let is_chat = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **Incorrect :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
let 1chien = 1;
|
||||||
|
let mechantChien = {};
|
||||||
|
let IS_CHIEN = false;
|
||||||
|
let nb_chien = new Chien();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Classes / Structures
|
||||||
|
|
||||||
|
* Une majuscule au début (`User`, `Project`).
|
||||||
|
* Un seul mot.
|
||||||
|
* Si plusieurs concepts sont nécessaires, utiliser une **composition**, pas un nom long.
|
||||||
|
|
||||||
|
✅ **Correct :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
class User {}
|
||||||
|
class Project {}
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **Incorrect :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
class user {}
|
||||||
|
class ProjectNuageDePoints {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Structure du code
|
||||||
|
|
||||||
|
### Déclaration des variables
|
||||||
|
|
||||||
|
Toutes les variables doivent être **déclarées en début de fonction**, avant toute action logique.
|
||||||
|
|
||||||
|
✅ **Correct :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function main() {
|
||||||
|
let compteur = 0;
|
||||||
|
let message = "Hello";
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **Incorrect :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function main() {
|
||||||
|
console.log("test");
|
||||||
|
let compteur = 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Typage implicite et explicite
|
||||||
|
|
||||||
|
* Si le type est évident, inutile de le préciser.
|
||||||
|
* Sinon, indiquer le type clairement.
|
||||||
|
|
||||||
|
✅ **Correct :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
let str_nom = "Jean";
|
||||||
|
let list_ids = [];
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **Incorrect :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
let x;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Placement des crochets
|
||||||
|
|
||||||
|
Le premier `{` doit toujours être **sur la même ligne** que l’instruction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Promesses et gestion asynchrone (JavaScript)
|
||||||
|
|
||||||
|
### Interdiction de `async/await`
|
||||||
|
|
||||||
|
* `async` / `await` sont **strictement interdits**.
|
||||||
|
* Toujours utiliser des chaînes `.then()` / `.catch()`.
|
||||||
|
|
||||||
|
✅ **Correct :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
action_async()
|
||||||
|
.then((data) => {
|
||||||
|
return sous_action_async(data);
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
utiliser_resultat(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
afficher_message_utilisateur("Une erreur est survenue.");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **Incorrect :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function main() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Bonnes pratiques générales
|
||||||
|
|
||||||
|
* Une **fonction = une responsabilité unique**.
|
||||||
|
* Extraire toute logique répétée dans une fonction utilitaire.
|
||||||
|
* Les noms de fonctions doivent décrire précisément leur action.
|
||||||
|
* Pas de code commenté laissé dans le projet.
|
||||||
|
* Les fonctions doivent tenir **dans la hauteur visible d’un écran**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Style de code
|
||||||
|
|
||||||
|
* **Indentation :** 4 espaces.
|
||||||
|
* **Point-virgule :** obligatoire.
|
||||||
|
* **Fonctions fléchées** toujours préférées.
|
||||||
|
* **Constantes globales** en MAJUSCULES.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Organisation des fichiers
|
||||||
|
|
||||||
|
* Fichiers JS en **PascalCase**.
|
||||||
|
* Fichiers Python en **snake_case**.
|
||||||
|
* Pas de framework structurant lourd.
|
||||||
|
* HTML / CSS / JS natif privilégié.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐍 Règles spécifiques Python
|
||||||
|
|
||||||
|
* **Type hints** obligatoires sur toutes les signatures de fonctions.
|
||||||
|
* **Docstrings** en français sur toutes les fonctions publiques.
|
||||||
|
* Imports regroupés en haut du fichier, dans l'ordre :
|
||||||
|
1. Bibliothèque standard
|
||||||
|
2. Bibliothèques tierces
|
||||||
|
3. Modules du projet
|
||||||
|
* Gestion d'erreurs : tout `try/except` doit loguer l'erreur ET la remonter.
|
||||||
|
* Pas de `print()` en production, utiliser le module `logging`.
|
||||||
|
* Les constantes globales sont en **MAJUSCULES**.
|
||||||
|
* Les variables suivent les mêmes règles de préfixage que JavaScript
|
||||||
|
(`nb_`, `is_`, `has_`, `list_`, `str_`, `obj_`).
|
||||||
|
|
||||||
|
✅ **Correct :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
nb_chats = 5
|
||||||
|
list_chats = []
|
||||||
|
is_chat = True
|
||||||
|
str_nom = "Jean"
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **Incorrect :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
chats = 5
|
||||||
|
mechantChien = {}
|
||||||
|
IS_CHIEN = False
|
||||||
|
nb_chien = Chien()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Commentaires et documentation
|
||||||
|
|
||||||
|
* Commentaires rares et utiles uniquement.
|
||||||
|
* Pas de JSDoc obligatoire.
|
||||||
|
* Commentaires en anglais simple si nécessaires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Gestion des erreurs
|
||||||
|
|
||||||
|
* Toujours afficher une **erreur utilisateur visible**.
|
||||||
|
* Les erreurs techniques doivent être **loguées**.
|
||||||
|
* Aucune erreur ne doit être ignorée silencieusement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Git et gestion de versions
|
||||||
|
|
||||||
|
* Un commit = une fonctionnalité complète.
|
||||||
|
* Aucun commit cassé ou incomplet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Outils et dépendances
|
||||||
|
|
||||||
|
* Dépendances externes **fortement limitées**.
|
||||||
|
* Aucun framework JS lourd.
|
||||||
|
* Backend en **Python** avec **Flask** (léger, sans ORM).
|
||||||
|
* **Exceptions autorisées pour le frontend** :
|
||||||
|
* **Bootstrap 5** (CSS + JS) via CDN pour la mise en page et les composants UI.
|
||||||
|
* **Material Design Icons (MDI)** via CDN pour les icônes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Règles spécifiques pour l’assistant IA
|
||||||
|
|
||||||
|
L’IA d’assistance au développement doit :
|
||||||
|
|
||||||
|
1. **Ne jamais utiliser `async/await`.**
|
||||||
|
Toujours préférer `.then()` / `.catch()` avec retour de promesse.
|
||||||
|
|
||||||
|
2. **Ne jamais utiliser `forEach()`.**
|
||||||
|
|
||||||
|
* L’itération sur des tableaux ou collections doit obligatoirement utiliser des boucles `for...of`.
|
||||||
|
* Cette règle garantit :
|
||||||
|
|
||||||
|
* un meilleur contrôle du flux (`break`, `continue`, `return`),
|
||||||
|
* une lisibilité accrue,
|
||||||
|
* une cohérence avec les règles de débogage et de responsabilité.
|
||||||
|
|
||||||
|
✅ **Correct :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
for (let user of list_users) {
|
||||||
|
traiter_utilisateur(user);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **Incorrect :**
|
||||||
|
|
||||||
|
```js
|
||||||
|
list_users.forEach((user) => {
|
||||||
|
traiter_utilisateur(user);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reformater automatiquement** le code selon cette norme.
|
||||||
|
|
||||||
|
4. **Vérifier la lisibilité et la cohérence** avant toute proposition.
|
||||||
|
|
||||||
|
5. **Proposer des refactorings** en cas de duplication.
|
||||||
|
|
||||||
|
6. **Créer des fonctions utilitaires** si une logique se répète.
|
||||||
|
|
||||||
|
7. **Respecter strictement le nommage** (`snake_case`, `PascalCase`).
|
||||||
|
|
||||||
|
8. **Toujours gérer les erreurs** avec un `.catch()` visible utilisateur.
|
||||||
|
|
||||||
|
9. **Ne pas utiliser de dépendances externes** sans valeur fonctionnelle claire.
|
||||||
|
|
||||||
|
10. **En Python, toujours utiliser des type hints** sur les paramètres et retours de fonctions.
|
||||||
|
|
||||||
|
11. **En Python, toujours inclure une docstring** sur les fonctions publiques.
|
||||||
|
|
||||||
|
12. **Ne jamais utiliser `print()` pour le logging** ; utiliser le module `logging`.
|
||||||
|
|
||||||
|
13. **Appliquer les mêmes préfixes de variables** en Python qu'en JavaScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📘 Conclusion
|
||||||
|
|
||||||
|
Ce document est une **référence de cohérence globale** pour le projet.
|
||||||
|
Il s’applique **aux humains comme à l’IA**.
|
||||||
|
|
||||||
|
Le code doit rester :
|
||||||
|
|
||||||
|
* **Lisible**
|
||||||
|
* **Prévisible**
|
||||||
|
* **Maintenable**
|
||||||
|
* **Humain**
|
||||||
167
main.js
Normal file
167
main.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const CameraParser = require("./src/main/CameraParser.js");
|
||||||
|
const QueueManager = require("./src/main/QueueManager.js");
|
||||||
|
const ConfigManager = require("./src/main/ConfigManager.js");
|
||||||
|
|
||||||
|
let obj_main_window = null;
|
||||||
|
let obj_queue_manager = null;
|
||||||
|
|
||||||
|
const create_window = () => {
|
||||||
|
obj_main_window = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1000,
|
||||||
|
minHeight: 700,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
title: "Multi Render Blender",
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_main_window.loadFile(path.join(__dirname, "src", "renderer", "index.html"));
|
||||||
|
|
||||||
|
obj_queue_manager = new QueueManager(obj_main_window);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── App lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
create_window();
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
create_window();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── IPC Handlers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
ipcMain.handle("select-blend-file", () => {
|
||||||
|
return dialog.showOpenDialog(obj_main_window, {
|
||||||
|
title: "Selectionner un fichier Blender",
|
||||||
|
filters: [
|
||||||
|
{ name: "Blender Files", extensions: ["blend"] },
|
||||||
|
],
|
||||||
|
properties: ["openFile"],
|
||||||
|
})
|
||||||
|
.then((obj_result) => {
|
||||||
|
if (obj_result.canceled || obj_result.filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return obj_result.filePaths[0];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-cameras", (event, str_blend_path) => {
|
||||||
|
return CameraParser.list_cameras(str_blend_path);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("start-render", (event, obj_config) => {
|
||||||
|
return obj_queue_manager.start(obj_config);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("pause-render", () => {
|
||||||
|
return obj_queue_manager.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("stop-render", () => {
|
||||||
|
return obj_queue_manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("save-config", (event, obj_config) => {
|
||||||
|
return ConfigManager.save(obj_main_window, obj_config);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("load-config", () => {
|
||||||
|
return ConfigManager.load(obj_main_window);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("read-image", (event, str_image_path) => {
|
||||||
|
let NB_MAX_RETRIES = 10;
|
||||||
|
let NB_RETRY_DELAY = 500;
|
||||||
|
|
||||||
|
let fn_try_read = (nb_attempt) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!fs.existsSync(str_image_path)) {
|
||||||
|
if (nb_attempt < NB_MAX_RETRIES) {
|
||||||
|
setTimeout(() => {
|
||||||
|
fn_try_read(nb_attempt + 1).then(resolve).catch(reject);
|
||||||
|
}, NB_RETRY_DELAY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error("Fichier introuvable apres " + NB_MAX_RETRIES + " tentatives : " + str_image_path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for file size to stabilize (file fully written)
|
||||||
|
let nb_size_prev = -1;
|
||||||
|
let fn_check_stable = () => {
|
||||||
|
let obj_stats = fs.statSync(str_image_path);
|
||||||
|
if (obj_stats.size === 0 || obj_stats.size !== nb_size_prev) {
|
||||||
|
nb_size_prev = obj_stats.size;
|
||||||
|
if (nb_attempt < NB_MAX_RETRIES) {
|
||||||
|
setTimeout(() => {
|
||||||
|
nb_attempt++;
|
||||||
|
fn_check_stable();
|
||||||
|
}, NB_RETRY_DELAY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (obj_stats.size === 0) {
|
||||||
|
reject(new Error("Le fichier est vide apres " + NB_MAX_RETRIES + " tentatives : " + str_image_path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.readFile(str_image_path, (obj_err, obj_buffer) => {
|
||||||
|
if (obj_err) {
|
||||||
|
reject(new Error("Impossible de lire l'image : " + obj_err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_ext = path.extname(str_image_path).toLowerCase().replace(".", "");
|
||||||
|
let str_mime = "image/png";
|
||||||
|
if (str_ext === "jpg" || str_ext === "jpeg") {
|
||||||
|
str_mime = "image/jpeg";
|
||||||
|
} else if (str_ext === "bmp") {
|
||||||
|
str_mime = "image/bmp";
|
||||||
|
} else if (str_ext === "tiff" || str_ext === "tif") {
|
||||||
|
str_mime = "image/tiff";
|
||||||
|
} else if (str_ext === "exr") {
|
||||||
|
str_mime = "image/x-exr";
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_base64 = obj_buffer.toString("base64");
|
||||||
|
resolve("data:" + str_mime + ";base64," + str_base64);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fn_check_stable();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return fn_try_read(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("select-output-folder", () => {
|
||||||
|
return dialog.showOpenDialog(obj_main_window, {
|
||||||
|
title: "Selectionner le dossier de sortie",
|
||||||
|
properties: ["openDirectory"],
|
||||||
|
})
|
||||||
|
.then((obj_result) => {
|
||||||
|
if (obj_result.canceled || obj_result.filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return obj_result.filePaths[0];
|
||||||
|
});
|
||||||
|
});
|
||||||
5248
package-lock.json
generated
Normal file
5248
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "multi-render-blender",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Application Electron pour piloter des rendus Blender multi-cameras",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"build": "electron-builder"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^34.0.0",
|
||||||
|
"electron-builder": "^25.1.8"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.multirender.blender",
|
||||||
|
"productName": "Multi Render Blender",
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "blender",
|
||||||
|
"to": "blender",
|
||||||
|
"filter": ["**/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"linux": {
|
||||||
|
"target": "dir"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "dir",
|
||||||
|
"signAndEditExecutable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
preload.js
Normal file
69
preload.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("api", {
|
||||||
|
select_blend_file: () => {
|
||||||
|
return ipcRenderer.invoke("select-blend-file");
|
||||||
|
},
|
||||||
|
|
||||||
|
get_cameras: (str_path) => {
|
||||||
|
return ipcRenderer.invoke("get-cameras", str_path);
|
||||||
|
},
|
||||||
|
|
||||||
|
start_render: (obj_config) => {
|
||||||
|
return ipcRenderer.invoke("start-render", obj_config);
|
||||||
|
},
|
||||||
|
|
||||||
|
pause_render: () => {
|
||||||
|
return ipcRenderer.invoke("pause-render");
|
||||||
|
},
|
||||||
|
|
||||||
|
stop_render: () => {
|
||||||
|
return ipcRenderer.invoke("stop-render");
|
||||||
|
},
|
||||||
|
|
||||||
|
save_config: (obj_config) => {
|
||||||
|
return ipcRenderer.invoke("save-config", obj_config);
|
||||||
|
},
|
||||||
|
|
||||||
|
load_config: () => {
|
||||||
|
return ipcRenderer.invoke("load-config");
|
||||||
|
},
|
||||||
|
|
||||||
|
select_output_folder: () => {
|
||||||
|
return ipcRenderer.invoke("select-output-folder");
|
||||||
|
},
|
||||||
|
|
||||||
|
read_image: (str_path) => {
|
||||||
|
return ipcRenderer.invoke("read-image", str_path);
|
||||||
|
},
|
||||||
|
|
||||||
|
on_render_progress: (fn_callback) => {
|
||||||
|
ipcRenderer.on("render-progress", (event, obj_data) => {
|
||||||
|
fn_callback(obj_data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
on_render_complete: (fn_callback) => {
|
||||||
|
ipcRenderer.on("render-complete", (event, obj_data) => {
|
||||||
|
fn_callback(obj_data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
on_render_error: (fn_callback) => {
|
||||||
|
ipcRenderer.on("render-error", (event, obj_data) => {
|
||||||
|
fn_callback(obj_data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
on_preview_update: (fn_callback) => {
|
||||||
|
ipcRenderer.on("preview-update", (event, str_image_path) => {
|
||||||
|
fn_callback(str_image_path);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
on_log: (fn_callback) => {
|
||||||
|
ipcRenderer.on("log", (event, str_message) => {
|
||||||
|
fn_callback(str_message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
103
src/main/BlenderProcess.js
Normal file
103
src/main/BlenderProcess.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const { spawn } = require("child_process");
|
||||||
|
const PathResolver = require("./PathResolver.js");
|
||||||
|
|
||||||
|
const BlenderProcess = {
|
||||||
|
render_frame: (obj_params) => {
|
||||||
|
let str_blend_path = obj_params.str_blend_path;
|
||||||
|
let str_camera_name = obj_params.str_camera_name;
|
||||||
|
let nb_frame = obj_params.nb_frame;
|
||||||
|
let nb_resolution_x = obj_params.nb_resolution_x;
|
||||||
|
let nb_resolution_y = obj_params.nb_resolution_y;
|
||||||
|
let str_format = obj_params.str_format;
|
||||||
|
let str_output_path = obj_params.str_output_path;
|
||||||
|
|
||||||
|
let str_safe_name = str_camera_name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||||
|
|
||||||
|
let str_python_expr = [
|
||||||
|
"import bpy",
|
||||||
|
"scene=bpy.context.scene",
|
||||||
|
"scene.camera=bpy.data.objects['" + str_safe_name + "']",
|
||||||
|
"scene.render.resolution_x=" + nb_resolution_x,
|
||||||
|
"scene.render.resolution_y=" + nb_resolution_y,
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
let list_args = [
|
||||||
|
"-b", str_blend_path,
|
||||||
|
"--python-expr", str_python_expr,
|
||||||
|
"-o", str_output_path,
|
||||||
|
"-F", str_format,
|
||||||
|
"-f", String(nb_frame),
|
||||||
|
];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let str_stdout = "";
|
||||||
|
let str_stderr = "";
|
||||||
|
|
||||||
|
let obj_process = spawn(PathResolver.get_blender_path(), list_args);
|
||||||
|
|
||||||
|
obj_process.stdout.on("data", (obj_data) => {
|
||||||
|
str_stdout += obj_data.toString();
|
||||||
|
if (obj_params.fn_on_stdout) {
|
||||||
|
obj_params.fn_on_stdout(obj_data.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_process.stderr.on("data", (obj_data) => {
|
||||||
|
str_stderr += obj_data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_process.on("close", (nb_code) => {
|
||||||
|
if (nb_code !== 0) {
|
||||||
|
reject({
|
||||||
|
str_message: "Blender a quitte avec le code " + nb_code,
|
||||||
|
str_stderr: str_stderr,
|
||||||
|
str_stdout: str_stdout,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_rendered_file = BlenderProcess._find_rendered_file(str_stdout);
|
||||||
|
resolve({
|
||||||
|
str_rendered_file: str_rendered_file,
|
||||||
|
str_stdout: str_stdout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_process.on("error", (obj_err) => {
|
||||||
|
reject({
|
||||||
|
str_message: "Impossible de lancer Blender : " + obj_err.message,
|
||||||
|
str_stderr: "",
|
||||||
|
str_stdout: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store process reference for kill support
|
||||||
|
if (obj_params.fn_on_process) {
|
||||||
|
obj_params.fn_on_process(obj_process);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_find_rendered_file: (str_stdout) => {
|
||||||
|
let list_lines = str_stdout.split("\n");
|
||||||
|
for (let str_line of list_lines) {
|
||||||
|
let nb_index = str_line.indexOf("Saved: ");
|
||||||
|
if (nb_index !== -1) {
|
||||||
|
let str_path = str_line.substring(nb_index + 7).trim();
|
||||||
|
// Remove trailing info like " Time: 00:01.23"
|
||||||
|
let nb_time_index = str_path.indexOf(" Time:");
|
||||||
|
if (nb_time_index !== -1) {
|
||||||
|
str_path = str_path.substring(0, nb_time_index).trim();
|
||||||
|
}
|
||||||
|
// Blender 5.x wraps path in single quotes: Saved: '/path/file.png'
|
||||||
|
if (str_path.startsWith("'") && str_path.endsWith("'")) {
|
||||||
|
str_path = str_path.substring(1, str_path.length - 1);
|
||||||
|
}
|
||||||
|
return str_path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = BlenderProcess;
|
||||||
72
src/main/CameraParser.js
Normal file
72
src/main/CameraParser.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
const { spawn } = require("child_process");
|
||||||
|
const PathResolver = require("./PathResolver.js");
|
||||||
|
|
||||||
|
const STR_CAMERA_MARKER = "CAMERAS_JSON:";
|
||||||
|
|
||||||
|
const STR_PYTHON_EXPR = [
|
||||||
|
"import bpy, json, sys;",
|
||||||
|
"cams=[o.name for o in bpy.data.objects if o.type=='CAMERA'];",
|
||||||
|
"sys.stdout.write('CAMERAS_JSON:' + json.dumps(cams) + '\\n');",
|
||||||
|
"sys.stdout.flush()",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const CameraParser = {
|
||||||
|
list_cameras: (str_blend_path) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let str_stdout = "";
|
||||||
|
let str_stderr = "";
|
||||||
|
|
||||||
|
let obj_process = spawn(PathResolver.get_blender_path(), [
|
||||||
|
"-b", str_blend_path,
|
||||||
|
"--python-expr", STR_PYTHON_EXPR,
|
||||||
|
]);
|
||||||
|
|
||||||
|
obj_process.stdout.on("data", (obj_data) => {
|
||||||
|
str_stdout += obj_data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_process.stderr.on("data", (obj_data) => {
|
||||||
|
str_stderr += obj_data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_process.on("close", (nb_code) => {
|
||||||
|
if (nb_code !== 0) {
|
||||||
|
reject(new Error("Blender a quitte avec le code " + nb_code + " : " + str_stderr));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let list_cameras = CameraParser._parse_camera_output(str_stdout);
|
||||||
|
if (list_cameras === null) {
|
||||||
|
console.error("[CameraParser] Stdout Blender:\n" + str_stdout.substring(str_stdout.length - 2000));
|
||||||
|
reject(new Error("Impossible de parser les cameras. Verifiez que le fichier .blend contient des cameras."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(list_cameras);
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_process.on("error", (obj_err) => {
|
||||||
|
reject(new Error("Impossible de lancer Blender. Verifiez qu'il est installe et accessible dans le PATH. " + obj_err.message));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_parse_camera_output: (str_stdout) => {
|
||||||
|
let list_lines = str_stdout.split("\n");
|
||||||
|
for (let str_line of list_lines) {
|
||||||
|
let nb_index = str_line.indexOf(STR_CAMERA_MARKER);
|
||||||
|
if (nb_index !== -1) {
|
||||||
|
let str_json = str_line.substring(nb_index + STR_CAMERA_MARKER.length).trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(str_json);
|
||||||
|
} catch (obj_err) {
|
||||||
|
console.error("[CameraParser] JSON parse error:", obj_err.message, "raw:", str_json);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = CameraParser;
|
||||||
66
src/main/ConfigManager.js
Normal file
66
src/main/ConfigManager.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const { dialog } = require("electron");
|
||||||
|
|
||||||
|
const ConfigManager = {
|
||||||
|
save: (obj_window, obj_config) => {
|
||||||
|
return dialog.showSaveDialog(obj_window, {
|
||||||
|
title: "Sauvegarder la configuration",
|
||||||
|
defaultPath: "render_config.json",
|
||||||
|
filters: [
|
||||||
|
{ name: "Configuration JSON", extensions: ["json"] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.then((obj_result) => {
|
||||||
|
if (obj_result.canceled || !obj_result.filePath) {
|
||||||
|
return { is_success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_json = JSON.stringify(obj_config, null, 4);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fs.writeFile(obj_result.filePath, str_json, "utf8", (obj_err) => {
|
||||||
|
if (obj_err) {
|
||||||
|
reject(new Error("Impossible de sauvegarder : " + obj_err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ is_success: true, str_path: obj_result.filePath });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
load: (obj_window) => {
|
||||||
|
return dialog.showOpenDialog(obj_window, {
|
||||||
|
title: "Charger une configuration",
|
||||||
|
filters: [
|
||||||
|
{ name: "Configuration JSON", extensions: ["json"] },
|
||||||
|
],
|
||||||
|
properties: ["openFile"],
|
||||||
|
})
|
||||||
|
.then((obj_result) => {
|
||||||
|
if (obj_result.canceled || obj_result.filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_file_path = obj_result.filePaths[0];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fs.readFile(str_file_path, "utf8", (obj_err, str_data) => {
|
||||||
|
if (obj_err) {
|
||||||
|
reject(new Error("Impossible de lire : " + obj_err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let obj_config = JSON.parse(str_data);
|
||||||
|
resolve(obj_config);
|
||||||
|
} catch (obj_parse_err) {
|
||||||
|
reject(new Error("Fichier corrompu : " + obj_parse_err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ConfigManager;
|
||||||
52
src/main/PathResolver.js
Normal file
52
src/main/PathResolver.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const STR_EXE_NAME = process.platform === "win32" ? "blender.exe" : "blender";
|
||||||
|
|
||||||
|
const PathResolver = {
|
||||||
|
_str_blender_path: null,
|
||||||
|
|
||||||
|
get_blender_path: () => {
|
||||||
|
if (PathResolver._str_blender_path) {
|
||||||
|
return PathResolver._str_blender_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode package : resources/blender/
|
||||||
|
let str_resources_dir = path.join(process.resourcesPath, "blender");
|
||||||
|
let str_found = PathResolver._find_in_dir(str_resources_dir);
|
||||||
|
if (str_found) {
|
||||||
|
PathResolver._str_blender_path = str_found;
|
||||||
|
return str_found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode dev : racine projet/blender/
|
||||||
|
let str_dev_dir = path.join(__dirname, "..", "..", "blender");
|
||||||
|
str_found = PathResolver._find_in_dir(str_dev_dir);
|
||||||
|
if (str_found) {
|
||||||
|
PathResolver._str_blender_path = str_found;
|
||||||
|
return str_found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback : PATH systeme
|
||||||
|
PathResolver._str_blender_path = "blender";
|
||||||
|
return "blender";
|
||||||
|
},
|
||||||
|
|
||||||
|
_find_in_dir: (str_dir) => {
|
||||||
|
if (!fs.existsSync(str_dir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let list_entries = fs.readdirSync(str_dir);
|
||||||
|
for (let str_entry of list_entries) {
|
||||||
|
let str_exe = path.join(str_dir, str_entry, STR_EXE_NAME);
|
||||||
|
if (fs.existsSync(str_exe)) {
|
||||||
|
return str_exe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PathResolver;
|
||||||
300
src/main/QueueManager.js
Normal file
300
src/main/QueueManager.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
const BlenderProcess = require("./BlenderProcess.js");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const STR_STATUS_IDLE = "idle";
|
||||||
|
const STR_STATUS_RUNNING = "running";
|
||||||
|
const STR_STATUS_PAUSED = "paused";
|
||||||
|
|
||||||
|
class QueueManager {
|
||||||
|
constructor(obj_window) {
|
||||||
|
this.obj_window = obj_window;
|
||||||
|
this.list_queue = [];
|
||||||
|
this.nb_current_index = 0;
|
||||||
|
this.str_status = STR_STATUS_IDLE;
|
||||||
|
this.obj_current_process = null;
|
||||||
|
this.nb_last_render_ms = 0;
|
||||||
|
this.str_last_image_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(obj_config) {
|
||||||
|
if (this.str_status === STR_STATUS_PAUSED) {
|
||||||
|
this.str_status = STR_STATUS_RUNNING;
|
||||||
|
this._send_log("Reprise du rendu...");
|
||||||
|
this._process_next();
|
||||||
|
return Promise.resolve({ is_success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.list_queue = this._build_queue(obj_config);
|
||||||
|
this.nb_current_index = 0;
|
||||||
|
this.str_status = STR_STATUS_RUNNING;
|
||||||
|
this.nb_last_render_ms = 0;
|
||||||
|
this.str_last_image_path = null;
|
||||||
|
this.str_overwrite_mode = obj_config.str_overwrite_mode || "overwrite";
|
||||||
|
|
||||||
|
this._send_log("File de rendu construite : " + this.list_queue.length + " elements.");
|
||||||
|
this._send_progress();
|
||||||
|
this._process_next();
|
||||||
|
|
||||||
|
return Promise.resolve({ is_success: true, nb_total: this.list_queue.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
if (this.str_status !== STR_STATUS_RUNNING) {
|
||||||
|
return Promise.resolve({ is_success: false });
|
||||||
|
}
|
||||||
|
this.str_status = STR_STATUS_PAUSED;
|
||||||
|
this._send_log("Rendu en pause.");
|
||||||
|
return Promise.resolve({ is_success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.str_status = STR_STATUS_IDLE;
|
||||||
|
|
||||||
|
if (this.obj_current_process) {
|
||||||
|
this.obj_current_process.kill("SIGTERM");
|
||||||
|
this.obj_current_process = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._send_log("Rendu arrete.");
|
||||||
|
return Promise.resolve({ is_success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
_build_queue(obj_config) {
|
||||||
|
let list_queue = [];
|
||||||
|
let str_blend_path = obj_config.str_blend_file;
|
||||||
|
let str_mode = obj_config.str_render_mode;
|
||||||
|
let str_base_output = obj_config.str_output_path;
|
||||||
|
let str_output_mode = obj_config.str_output_mode || "subfolder";
|
||||||
|
let list_cameras = obj_config.list_cameras;
|
||||||
|
|
||||||
|
let list_enabled = [];
|
||||||
|
for (let obj_cam of list_cameras) {
|
||||||
|
if (obj_cam.is_enabled) {
|
||||||
|
list_enabled.push(obj_cam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_mode === "camera_by_camera") {
|
||||||
|
for (let obj_cam of list_enabled) {
|
||||||
|
for (let nb_frame = obj_cam.nb_frame_start; nb_frame <= obj_cam.nb_frame_end; nb_frame++) {
|
||||||
|
list_queue.push(this._create_queue_item(str_blend_path, str_base_output, str_output_mode, obj_cam, nb_frame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let nb_min_frame = Infinity;
|
||||||
|
let nb_max_frame = -Infinity;
|
||||||
|
for (let obj_cam of list_enabled) {
|
||||||
|
if (obj_cam.nb_frame_start < nb_min_frame) {
|
||||||
|
nb_min_frame = obj_cam.nb_frame_start;
|
||||||
|
}
|
||||||
|
if (obj_cam.nb_frame_end > nb_max_frame) {
|
||||||
|
nb_max_frame = obj_cam.nb_frame_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let nb_frame = nb_min_frame; nb_frame <= nb_max_frame; nb_frame++) {
|
||||||
|
for (let obj_cam of list_enabled) {
|
||||||
|
if (nb_frame >= obj_cam.nb_frame_start && nb_frame <= obj_cam.nb_frame_end) {
|
||||||
|
list_queue.push(this._create_queue_item(str_blend_path, str_base_output, str_output_mode, obj_cam, nb_frame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list_queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_create_queue_item(str_blend_path, str_base_output, str_output_mode, obj_cam, nb_frame) {
|
||||||
|
let str_padded_frame = String(nb_frame).padStart(5, "0");
|
||||||
|
let str_ext = obj_cam.str_format.toLowerCase();
|
||||||
|
if (str_ext === "open_exr") {
|
||||||
|
str_ext = "exr";
|
||||||
|
} else if (str_ext === "jpeg") {
|
||||||
|
str_ext = "jpg";
|
||||||
|
} else if (str_ext === "tiff") {
|
||||||
|
str_ext = "tif";
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_output_path = "";
|
||||||
|
let str_expected_file = "";
|
||||||
|
|
||||||
|
if (str_output_mode === "prefix") {
|
||||||
|
// Flat: /sortie/Camera.001_frame_#####
|
||||||
|
str_output_path = path.join(str_base_output, obj_cam.str_name + "_frame_#####");
|
||||||
|
str_expected_file = path.join(str_base_output, obj_cam.str_name + "_frame_" + str_padded_frame + "." + str_ext);
|
||||||
|
} else {
|
||||||
|
// Subfolder: /sortie/Camera.001/frame_#####
|
||||||
|
let str_output_dir = path.join(str_base_output, obj_cam.str_name);
|
||||||
|
str_output_path = path.join(str_output_dir, "frame_#####");
|
||||||
|
str_expected_file = path.join(str_output_dir, "frame_" + str_padded_frame + "." + str_ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
str_blend_path: str_blend_path,
|
||||||
|
str_camera_name: obj_cam.str_name,
|
||||||
|
nb_frame: nb_frame,
|
||||||
|
nb_resolution_x: obj_cam.nb_resolution_x,
|
||||||
|
nb_resolution_y: obj_cam.nb_resolution_y,
|
||||||
|
str_format: obj_cam.str_format,
|
||||||
|
str_output_path: str_output_path,
|
||||||
|
str_expected_file: str_expected_file,
|
||||||
|
str_status: "pending",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_process_next() {
|
||||||
|
if (this.str_status !== STR_STATUS_RUNNING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch skip : boucle iterative pour eviter un stack overflow recursif
|
||||||
|
let nb_skip_count = 0;
|
||||||
|
while (this.nb_current_index < this.list_queue.length && this.str_overwrite_mode === "skip") {
|
||||||
|
let obj_check = this.list_queue[this.nb_current_index];
|
||||||
|
if (!fs.existsSync(obj_check.str_expected_file)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let obj_stats = fs.statSync(obj_check.str_expected_file);
|
||||||
|
if (obj_stats.size === 0) {
|
||||||
|
this._send_log("Placeholder vide detecte, re-rendu : " + obj_check.str_camera_name + " F" + obj_check.nb_frame);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
obj_check.str_status = "skipped";
|
||||||
|
this.nb_current_index++;
|
||||||
|
nb_skip_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nb_skip_count > 0) {
|
||||||
|
this._send_log("Skip : " + nb_skip_count + " fichier(s) existant(s)");
|
||||||
|
this._send_progress();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.nb_current_index >= this.list_queue.length) {
|
||||||
|
this.str_status = STR_STATUS_IDLE;
|
||||||
|
this._send_log("Tous les rendus sont termines !");
|
||||||
|
this._send_event("render-complete", { is_all_done: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj_item = this.list_queue[this.nb_current_index];
|
||||||
|
obj_item.str_status = "rendering";
|
||||||
|
|
||||||
|
if (this.str_overwrite_mode === "skip") {
|
||||||
|
try {
|
||||||
|
let str_dir = path.dirname(obj_item.str_expected_file);
|
||||||
|
if (!fs.existsSync(str_dir)) {
|
||||||
|
fs.mkdirSync(str_dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(obj_item.str_expected_file, "");
|
||||||
|
} catch (obj_file_err) {
|
||||||
|
this._send_log("ERREUR creation placeholder : " + obj_file_err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._send_log("Rendu : " + obj_item.str_camera_name + " - Frame " + obj_item.nb_frame);
|
||||||
|
this._send_progress();
|
||||||
|
|
||||||
|
let nb_start = Date.now();
|
||||||
|
|
||||||
|
BlenderProcess.render_frame({
|
||||||
|
str_blend_path: obj_item.str_blend_path,
|
||||||
|
str_camera_name: obj_item.str_camera_name,
|
||||||
|
nb_frame: obj_item.nb_frame,
|
||||||
|
nb_resolution_x: obj_item.nb_resolution_x,
|
||||||
|
nb_resolution_y: obj_item.nb_resolution_y,
|
||||||
|
str_format: obj_item.str_format,
|
||||||
|
str_output_path: obj_item.str_output_path,
|
||||||
|
fn_on_stdout: (str_data) => {
|
||||||
|
this._send_log(str_data.trim());
|
||||||
|
},
|
||||||
|
fn_on_process: (obj_process) => {
|
||||||
|
this.obj_current_process = obj_process;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((obj_result) => {
|
||||||
|
this.obj_current_process = null;
|
||||||
|
|
||||||
|
if (this.str_status !== STR_STATUS_RUNNING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
obj_item.str_status = "done";
|
||||||
|
this.nb_last_render_ms = Date.now() - nb_start;
|
||||||
|
|
||||||
|
let str_image = obj_result.str_rendered_file || obj_item.str_expected_file;
|
||||||
|
this.str_last_image_path = str_image;
|
||||||
|
this._send_event("preview-update", str_image);
|
||||||
|
this._send_log("Termine : " + str_image);
|
||||||
|
|
||||||
|
this.nb_current_index++;
|
||||||
|
this._send_progress();
|
||||||
|
this._process_next();
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
this.obj_current_process = null;
|
||||||
|
|
||||||
|
if (this.str_overwrite_mode === "skip" && fs.existsSync(obj_item.str_expected_file)) {
|
||||||
|
try {
|
||||||
|
let obj_stats = fs.statSync(obj_item.str_expected_file);
|
||||||
|
if (obj_stats.size === 0) {
|
||||||
|
fs.unlinkSync(obj_item.str_expected_file);
|
||||||
|
}
|
||||||
|
} catch (obj_cleanup_err) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.str_status !== STR_STATUS_RUNNING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
obj_item.str_status = "error";
|
||||||
|
this.nb_last_render_ms = Date.now() - nb_start;
|
||||||
|
this.str_last_image_path = null;
|
||||||
|
|
||||||
|
this._send_log("ERREUR : " + obj_err.str_message);
|
||||||
|
this._send_event("render-error", {
|
||||||
|
str_camera: obj_item.str_camera_name,
|
||||||
|
nb_frame: obj_item.nb_frame,
|
||||||
|
str_error: obj_err.str_message,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nb_current_index++;
|
||||||
|
this._send_progress();
|
||||||
|
this._process_next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_send_progress() {
|
||||||
|
let obj_item = this.list_queue[this.nb_current_index] || {};
|
||||||
|
let list_skipped = [];
|
||||||
|
for (let nb_i = 0; nb_i < this.list_queue.length; nb_i++) {
|
||||||
|
if (this.list_queue[nb_i].str_status === "skipped") {
|
||||||
|
list_skipped.push(nb_i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._send_event("render-progress", {
|
||||||
|
nb_current: this.nb_current_index,
|
||||||
|
nb_total: this.list_queue.length,
|
||||||
|
str_camera: obj_item.str_camera_name || "-",
|
||||||
|
nb_frame: obj_item.nb_frame || 0,
|
||||||
|
str_status: this.str_status,
|
||||||
|
nb_last_render_ms: this.nb_last_render_ms,
|
||||||
|
str_last_image_path: this.str_last_image_path,
|
||||||
|
list_skipped: list_skipped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_send_log(str_message) {
|
||||||
|
this._send_event("log", str_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_send_event(str_channel, obj_data) {
|
||||||
|
if (this.obj_window && !this.obj_window.isDestroyed()) {
|
||||||
|
this.obj_window.webContents.send(str_channel, obj_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = QueueManager;
|
||||||
30
src/python/list_cameras.py
Normal file
30
src/python/list_cameras.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Script execute par Blender pour lister les cameras d'un fichier .blend."""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
obj_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_cameras() -> list[str]:
|
||||||
|
"""Recupere la liste des noms de cameras dans la scene Blender."""
|
||||||
|
list_cameras = []
|
||||||
|
for obj_item in bpy.data.objects:
|
||||||
|
if obj_item.type == "CAMERA":
|
||||||
|
list_cameras.append(obj_item.name)
|
||||||
|
return list_cameras
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Point d'entree principal du script."""
|
||||||
|
list_cameras = get_list_cameras()
|
||||||
|
str_output = json.dumps(list_cameras)
|
||||||
|
# stdout utilise pour communiquer avec Node.js
|
||||||
|
sys.stdout.write("CAMERAS_JSON:" + str_output + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
249
src/renderer/index.html
Normal file
249
src/renderer/index.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; font-src https://cdn.jsdelivr.net; img-src 'self' file: data:;">
|
||||||
|
<title>Multi Render Blender</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Material Design Icons -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" rel="stylesheet">
|
||||||
|
<!-- App CSS -->
|
||||||
|
<link rel="stylesheet" href="styles/Main.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-dark text-light">
|
||||||
|
|
||||||
|
<!-- ── Top Bar ──────────────────────────────────────────── -->
|
||||||
|
<nav class="navbar navbar-dark bg-dark border-bottom border-secondary px-3">
|
||||||
|
<span class="navbar-brand mb-0 h1">
|
||||||
|
<i class="mdi mdi-blender-software me-2"></i>Multi Render Blender
|
||||||
|
</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button id="btn_load_config" class="btn btn-sm btn-outline-secondary" title="Charger config">
|
||||||
|
<i class="mdi mdi-folder-open-outline"></i>
|
||||||
|
</button>
|
||||||
|
<button id="btn_save_config" class="btn btn-sm btn-outline-secondary" title="Sauvegarder config">
|
||||||
|
<i class="mdi mdi-content-save-outline"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid p-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
<!-- ── Left Column : File + Cameras ─────────────── -->
|
||||||
|
<div class="col-md-4 d-flex flex-column gap-3">
|
||||||
|
|
||||||
|
<!-- Blend file selection -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary">
|
||||||
|
<i class="mdi mdi-file-outline me-1"></i>Fichier Blender
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="input_blend_path" class="form-control bg-dark text-light border-secondary" placeholder="Aucun fichier selectionne" readonly>
|
||||||
|
<button id="btn_select_blend" class="btn btn-outline-primary" type="button">
|
||||||
|
<i class="mdi mdi-folder-search-outline"></i> Parcourir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output folder -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary">
|
||||||
|
<i class="mdi mdi-folder-outline me-1"></i>Dossier de sortie
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="input_output_path" class="form-control bg-dark text-light border-secondary" placeholder="Selectionnez un dossier" readonly>
|
||||||
|
<button id="btn_select_output" class="btn btn-outline-primary" type="button">
|
||||||
|
<i class="mdi mdi-folder-search-outline"></i> Parcourir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="output_mode" id="radio_output_subfolder" value="subfolder" checked>
|
||||||
|
<label class="form-check-label" for="radio_output_subfolder">
|
||||||
|
Sous-dossier par camera
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="output_mode" id="radio_output_prefix" value="prefix">
|
||||||
|
<label class="form-check-label" for="radio_output_prefix">
|
||||||
|
Nom camera dans le fichier
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<small id="label_output_example" class="text-light-emphasis">Ex: /sortie/<strong>Camera.001</strong>/frame_00001.png</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Render mode -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary">
|
||||||
|
<i class="mdi mdi-swap-horizontal me-1"></i>Mode de rendu
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="render_mode" id="radio_camera_by_camera" value="camera_by_camera" checked>
|
||||||
|
<label class="form-check-label" for="radio_camera_by_camera">
|
||||||
|
Camera par camera
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="render_mode" id="radio_frame_by_frame" value="frame_by_frame">
|
||||||
|
<label class="form-check-label" for="radio_frame_by_frame">
|
||||||
|
Frame par frame
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<hr class="my-2 border-secondary">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="overwrite_mode" id="radio_overwrite" value="overwrite" checked>
|
||||||
|
<label class="form-check-label" for="radio_overwrite">
|
||||||
|
Ecraser les fichiers existants
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="overwrite_mode" id="radio_skip" value="skip">
|
||||||
|
<label class="form-check-label" for="radio_skip">
|
||||||
|
Passer si le fichier existe (multi-PC)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera list -->
|
||||||
|
<div class="card bg-dark border-secondary flex-grow-1">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="mdi mdi-camera-outline me-1"></i>Cameras</span>
|
||||||
|
<span id="badge_camera_count" class="badge bg-secondary">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="container_camera_list" class="list-group list-group-flush overflow-auto" style="max-height: 400px;">
|
||||||
|
<div class="text-center text-light-emphasis py-4">
|
||||||
|
<i class="mdi mdi-camera-off-outline d-block mb-2" style="font-size: 2rem;"></i>
|
||||||
|
Chargez un fichier .blend
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Center Column : Camera Config + Controls ── -->
|
||||||
|
<div class="col-md-4 d-flex flex-column gap-3">
|
||||||
|
|
||||||
|
<!-- Camera config -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary">
|
||||||
|
<i class="mdi mdi-cog-outline me-1"></i>Configuration : <span id="label_selected_camera">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="container_camera_config">
|
||||||
|
<div class="text-center text-light-emphasis py-4">
|
||||||
|
Selectionnez une camera
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Render controls -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary">
|
||||||
|
<i class="mdi mdi-play-circle-outline me-1"></i>Controles
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<button id="btn_start" class="btn btn-success flex-fill" disabled>
|
||||||
|
<i class="mdi mdi-play"></i> Start
|
||||||
|
</button>
|
||||||
|
<button id="btn_pause" class="btn btn-warning flex-fill" disabled>
|
||||||
|
<i class="mdi mdi-pause"></i> Pause
|
||||||
|
</button>
|
||||||
|
<button id="btn_stop" class="btn btn-danger flex-fill" disabled>
|
||||||
|
<i class="mdi mdi-stop"></i> Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Progress -->
|
||||||
|
<div id="container_progress" class="mb-2">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<small id="label_progress_status">En attente</small>
|
||||||
|
<small id="label_progress_count">0 / 0</small>
|
||||||
|
</div>
|
||||||
|
<div class="progress bg-secondary" style="height: 8px;">
|
||||||
|
<div id="bar_progress" class="progress-bar bg-primary" role="progressbar" style="width: 0%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<small class="text-light-emphasis">Camera : <span id="label_current_camera">-</span></small>
|
||||||
|
<small class="text-light-emphasis">Frame : <span id="label_current_frame">-</span></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Render queue -->
|
||||||
|
<div class="card bg-dark border-secondary flex-grow-1">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="mdi mdi-format-list-numbered me-1"></i>File de rendu</span>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<small id="label_queue_time_estimate" class="queue-time-estimate"></small>
|
||||||
|
<span id="badge_queue_count" class="badge bg-secondary">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="container_render_queue" class="list-group list-group-flush overflow-auto" style="max-height: 300px;">
|
||||||
|
<div class="text-center text-light-emphasis py-4">
|
||||||
|
File vide
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Right Column : Preview + Console ─────────── -->
|
||||||
|
<div class="col-md-4 d-flex flex-column gap-3">
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary">
|
||||||
|
<i class="mdi mdi-image-outline me-1"></i>Preview
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2 text-center" id="container_preview">
|
||||||
|
<div class="preview-placeholder d-flex align-items-center justify-content-center" style="min-height: 300px;">
|
||||||
|
<div class="text-light-emphasis">
|
||||||
|
<i class="mdi mdi-image-off-outline d-block mb-2" style="font-size: 3rem;"></i>
|
||||||
|
Aucun rendu disponible
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Console logs -->
|
||||||
|
<div class="card bg-dark border-secondary flex-grow-1">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="mdi mdi-console me-1"></i>Console</span>
|
||||||
|
<button id="btn_clear_console" class="btn btn-sm btn-outline-secondary" title="Vider">
|
||||||
|
<i class="mdi mdi-delete-outline"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="container_console" class="console-output overflow-auto p-2" style="max-height: 300px; min-height: 200px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="scripts/ConsoleLog.js"></script>
|
||||||
|
<script src="scripts/CameraList.js"></script>
|
||||||
|
<script src="scripts/CameraConfig.js"></script>
|
||||||
|
<script src="scripts/RenderQueue.js"></script>
|
||||||
|
<script src="scripts/PreviewPanel.js"></script>
|
||||||
|
<script src="scripts/ProgressBar.js"></script>
|
||||||
|
<script src="scripts/App.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
311
src/renderer/scripts/App.js
Normal file
311
src/renderer/scripts/App.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
const App = {
|
||||||
|
str_blend_path: null,
|
||||||
|
str_output_path: null,
|
||||||
|
|
||||||
|
init: () => {
|
||||||
|
ConsoleLog.init();
|
||||||
|
CameraList.init(App._on_camera_select);
|
||||||
|
CameraConfig.init();
|
||||||
|
RenderQueue.init();
|
||||||
|
PreviewPanel.init();
|
||||||
|
ProgressBar.init();
|
||||||
|
|
||||||
|
App._bind_events();
|
||||||
|
App._bind_render_events();
|
||||||
|
|
||||||
|
ConsoleLog.add("Application prete.");
|
||||||
|
},
|
||||||
|
|
||||||
|
_bind_events: () => {
|
||||||
|
let obj_btn_blend = document.getElementById("btn_select_blend");
|
||||||
|
obj_btn_blend.addEventListener("click", () => {
|
||||||
|
App._select_blend_file();
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_btn_output = document.getElementById("btn_select_output");
|
||||||
|
obj_btn_output.addEventListener("click", () => {
|
||||||
|
App._select_output_folder();
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_btn_start = document.getElementById("btn_start");
|
||||||
|
obj_btn_start.addEventListener("click", () => {
|
||||||
|
App._start_render();
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_btn_pause = document.getElementById("btn_pause");
|
||||||
|
obj_btn_pause.addEventListener("click", () => {
|
||||||
|
App._pause_render();
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_btn_stop = document.getElementById("btn_stop");
|
||||||
|
obj_btn_stop.addEventListener("click", () => {
|
||||||
|
App._stop_render();
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_btn_save = document.getElementById("btn_save_config");
|
||||||
|
obj_btn_save.addEventListener("click", () => {
|
||||||
|
App._save_config();
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_btn_load = document.getElementById("btn_load_config");
|
||||||
|
obj_btn_load.addEventListener("click", () => {
|
||||||
|
App._load_config();
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_radio_subfolder = document.getElementById("radio_output_subfolder");
|
||||||
|
let obj_radio_prefix = document.getElementById("radio_output_prefix");
|
||||||
|
obj_radio_subfolder.addEventListener("change", () => { App._update_output_example(); });
|
||||||
|
obj_radio_prefix.addEventListener("change", () => { App._update_output_example(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
_bind_render_events: () => {
|
||||||
|
window.api.on_render_complete((obj_data) => {
|
||||||
|
if (obj_data.is_all_done) {
|
||||||
|
ConsoleLog.add("Tous les rendus sont termines !");
|
||||||
|
App._set_controls_state("idle");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.api.on_render_error((obj_data) => {
|
||||||
|
ConsoleLog.add("ERREUR sur " + obj_data.str_camera + " frame " + obj_data.nb_frame + " : " + obj_data.str_error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_update_output_example: () => {
|
||||||
|
let str_mode = document.querySelector('input[name="output_mode"]:checked').value;
|
||||||
|
let obj_label = document.getElementById("label_output_example");
|
||||||
|
if (str_mode === "subfolder") {
|
||||||
|
obj_label.innerHTML = 'Ex: /sortie/<strong>Camera.001</strong>/frame_00001.png';
|
||||||
|
} else {
|
||||||
|
obj_label.innerHTML = 'Ex: /sortie/<strong>Camera.001</strong>_frame_00001.png';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_select_blend_file: () => {
|
||||||
|
let obj_btn_blend = document.getElementById("btn_select_blend");
|
||||||
|
|
||||||
|
window.api.select_blend_file()
|
||||||
|
.then((str_path) => {
|
||||||
|
if (!str_path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
App.str_blend_path = str_path;
|
||||||
|
document.getElementById("input_blend_path").value = str_path;
|
||||||
|
ConsoleLog.add("Fichier charge : " + str_path);
|
||||||
|
|
||||||
|
obj_btn_blend.disabled = true;
|
||||||
|
CameraList.show_loading();
|
||||||
|
|
||||||
|
return window.api.get_cameras(str_path);
|
||||||
|
})
|
||||||
|
.then((list_cameras) => {
|
||||||
|
obj_btn_blend.disabled = false;
|
||||||
|
|
||||||
|
if (!list_cameras) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CameraList.set_cameras(list_cameras);
|
||||||
|
CameraConfig.clear();
|
||||||
|
ConsoleLog.add(list_cameras.length + " camera(s) trouvee(s).");
|
||||||
|
App._update_start_button();
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
obj_btn_blend.disabled = false;
|
||||||
|
ConsoleLog.add("Erreur chargement : " + obj_err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_select_output_folder: () => {
|
||||||
|
window.api.select_output_folder()
|
||||||
|
.then((str_path) => {
|
||||||
|
if (!str_path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
App.str_output_path = str_path;
|
||||||
|
document.getElementById("input_output_path").value = str_path;
|
||||||
|
ConsoleLog.add("Dossier de sortie : " + str_path);
|
||||||
|
App._update_start_button();
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
ConsoleLog.add("Erreur selection dossier : " + obj_err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_on_camera_select: (obj_camera) => {
|
||||||
|
CameraConfig.show(obj_camera);
|
||||||
|
},
|
||||||
|
|
||||||
|
_start_render: () => {
|
||||||
|
let obj_btn_start = document.getElementById("btn_start");
|
||||||
|
if (obj_btn_start.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
obj_btn_start.disabled = true;
|
||||||
|
|
||||||
|
if (!App.str_output_path) {
|
||||||
|
ConsoleLog.add("Veuillez selectionner un dossier de sortie.");
|
||||||
|
App._set_controls_state("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_mode = document.querySelector('input[name="render_mode"]:checked').value;
|
||||||
|
let str_output_mode = document.querySelector('input[name="output_mode"]:checked').value;
|
||||||
|
let str_overwrite_mode = document.querySelector('input[name="overwrite_mode"]:checked').value;
|
||||||
|
let list_cameras = CameraList.list_cameras;
|
||||||
|
|
||||||
|
let obj_config = {
|
||||||
|
str_blend_file: App.str_blend_path,
|
||||||
|
str_render_mode: str_mode,
|
||||||
|
str_output_mode: str_output_mode,
|
||||||
|
str_overwrite_mode: str_overwrite_mode,
|
||||||
|
str_output_path: App.str_output_path,
|
||||||
|
list_cameras: list_cameras,
|
||||||
|
};
|
||||||
|
|
||||||
|
RenderQueue.build_display(str_mode, list_cameras);
|
||||||
|
ProgressBar.reset();
|
||||||
|
|
||||||
|
window.api.start_render(obj_config)
|
||||||
|
.then(() => {
|
||||||
|
App._set_controls_state("running");
|
||||||
|
ConsoleLog.add("Rendu lance en mode : " + str_mode);
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
App._set_controls_state("idle");
|
||||||
|
ConsoleLog.add("Erreur lancement rendu : " + obj_err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_pause_render: () => {
|
||||||
|
window.api.pause_render()
|
||||||
|
.then(() => {
|
||||||
|
App._set_controls_state("paused");
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
ConsoleLog.add("Erreur pause : " + obj_err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_stop_render: () => {
|
||||||
|
window.api.stop_render()
|
||||||
|
.then(() => {
|
||||||
|
App._set_controls_state("idle");
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
ConsoleLog.add("Erreur arret : " + obj_err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_save_config: () => {
|
||||||
|
let str_mode = document.querySelector('input[name="render_mode"]:checked').value;
|
||||||
|
let str_output_mode = document.querySelector('input[name="output_mode"]:checked').value;
|
||||||
|
let str_overwrite_mode = document.querySelector('input[name="overwrite_mode"]:checked').value;
|
||||||
|
let obj_config = {
|
||||||
|
str_blend_file: App.str_blend_path,
|
||||||
|
str_render_mode: str_mode,
|
||||||
|
str_output_mode: str_output_mode,
|
||||||
|
str_overwrite_mode: str_overwrite_mode,
|
||||||
|
str_output_path: App.str_output_path,
|
||||||
|
list_cameras: CameraList.list_cameras,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.api.save_config(obj_config)
|
||||||
|
.then((obj_result) => {
|
||||||
|
if (obj_result && obj_result.is_success) {
|
||||||
|
ConsoleLog.add("Configuration exportee : " + obj_result.str_path);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
ConsoleLog.add("Erreur sauvegarde : " + obj_err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_load_config: () => {
|
||||||
|
window.api.load_config()
|
||||||
|
.then((obj_config) => {
|
||||||
|
if (!obj_config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
App.str_blend_path = obj_config.str_blend_file;
|
||||||
|
App.str_output_path = obj_config.str_output_path || null;
|
||||||
|
document.getElementById("input_blend_path").value = obj_config.str_blend_file || "";
|
||||||
|
document.getElementById("input_output_path").value = obj_config.str_output_path || "";
|
||||||
|
|
||||||
|
if (obj_config.str_render_mode === "frame_by_frame") {
|
||||||
|
document.getElementById("radio_frame_by_frame").checked = true;
|
||||||
|
} else {
|
||||||
|
document.getElementById("radio_camera_by_camera").checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj_config.str_output_mode === "prefix") {
|
||||||
|
document.getElementById("radio_output_prefix").checked = true;
|
||||||
|
} else {
|
||||||
|
document.getElementById("radio_output_subfolder").checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj_config.str_overwrite_mode === "skip") {
|
||||||
|
document.getElementById("radio_skip").checked = true;
|
||||||
|
} else {
|
||||||
|
document.getElementById("radio_overwrite").checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
App._update_output_example();
|
||||||
|
|
||||||
|
if (obj_config.list_cameras && obj_config.list_cameras.length > 0) {
|
||||||
|
CameraList.list_cameras = obj_config.list_cameras;
|
||||||
|
CameraList.str_selected_camera = null;
|
||||||
|
CameraList.render();
|
||||||
|
|
||||||
|
let obj_badge = document.getElementById("badge_camera_count");
|
||||||
|
obj_badge.textContent = String(obj_config.list_cameras.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
CameraConfig.clear();
|
||||||
|
App._update_start_button();
|
||||||
|
ConsoleLog.add("Configuration importee.");
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
ConsoleLog.add("Erreur chargement config : " + obj_err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── UI State ───────────────────────────────────────────
|
||||||
|
|
||||||
|
_set_controls_state: (str_state) => {
|
||||||
|
let obj_btn_start = document.getElementById("btn_start");
|
||||||
|
let obj_btn_pause = document.getElementById("btn_pause");
|
||||||
|
let obj_btn_stop = document.getElementById("btn_stop");
|
||||||
|
|
||||||
|
if (str_state === "running") {
|
||||||
|
obj_btn_start.disabled = true;
|
||||||
|
obj_btn_pause.disabled = false;
|
||||||
|
obj_btn_stop.disabled = false;
|
||||||
|
} else if (str_state === "paused") {
|
||||||
|
obj_btn_start.disabled = false;
|
||||||
|
obj_btn_pause.disabled = true;
|
||||||
|
obj_btn_stop.disabled = false;
|
||||||
|
} else {
|
||||||
|
App._update_start_button();
|
||||||
|
obj_btn_pause.disabled = true;
|
||||||
|
obj_btn_stop.disabled = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_update_start_button: () => {
|
||||||
|
let obj_btn_start = document.getElementById("btn_start");
|
||||||
|
let is_ready = App.str_blend_path
|
||||||
|
&& App.str_output_path
|
||||||
|
&& CameraList.get_enabled_cameras().length > 0;
|
||||||
|
obj_btn_start.disabled = !is_ready;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
App.init();
|
||||||
|
});
|
||||||
86
src/renderer/scripts/CameraConfig.js
Normal file
86
src/renderer/scripts/CameraConfig.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const CameraConfig = {
|
||||||
|
obj_current_camera: null,
|
||||||
|
|
||||||
|
init: () => {
|
||||||
|
// Initialized on demand when a camera is selected
|
||||||
|
},
|
||||||
|
|
||||||
|
show: (obj_camera) => {
|
||||||
|
CameraConfig.obj_current_camera = obj_camera;
|
||||||
|
|
||||||
|
let obj_label = document.getElementById("label_selected_camera");
|
||||||
|
obj_label.textContent = obj_camera.str_name;
|
||||||
|
|
||||||
|
let obj_container = document.getElementById("container_camera_config");
|
||||||
|
obj_container.innerHTML = "";
|
||||||
|
|
||||||
|
let str_html = ""
|
||||||
|
+ '<div class="row g-2">'
|
||||||
|
+ ' <div class="col-6">'
|
||||||
|
+ ' <label class="form-label form-label-sm">Resolution X</label>'
|
||||||
|
+ ' <input type="number" class="form-control form-control-sm bg-dark text-light border-secondary" id="input_res_x" value="' + obj_camera.nb_resolution_x + '" min="1">'
|
||||||
|
+ " </div>"
|
||||||
|
+ ' <div class="col-6">'
|
||||||
|
+ ' <label class="form-label form-label-sm">Resolution Y</label>'
|
||||||
|
+ ' <input type="number" class="form-control form-control-sm bg-dark text-light border-secondary" id="input_res_y" value="' + obj_camera.nb_resolution_y + '" min="1">'
|
||||||
|
+ " </div>"
|
||||||
|
+ ' <div class="col-6">'
|
||||||
|
+ ' <label class="form-label form-label-sm">Frame start</label>'
|
||||||
|
+ ' <input type="number" class="form-control form-control-sm bg-dark text-light border-secondary" id="input_frame_start" value="' + obj_camera.nb_frame_start + '" min="0">'
|
||||||
|
+ " </div>"
|
||||||
|
+ ' <div class="col-6">'
|
||||||
|
+ ' <label class="form-label form-label-sm">Frame end</label>'
|
||||||
|
+ ' <input type="number" class="form-control form-control-sm bg-dark text-light border-secondary" id="input_frame_end" value="' + obj_camera.nb_frame_end + '" min="0">'
|
||||||
|
+ " </div>"
|
||||||
|
+ ' <div class="col-12">'
|
||||||
|
+ ' <label class="form-label form-label-sm">Format</label>'
|
||||||
|
+ ' <select class="form-select form-select-sm bg-dark text-light border-secondary" id="select_format">'
|
||||||
|
+ ' <option value="PNG"' + (obj_camera.str_format === "PNG" ? " selected" : "") + ">PNG</option>"
|
||||||
|
+ ' <option value="JPEG"' + (obj_camera.str_format === "JPEG" ? " selected" : "") + ">JPEG</option>"
|
||||||
|
+ ' <option value="OPEN_EXR"' + (obj_camera.str_format === "OPEN_EXR" ? " selected" : "") + ">EXR</option>"
|
||||||
|
+ ' <option value="BMP"' + (obj_camera.str_format === "BMP" ? " selected" : "") + ">BMP</option>"
|
||||||
|
+ ' <option value="TIFF"' + (obj_camera.str_format === "TIFF" ? " selected" : "") + ">TIFF</option>"
|
||||||
|
+ " </select>"
|
||||||
|
+ " </div>"
|
||||||
|
+ ' <div class="col-12 mt-3">'
|
||||||
|
+ ' <button class="btn btn-sm btn-primary w-100" id="btn_apply_config">'
|
||||||
|
+ ' <i class="mdi mdi-check me-1"></i>Appliquer'
|
||||||
|
+ " </button>"
|
||||||
|
+ " </div>"
|
||||||
|
+ "</div>";
|
||||||
|
|
||||||
|
obj_container.innerHTML = str_html;
|
||||||
|
|
||||||
|
CameraConfig._bind_events();
|
||||||
|
},
|
||||||
|
|
||||||
|
_bind_events: () => {
|
||||||
|
let obj_btn_apply = document.getElementById("btn_apply_config");
|
||||||
|
obj_btn_apply.addEventListener("click", () => {
|
||||||
|
CameraConfig._apply();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_apply: () => {
|
||||||
|
let obj_cam = CameraConfig.obj_current_camera;
|
||||||
|
if (!obj_cam) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
obj_cam.nb_resolution_x = parseInt(document.getElementById("input_res_x").value, 10) || 1920;
|
||||||
|
obj_cam.nb_resolution_y = parseInt(document.getElementById("input_res_y").value, 10) || 1080;
|
||||||
|
obj_cam.nb_frame_start = parseInt(document.getElementById("input_frame_start").value, 10) || 1;
|
||||||
|
obj_cam.nb_frame_end = parseInt(document.getElementById("input_frame_end").value, 10) || 250;
|
||||||
|
obj_cam.str_format = document.getElementById("select_format").value;
|
||||||
|
|
||||||
|
ConsoleLog.add("Config appliquee pour " + obj_cam.str_name);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => {
|
||||||
|
CameraConfig.obj_current_camera = null;
|
||||||
|
let obj_label = document.getElementById("label_selected_camera");
|
||||||
|
obj_label.textContent = "-";
|
||||||
|
let obj_container = document.getElementById("container_camera_config");
|
||||||
|
obj_container.innerHTML = '<div class="text-center text-light-emphasis py-4">Selectionnez une camera</div>';
|
||||||
|
},
|
||||||
|
};
|
||||||
116
src/renderer/scripts/CameraList.js
Normal file
116
src/renderer/scripts/CameraList.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const CameraList = {
|
||||||
|
list_cameras: [],
|
||||||
|
str_selected_camera: null,
|
||||||
|
fn_on_select: null,
|
||||||
|
|
||||||
|
init: (fn_on_select) => {
|
||||||
|
CameraList.fn_on_select = fn_on_select;
|
||||||
|
},
|
||||||
|
|
||||||
|
set_cameras: (list_names) => {
|
||||||
|
CameraList.list_cameras = [];
|
||||||
|
|
||||||
|
for (let str_name of list_names) {
|
||||||
|
CameraList.list_cameras.push({
|
||||||
|
str_name: str_name,
|
||||||
|
is_enabled: true,
|
||||||
|
nb_resolution_x: 1920,
|
||||||
|
nb_resolution_y: 1080,
|
||||||
|
nb_frame_start: 1,
|
||||||
|
nb_frame_end: 250,
|
||||||
|
str_format: "PNG",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
CameraList.str_selected_camera = null;
|
||||||
|
CameraList.render();
|
||||||
|
|
||||||
|
let obj_badge = document.getElementById("badge_camera_count");
|
||||||
|
obj_badge.textContent = String(list_names.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
get_camera_by_name: (str_name) => {
|
||||||
|
for (let obj_cam of CameraList.list_cameras) {
|
||||||
|
if (obj_cam.str_name === str_name) {
|
||||||
|
return obj_cam;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
get_enabled_cameras: () => {
|
||||||
|
let list_enabled = [];
|
||||||
|
for (let obj_cam of CameraList.list_cameras) {
|
||||||
|
if (obj_cam.is_enabled) {
|
||||||
|
list_enabled.push(obj_cam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list_enabled;
|
||||||
|
},
|
||||||
|
|
||||||
|
show_loading: () => {
|
||||||
|
let obj_container = document.getElementById("container_camera_list");
|
||||||
|
obj_container.innerHTML = '<div class="text-center text-light-emphasis py-4">'
|
||||||
|
+ '<i class="mdi mdi-loading mdi-spin d-block mb-2" style="font-size: 2rem;"></i>'
|
||||||
|
+ "Chargement des cameras..."
|
||||||
|
+ "</div>";
|
||||||
|
|
||||||
|
let obj_badge = document.getElementById("badge_camera_count");
|
||||||
|
obj_badge.textContent = "0";
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
let obj_container = document.getElementById("container_camera_list");
|
||||||
|
obj_container.innerHTML = "";
|
||||||
|
|
||||||
|
if (CameraList.list_cameras.length === 0) {
|
||||||
|
obj_container.innerHTML = '<div class="text-center text-light-emphasis py-4">'
|
||||||
|
+ '<i class="mdi mdi-camera-off-outline d-block mb-2" style="font-size: 2rem;"></i>'
|
||||||
|
+ "Chargez un fichier .blend"
|
||||||
|
+ "</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let obj_cam of CameraList.list_cameras) {
|
||||||
|
let obj_item = document.createElement("div");
|
||||||
|
obj_item.classList.add("list-group-item", "list-group-item-action", "bg-dark", "text-light", "border-secondary", "d-flex", "align-items-center", "gap-2");
|
||||||
|
|
||||||
|
if (obj_cam.str_name === CameraList.str_selected_camera) {
|
||||||
|
obj_item.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj_checkbox = document.createElement("input");
|
||||||
|
obj_checkbox.type = "checkbox";
|
||||||
|
obj_checkbox.classList.add("form-check-input");
|
||||||
|
obj_checkbox.checked = obj_cam.is_enabled;
|
||||||
|
obj_checkbox.addEventListener("change", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
obj_cam.is_enabled = obj_checkbox.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
let obj_icon = document.createElement("i");
|
||||||
|
obj_icon.classList.add("mdi", "mdi-camera-outline");
|
||||||
|
|
||||||
|
let obj_label = document.createElement("span");
|
||||||
|
obj_label.classList.add("flex-grow-1");
|
||||||
|
obj_label.textContent = obj_cam.str_name;
|
||||||
|
|
||||||
|
obj_item.appendChild(obj_checkbox);
|
||||||
|
obj_item.appendChild(obj_icon);
|
||||||
|
obj_item.appendChild(obj_label);
|
||||||
|
|
||||||
|
obj_item.addEventListener("click", (event) => {
|
||||||
|
if (event.target === obj_checkbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CameraList.str_selected_camera = obj_cam.str_name;
|
||||||
|
CameraList.render();
|
||||||
|
if (CameraList.fn_on_select) {
|
||||||
|
CameraList.fn_on_select(obj_cam);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
obj_container.appendChild(obj_item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
78
src/renderer/scripts/ConsoleLog.js
Normal file
78
src/renderer/scripts/ConsoleLog.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const NB_MAX_CONSOLE_LINES = 500;
|
||||||
|
const NB_FLUSH_INTERVAL = 100;
|
||||||
|
|
||||||
|
const ConsoleLog = {
|
||||||
|
obj_container: null,
|
||||||
|
_list_buffer: [],
|
||||||
|
_nb_flush_timer: null,
|
||||||
|
|
||||||
|
init: () => {
|
||||||
|
ConsoleLog.obj_container = document.getElementById("container_console");
|
||||||
|
|
||||||
|
let obj_btn_clear = document.getElementById("btn_clear_console");
|
||||||
|
obj_btn_clear.addEventListener("click", () => {
|
||||||
|
ConsoleLog.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.api.on_log((str_message) => {
|
||||||
|
ConsoleLog.add(str_message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
add: (str_message) => {
|
||||||
|
ConsoleLog._list_buffer.push(str_message);
|
||||||
|
|
||||||
|
if (ConsoleLog._nb_flush_timer === null) {
|
||||||
|
ConsoleLog._nb_flush_timer = setTimeout(() => {
|
||||||
|
ConsoleLog._flush();
|
||||||
|
}, NB_FLUSH_INTERVAL);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_flush: () => {
|
||||||
|
ConsoleLog._nb_flush_timer = null;
|
||||||
|
|
||||||
|
let list_messages = ConsoleLog._list_buffer;
|
||||||
|
ConsoleLog._list_buffer = [];
|
||||||
|
|
||||||
|
let obj_fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (let str_message of list_messages) {
|
||||||
|
let obj_line = document.createElement("div");
|
||||||
|
obj_line.classList.add("console-line");
|
||||||
|
|
||||||
|
let obj_time = document.createElement("span");
|
||||||
|
obj_time.classList.add("console-time");
|
||||||
|
let obj_date = new Date();
|
||||||
|
let str_time = String(obj_date.getHours()).padStart(2, "0")
|
||||||
|
+ ":" + String(obj_date.getMinutes()).padStart(2, "0")
|
||||||
|
+ ":" + String(obj_date.getSeconds()).padStart(2, "0");
|
||||||
|
obj_time.textContent = str_time;
|
||||||
|
|
||||||
|
let obj_text = document.createElement("span");
|
||||||
|
obj_text.classList.add("console-text");
|
||||||
|
obj_text.textContent = str_message;
|
||||||
|
|
||||||
|
obj_line.appendChild(obj_time);
|
||||||
|
obj_line.appendChild(obj_text);
|
||||||
|
obj_fragment.appendChild(obj_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleLog.obj_container.appendChild(obj_fragment);
|
||||||
|
|
||||||
|
while (ConsoleLog.obj_container.childElementCount > NB_MAX_CONSOLE_LINES) {
|
||||||
|
ConsoleLog.obj_container.removeChild(ConsoleLog.obj_container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleLog.obj_container.scrollTop = ConsoleLog.obj_container.scrollHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => {
|
||||||
|
ConsoleLog._list_buffer = [];
|
||||||
|
if (ConsoleLog._nb_flush_timer !== null) {
|
||||||
|
clearTimeout(ConsoleLog._nb_flush_timer);
|
||||||
|
ConsoleLog._nb_flush_timer = null;
|
||||||
|
}
|
||||||
|
ConsoleLog.obj_container.innerHTML = "";
|
||||||
|
},
|
||||||
|
};
|
||||||
58
src/renderer/scripts/PreviewPanel.js
Normal file
58
src/renderer/scripts/PreviewPanel.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const PreviewPanel = {
|
||||||
|
obj_container: null,
|
||||||
|
|
||||||
|
init: () => {
|
||||||
|
PreviewPanel.obj_container = document.getElementById("container_preview");
|
||||||
|
|
||||||
|
window.api.on_preview_update((str_image_path) => {
|
||||||
|
PreviewPanel.show_image(str_image_path);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
show_image: (str_image_path) => {
|
||||||
|
if (!str_image_path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PreviewPanel.obj_container.innerHTML = '<div class="text-center text-light-emphasis py-4">'
|
||||||
|
+ '<i class="mdi mdi-loading mdi-spin d-block mb-2" style="font-size: 2rem;"></i>'
|
||||||
|
+ "Chargement..."
|
||||||
|
+ "</div>";
|
||||||
|
|
||||||
|
window.api.read_image(str_image_path)
|
||||||
|
.then((str_data_url) => {
|
||||||
|
PreviewPanel.obj_container.innerHTML = "";
|
||||||
|
|
||||||
|
let obj_img = document.createElement("img");
|
||||||
|
obj_img.classList.add("preview-image");
|
||||||
|
obj_img.src = str_data_url;
|
||||||
|
obj_img.alt = "Rendu";
|
||||||
|
|
||||||
|
let obj_label = document.createElement("div");
|
||||||
|
obj_label.classList.add("preview-label", "text-light-emphasis", "mt-1");
|
||||||
|
|
||||||
|
let str_filename = str_image_path.replace(/\\/g, "/");
|
||||||
|
let list_parts = str_filename.split("/");
|
||||||
|
obj_label.textContent = list_parts[list_parts.length - 1];
|
||||||
|
|
||||||
|
PreviewPanel.obj_container.appendChild(obj_img);
|
||||||
|
PreviewPanel.obj_container.appendChild(obj_label);
|
||||||
|
})
|
||||||
|
.catch((obj_err) => {
|
||||||
|
ConsoleLog.add("Erreur preview : " + (obj_err.message || obj_err));
|
||||||
|
PreviewPanel.obj_container.innerHTML = '<div class="text-center text-warning py-4">'
|
||||||
|
+ '<i class="mdi mdi-image-broken-variant d-block mb-2" style="font-size: 3rem;"></i>'
|
||||||
|
+ "Impossible de charger l'image"
|
||||||
|
+ '<div class="mt-1" style="font-size: 0.7rem;">' + str_image_path + "</div>"
|
||||||
|
+ "</div>";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => {
|
||||||
|
PreviewPanel.obj_container.innerHTML = '<div class="preview-placeholder d-flex align-items-center justify-content-center" style="min-height: 300px;">'
|
||||||
|
+ '<div class="text-light-emphasis">'
|
||||||
|
+ '<i class="mdi mdi-image-off-outline d-block mb-2" style="font-size: 3rem;"></i>'
|
||||||
|
+ "Aucun rendu disponible"
|
||||||
|
+ "</div></div>";
|
||||||
|
},
|
||||||
|
};
|
||||||
54
src/renderer/scripts/ProgressBar.js
Normal file
54
src/renderer/scripts/ProgressBar.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const ProgressBar = {
|
||||||
|
init: () => {
|
||||||
|
window.api.on_render_progress((obj_data) => {
|
||||||
|
ProgressBar.update(obj_data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (obj_data) => {
|
||||||
|
let nb_current = obj_data.nb_current || 0;
|
||||||
|
let nb_total = obj_data.nb_total || 0;
|
||||||
|
let str_camera = obj_data.str_camera || "-";
|
||||||
|
let nb_frame = obj_data.nb_frame || 0;
|
||||||
|
let str_status = obj_data.str_status || "idle";
|
||||||
|
|
||||||
|
let nb_percent = 0;
|
||||||
|
if (nb_total > 0) {
|
||||||
|
nb_percent = Math.round((nb_current / nb_total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj_bar = document.getElementById("bar_progress");
|
||||||
|
obj_bar.style.width = nb_percent + "%";
|
||||||
|
|
||||||
|
let obj_count = document.getElementById("label_progress_count");
|
||||||
|
obj_count.textContent = nb_current + " / " + nb_total;
|
||||||
|
|
||||||
|
let obj_status = document.getElementById("label_progress_status");
|
||||||
|
if (str_status === "running") {
|
||||||
|
obj_status.textContent = "Rendu en cours...";
|
||||||
|
} else if (str_status === "paused") {
|
||||||
|
obj_status.textContent = "En pause";
|
||||||
|
} else if (str_status === "idle" && nb_current >= nb_total && nb_total > 0) {
|
||||||
|
obj_status.textContent = "Termine";
|
||||||
|
} else {
|
||||||
|
obj_status.textContent = "En attente";
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj_camera_label = document.getElementById("label_current_camera");
|
||||||
|
obj_camera_label.textContent = str_camera;
|
||||||
|
|
||||||
|
let obj_frame_label = document.getElementById("label_current_frame");
|
||||||
|
obj_frame_label.textContent = nb_frame > 0 ? String(nb_frame) : "-";
|
||||||
|
|
||||||
|
RenderQueue.update_progress(nb_current, obj_data.nb_last_render_ms || 0, obj_data.str_last_image_path || null, obj_data.list_skipped || []);
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
let obj_bar = document.getElementById("bar_progress");
|
||||||
|
obj_bar.style.width = "0%";
|
||||||
|
document.getElementById("label_progress_count").textContent = "0 / 0";
|
||||||
|
document.getElementById("label_progress_status").textContent = "En attente";
|
||||||
|
document.getElementById("label_current_camera").textContent = "-";
|
||||||
|
document.getElementById("label_current_frame").textContent = "-";
|
||||||
|
},
|
||||||
|
};
|
||||||
234
src/renderer/scripts/RenderQueue.js
Normal file
234
src/renderer/scripts/RenderQueue.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
const RenderQueue = {
|
||||||
|
list_items: [],
|
||||||
|
nb_total_render_ms: 0,
|
||||||
|
nb_completed_renders: 0,
|
||||||
|
nb_last_current: 0,
|
||||||
|
|
||||||
|
init: () => {
|
||||||
|
// Initialized on demand
|
||||||
|
},
|
||||||
|
|
||||||
|
build_display: (str_mode, list_cameras) => {
|
||||||
|
RenderQueue.list_items = [];
|
||||||
|
RenderQueue.nb_total_render_ms = 0;
|
||||||
|
RenderQueue.nb_completed_renders = 0;
|
||||||
|
RenderQueue.nb_last_current = 0;
|
||||||
|
|
||||||
|
let list_enabled = [];
|
||||||
|
for (let obj_cam of list_cameras) {
|
||||||
|
if (obj_cam.is_enabled) {
|
||||||
|
list_enabled.push(obj_cam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_mode === "camera_by_camera") {
|
||||||
|
for (let obj_cam of list_enabled) {
|
||||||
|
for (let nb_frame = obj_cam.nb_frame_start; nb_frame <= obj_cam.nb_frame_end; nb_frame++) {
|
||||||
|
RenderQueue.list_items.push({
|
||||||
|
str_camera: obj_cam.str_name,
|
||||||
|
nb_frame: nb_frame,
|
||||||
|
str_status: "pending",
|
||||||
|
str_image_path: null,
|
||||||
|
obj_dom_el: null,
|
||||||
|
obj_dom_icon: null,
|
||||||
|
str_dom_status: null,
|
||||||
|
is_click_bound: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let nb_min = Infinity;
|
||||||
|
let nb_max = -Infinity;
|
||||||
|
for (let obj_cam of list_enabled) {
|
||||||
|
if (obj_cam.nb_frame_start < nb_min) {
|
||||||
|
nb_min = obj_cam.nb_frame_start;
|
||||||
|
}
|
||||||
|
if (obj_cam.nb_frame_end > nb_max) {
|
||||||
|
nb_max = obj_cam.nb_frame_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let nb_frame = nb_min; nb_frame <= nb_max; nb_frame++) {
|
||||||
|
for (let obj_cam of list_enabled) {
|
||||||
|
if (nb_frame >= obj_cam.nb_frame_start && nb_frame <= obj_cam.nb_frame_end) {
|
||||||
|
RenderQueue.list_items.push({
|
||||||
|
str_camera: obj_cam.str_name,
|
||||||
|
nb_frame: nb_frame,
|
||||||
|
str_status: "pending",
|
||||||
|
str_image_path: null,
|
||||||
|
obj_dom_el: null,
|
||||||
|
obj_dom_icon: null,
|
||||||
|
str_dom_status: null,
|
||||||
|
is_click_bound: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj_badge = document.getElementById("badge_queue_count");
|
||||||
|
obj_badge.textContent = String(RenderQueue.list_items.length);
|
||||||
|
|
||||||
|
RenderQueue._update_time_display();
|
||||||
|
RenderQueue._create_dom();
|
||||||
|
},
|
||||||
|
|
||||||
|
_create_dom: () => {
|
||||||
|
let obj_container = document.getElementById("container_render_queue");
|
||||||
|
obj_container.innerHTML = "";
|
||||||
|
|
||||||
|
if (RenderQueue.list_items.length === 0) {
|
||||||
|
obj_container.innerHTML = '<div class="text-center text-light-emphasis py-4">File vide</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj_fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (let obj_item of RenderQueue.list_items) {
|
||||||
|
let obj_el = document.createElement("div");
|
||||||
|
obj_el.classList.add("list-group-item", "bg-dark", "text-light", "border-secondary", "py-1", "px-3", "d-flex", "align-items-center", "gap-2");
|
||||||
|
|
||||||
|
let obj_icon = document.createElement("i");
|
||||||
|
obj_icon.classList.add("mdi", "mdi-clock-outline", "text-muted");
|
||||||
|
|
||||||
|
let obj_name = document.createElement("small");
|
||||||
|
obj_name.classList.add("flex-grow-1");
|
||||||
|
obj_name.textContent = obj_item.str_camera;
|
||||||
|
|
||||||
|
let obj_frame = document.createElement("small");
|
||||||
|
obj_frame.classList.add("text-light-emphasis");
|
||||||
|
obj_frame.textContent = "F" + obj_item.nb_frame;
|
||||||
|
|
||||||
|
obj_el.appendChild(obj_icon);
|
||||||
|
obj_el.appendChild(obj_name);
|
||||||
|
obj_el.appendChild(obj_frame);
|
||||||
|
|
||||||
|
obj_item.obj_dom_el = obj_el;
|
||||||
|
obj_item.obj_dom_icon = obj_icon;
|
||||||
|
obj_item.str_dom_status = "pending";
|
||||||
|
|
||||||
|
obj_fragment.appendChild(obj_el);
|
||||||
|
}
|
||||||
|
|
||||||
|
obj_container.appendChild(obj_fragment);
|
||||||
|
},
|
||||||
|
|
||||||
|
update_progress: (nb_current, nb_last_render_ms, str_last_image_path, list_skipped) => {
|
||||||
|
if (nb_current > RenderQueue.nb_last_current && nb_last_render_ms > 0) {
|
||||||
|
RenderQueue.nb_total_render_ms += nb_last_render_ms;
|
||||||
|
RenderQueue.nb_completed_renders++;
|
||||||
|
}
|
||||||
|
RenderQueue.nb_last_current = nb_current;
|
||||||
|
|
||||||
|
if (str_last_image_path && nb_current > 0 && nb_current - 1 < RenderQueue.list_items.length) {
|
||||||
|
RenderQueue.list_items[nb_current - 1].str_image_path = str_last_image_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let nb_i = 0; nb_i < RenderQueue.list_items.length; nb_i++) {
|
||||||
|
if (list_skipped && list_skipped.indexOf(nb_i) !== -1) {
|
||||||
|
RenderQueue.list_items[nb_i].str_status = "skipped";
|
||||||
|
} else if (nb_i < nb_current) {
|
||||||
|
RenderQueue.list_items[nb_i].str_status = "done";
|
||||||
|
} else if (nb_i === nb_current) {
|
||||||
|
RenderQueue.list_items[nb_i].str_status = "rendering";
|
||||||
|
} else {
|
||||||
|
RenderQueue.list_items[nb_i].str_status = "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderQueue._update_time_display();
|
||||||
|
RenderQueue._update_statuses();
|
||||||
|
},
|
||||||
|
|
||||||
|
_update_statuses: () => {
|
||||||
|
for (let obj_item of RenderQueue.list_items) {
|
||||||
|
if (!obj_item.obj_dom_el) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_needs_click = obj_item.str_status === "done" && obj_item.str_image_path && !obj_item.is_click_bound;
|
||||||
|
|
||||||
|
if (obj_item.str_status === obj_item.str_dom_status && !is_needs_click) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let str_icon = "mdi-clock-outline";
|
||||||
|
let str_color = "text-muted";
|
||||||
|
|
||||||
|
if (obj_item.str_status === "rendering") {
|
||||||
|
str_icon = "mdi-loading mdi-spin";
|
||||||
|
str_color = "text-primary";
|
||||||
|
} else if (obj_item.str_status === "done") {
|
||||||
|
str_icon = "mdi-check-circle";
|
||||||
|
str_color = "text-success";
|
||||||
|
} else if (obj_item.str_status === "error") {
|
||||||
|
str_icon = "mdi-alert-circle";
|
||||||
|
str_color = "text-danger";
|
||||||
|
} else if (obj_item.str_status === "skipped") {
|
||||||
|
str_icon = "mdi-skip-next-circle";
|
||||||
|
str_color = "text-info";
|
||||||
|
}
|
||||||
|
|
||||||
|
obj_item.obj_dom_icon.className = "mdi " + str_icon + " " + str_color;
|
||||||
|
|
||||||
|
if (is_needs_click) {
|
||||||
|
obj_item.obj_dom_el.classList.add("queue-item-clickable");
|
||||||
|
let str_path = obj_item.str_image_path;
|
||||||
|
obj_item.obj_dom_el.addEventListener("click", () => {
|
||||||
|
PreviewPanel.show_image(str_path);
|
||||||
|
});
|
||||||
|
obj_item.is_click_bound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
obj_item.str_dom_status = obj_item.str_status;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_update_time_display: () => {
|
||||||
|
let obj_label = document.getElementById("label_queue_time_estimate");
|
||||||
|
if (!obj_label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RenderQueue.nb_completed_renders === 0) {
|
||||||
|
obj_label.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nb_avg_ms = RenderQueue.nb_total_render_ms / RenderQueue.nb_completed_renders;
|
||||||
|
let nb_remaining_count = 0;
|
||||||
|
for (let obj_item of RenderQueue.list_items) {
|
||||||
|
if (obj_item.str_status !== "done" && obj_item.str_status !== "skipped") {
|
||||||
|
nb_remaining_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nb_remaining_ms = nb_avg_ms * nb_remaining_count;
|
||||||
|
obj_label.innerHTML = '<i class="mdi mdi-clock-outline me-1"></i>'
|
||||||
|
+ RenderQueue._format_duration(nb_remaining_ms);
|
||||||
|
},
|
||||||
|
|
||||||
|
_format_duration: (nb_ms) => {
|
||||||
|
let nb_total_seconds = Math.ceil(nb_ms / 1000);
|
||||||
|
let nb_days = Math.floor(nb_total_seconds / 86400);
|
||||||
|
nb_total_seconds %= 86400;
|
||||||
|
let nb_hours = Math.floor(nb_total_seconds / 3600);
|
||||||
|
nb_total_seconds %= 3600;
|
||||||
|
let nb_minutes = Math.floor(nb_total_seconds / 60);
|
||||||
|
let nb_seconds = nb_total_seconds % 60;
|
||||||
|
|
||||||
|
let str_result = "";
|
||||||
|
if (nb_days > 0) {
|
||||||
|
str_result += nb_days + "j ";
|
||||||
|
}
|
||||||
|
if (nb_hours > 0 || nb_days > 0) {
|
||||||
|
str_result += nb_hours + "h ";
|
||||||
|
}
|
||||||
|
if (nb_minutes > 0 || nb_hours > 0 || nb_days > 0) {
|
||||||
|
str_result += nb_minutes + "m ";
|
||||||
|
}
|
||||||
|
str_result += nb_seconds + "s";
|
||||||
|
|
||||||
|
return str_result;
|
||||||
|
},
|
||||||
|
};
|
||||||
207
src/renderer/styles/Main.css
Normal file
207
src/renderer/styles/Main.css
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/* ── Global ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-fluid {
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-4 {
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Camera list ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.list-group-item-action:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item.active {
|
||||||
|
background-color: rgba(13, 110, 253, 0.2) !important;
|
||||||
|
border-color: #495057 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preview ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 350px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Console ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.console-output {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-time {
|
||||||
|
color: #6e7681;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-text {
|
||||||
|
color: #c9d1d9;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render queue items ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
#container_render_queue .list-group-item {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container_render_queue .queue-item-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container_render_queue .queue-item-clickable:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Queue time estimate ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.queue-time-estimate {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #8b949e;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text contrast overrides ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-light-emphasis {
|
||||||
|
color: #c9d1d9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-label {
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item {
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
::placeholder {
|
||||||
|
color: #8b949e !important;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form tweaks ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
color: #e1e4e8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:disabled,
|
||||||
|
.form-control[readonly] {
|
||||||
|
color: #c9d1d9 !important;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #495057;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badge ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user