This commit is contained in:
sorlinv
2026-02-20 19:27:27 +01:00
commit b556cce88c
23 changed files with 8182 additions and 0 deletions

View 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
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
config/saves/
blender/
*.log

293
CLAUDE.md Normal file
View 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
View 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 dassistance.
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 linstruction.
---
## ⚙️ 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 dun é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 lassistant IA
LIA dassistance 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()`.**
* Lité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 sapplique **aux humains comme à lIA**.
Le code doit rester :
* **Lisible**
* **Prévisible**
* **Maintenable**
* **Humain**

167
main.js Normal file
View 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

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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();
});

View 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>';
},
};

View 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);
}
},
};

View 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 = "";
},
};

View 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>";
},
};

View 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 = "-";
},
};

View 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;
},
};

View 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;
}