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

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