14 Commits

Author SHA1 Message Date
sorlinv
cbd2f676e2 chore: release v1.6.3 2026-03-05 16:09:31 +01:00
sorlinv
ada5c05aa7 chore: release v1.6.2 2026-03-05 16:00:52 +01:00
sorlinv
eae2c68f29 Update package-lock.json 2026-03-05 15:59:18 +01:00
sorlinv
54d7d41eb6 chore: release v1.6.1 2026-03-05 15:55:54 +01:00
sorlinv
88ad17164c fix nodemailer 2026-03-05 15:55:43 +01:00
sorlinv
40b9e29f80 chore: release v1.6.0 2026-03-05 15:43:45 +01:00
sorlinv
b169e69b24 temps restant + video + download link + left click + show next frame done 2026-03-05 15:43:28 +01:00
sorlinv
9ab59373df Update package-lock.json 2026-03-04 11:38:21 +01:00
sorlinv
02d2e9ed1d chore: release v1.5.0 2026-03-04 09:52:30 +01:00
sorlinv
b1c66f055a better render queue multi 2026-03-04 09:52:17 +01:00
sorlinv
7670707d0d chore: release v1.4.0 2026-02-27 18:02:43 +01:00
sorlinv
bf05c62feb better queue render 2026-02-27 18:01:00 +01:00
sorlinv
de40d2b6af chore: release v1.3.0 2026-02-27 16:39:23 +01:00
sorlinv
19fab8ec65 fix release.sh 2026-02-27 10:36:58 +01:00
26 changed files with 2943 additions and 116 deletions

View File

@@ -21,7 +21,18 @@
"Bash(git commit:*)",
"Bash(git tag:*)",
"Bash(node:*)",
"Bash(chmod +x /home/valentin/Documents/GitHub/multi_render_blender/release.sh)"
"Bash(chmod +x /home/valentin/Documents/GitHub/multi_render_blender/release.sh)",
"Bash(git rm --cached .env)",
"Bash(git status -u)",
"Bash(git push)",
"Bash(git push origin v1.2.0)",
"Bash(python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); [print\\(f''{t[\"\"name\"\"]} -> {t[\"\"id\"\"][:12]}''\\) for t in data]\")",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(f''Release creee: id={d.get\\(\"\"id\"\", \"\"ERREUR\"\"\\)} tag={d.get\\(\"\"tag_name\"\", \"\"?\"\"\\)}''\\); print\\(d.get\\(''message'',''''\\)\\) if ''message'' in d else None\")",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(f'' OK: {d.get\\(\"\"name\"\",\"\"?\"\"\\)} \\({d.get\\(\"\"size\"\",0\\)//1024//1024}MB\\)''\\)\")",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(f''OK: {d.get\\(\"\"name\"\",d.get\\(\"\"message\"\",\"\"?\"\"\\)\\)} size={d.get\\(\"\"size\"\",0\\)//1024//1024}MB''\\)\")",
"Bash(python3 -c \":*)",
"mcp__ide__getDiagnostics",
"Bash(ffmpeg:*)"
]
}
}

161
main.js
View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
const { app, BrowserWindow, ipcMain, dialog, shell, clipboard, nativeImage } = require("electron");
const path = require("path");
const fs = require("fs");
const CameraParser = require("./src/main/CameraParser.js");
@@ -6,12 +6,16 @@ const QueueManager = require("./src/main/QueueManager.js");
const ConfigManager = require("./src/main/ConfigManager.js");
const UpdateManager = require("./src/main/UpdateManager.js");
const PathResolver = require("./src/main/PathResolver.js");
const VideoGenerator = require("./src/main/VideoGenerator.js");
const EmailNotifier = require("./src/main/EmailNotifier.js");
let obj_main_window = null;
let obj_queue_manager = null;
let obj_notification_config = { is_notify_each_image: false, is_notify_all_done: true };
let obj_email_config = { is_enabled: false, str_to: "" };
const STR_NOTIFICATION_CONFIG_FILE = "notification_config.json";
const STR_EMAIL_CONFIG_FILE = "email_config.json";
const _load_notification_config = () => {
let str_config_path = path.join(app.getPath("userData"), STR_NOTIFICATION_CONFIG_FILE);
@@ -34,6 +38,28 @@ const _save_notification_config = () => {
}
};
const _load_email_config = () => {
let str_config_path = path.join(app.getPath("userData"), STR_EMAIL_CONFIG_FILE);
try {
if (fs.existsSync(str_config_path)) {
let str_content = fs.readFileSync(str_config_path, "utf8");
obj_email_config = JSON.parse(str_content);
EmailNotifier.set_config(obj_email_config);
}
} catch (obj_err) {
console.error("Erreur lecture config email :", obj_err.message);
}
};
const _save_email_config = () => {
let str_config_path = path.join(app.getPath("userData"), STR_EMAIL_CONFIG_FILE);
try {
fs.writeFileSync(str_config_path, JSON.stringify(obj_email_config, null, 4), "utf8");
} catch (obj_err) {
console.error("Erreur sauvegarde config email :", obj_err.message);
}
};
const create_window = () => {
obj_main_window = new BrowserWindow({
width: 1400,
@@ -53,13 +79,17 @@ const create_window = () => {
obj_queue_manager = new QueueManager(obj_main_window);
PathResolver.load_saved_path();
PathResolver.load_saved_ffmpeg_path();
_load_notification_config();
_load_email_config();
obj_queue_manager.set_notification_config(obj_notification_config);
obj_queue_manager.set_email_notifier(EmailNotifier);
UpdateManager.init(obj_main_window);
obj_main_window.webContents.on("did-finish-load", () => {
UpdateManager.check_for_updates();
obj_main_window.webContents.send("blender-path-status", PathResolver.get_status());
obj_main_window.webContents.send("ffmpeg-path-status", PathResolver.get_ffmpeg_status());
});
};
@@ -205,6 +235,37 @@ ipcMain.handle("set-blender-path", (event, str_path) => {
return obj_result;
});
ipcMain.handle("get-ffmpeg-path", () => {
return PathResolver.get_ffmpeg_status();
});
ipcMain.handle("set-ffmpeg-path", (event, str_path) => {
let obj_result = PathResolver.set_ffmpeg_path(str_path);
if (obj_result.is_success) {
obj_main_window.webContents.send("ffmpeg-path-status", PathResolver.get_ffmpeg_status());
}
return obj_result;
});
ipcMain.handle("select-ffmpeg-exe", () => {
let str_exe_name = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
let list_filters = process.platform === "win32"
? [{ name: "FFmpeg", extensions: ["exe"] }]
: [{ name: "FFmpeg", extensions: ["*"] }];
return dialog.showOpenDialog(obj_main_window, {
title: "Selectionner l'executable FFmpeg",
filters: list_filters,
properties: ["openFile"],
})
.then((obj_result) => {
if (obj_result.canceled || obj_result.filePaths.length === 0) {
return null;
}
return obj_result.filePaths[0];
});
});
ipcMain.handle("select-blender-exe", () => {
let str_exe_name = process.platform === "win32" ? "blender.exe" : "blender";
let list_filters = process.platform === "win32"
@@ -232,6 +293,15 @@ ipcMain.handle("apply-update", (event, str_tag_name) => {
return UpdateManager.download_and_apply(str_tag_name);
});
ipcMain.handle("generate-preview-video", (event, obj_params) => {
let fn_on_log = (str_msg) => {
if (obj_main_window && !obj_main_window.isDestroyed()) {
obj_main_window.webContents.send("log", "[ffmpeg] " + str_msg);
}
};
return VideoGenerator.generate(obj_params, fn_on_log);
});
ipcMain.handle("select-output-folder", () => {
return dialog.showOpenDialog(obj_main_window, {
title: "Selectionner le dossier de sortie",
@@ -245,6 +315,78 @@ ipcMain.handle("select-output-folder", () => {
});
});
// ── File actions (context menu) ───────────────────────────────
ipcMain.handle("show-item-in-folder", (event, str_path) => {
shell.showItemInFolder(str_path);
return { is_success: true };
});
ipcMain.handle("open-file-default", (event, str_path) => {
return shell.openPath(str_path)
.then((str_error) => {
if (str_error) {
return { is_success: false, str_error: str_error };
}
return { is_success: true };
});
});
ipcMain.handle("delete-rendered-file", (event, str_path) => {
try {
if (fs.existsSync(str_path)) {
fs.unlinkSync(str_path);
return { is_success: true };
}
return { is_success: false, str_error: "Fichier introuvable" };
} catch (obj_err) {
return { is_success: false, str_error: obj_err.message };
}
});
ipcMain.handle("delete-rendered-files", (event, list_paths) => {
let nb_deleted = 0;
let list_errors = [];
for (let str_p of list_paths) {
try {
if (fs.existsSync(str_p)) {
fs.unlinkSync(str_p);
nb_deleted++;
}
} catch (obj_err) {
list_errors.push(str_p + ": " + obj_err.message);
}
}
return { is_success: true, nb_deleted: nb_deleted, list_errors: list_errors };
});
ipcMain.handle("get-file-info", (event, str_path) => {
try {
let obj_stats = fs.statSync(str_path);
return {
is_success: true,
nb_size: obj_stats.size,
str_modified: obj_stats.mtime.toISOString(),
str_created: obj_stats.birthtime.toISOString(),
};
} catch (obj_err) {
return { is_success: false, str_error: obj_err.message };
}
});
ipcMain.handle("copy-image-to-clipboard", (event, str_path) => {
try {
let obj_image = nativeImage.createFromPath(str_path);
if (obj_image.isEmpty()) {
return { is_success: false, str_error: "Image vide ou format non supporte" };
}
clipboard.writeImage(obj_image);
return { is_success: true };
} catch (obj_err) {
return { is_success: false, str_error: obj_err.message };
}
});
// ── Notification Config ───────────────────────────────────────
ipcMain.handle("get-notification-config", () => {
@@ -257,3 +399,20 @@ ipcMain.handle("set-notification-config", (event, obj_config) => {
obj_queue_manager.set_notification_config(obj_notification_config);
return { is_success: true };
});
// ── Email Config ──────────────────────────────────────────────
ipcMain.handle("get-email-config", () => {
return obj_email_config;
});
ipcMain.handle("set-email-config", (event, obj_config) => {
obj_email_config = obj_config;
_save_email_config();
EmailNotifier.set_config(obj_email_config);
return { is_success: true };
});
ipcMain.handle("test-email", (event, obj_config) => {
return EmailNotifier.test(obj_config);
});

16
package-lock.json generated
View File

@@ -1,13 +1,16 @@
{
"name": "multi-render-blender",
"version": "1.2.0",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-render-blender",
"version": "1.2.0",
"version": "1.6.1",
"license": "MIT",
"dependencies": {
"nodemailer": "^8.0.1"
},
"devDependencies": {
"electron": "^34.0.0",
"electron-builder": "^25.1.8"
@@ -3958,6 +3961,15 @@
"node": ">=10"
}
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "multi-render-blender",
"version": "1.2.0",
"version": "1.6.3",
"description": "Application Electron pour piloter des rendus Blender multi-cameras",
"main": "main.js",
"scripts": {
@@ -11,8 +11,8 @@
"author": "",
"license": "MIT",
"devDependencies": {
"electron": "^34.0.0",
"electron-builder": "^25.1.8"
"electron": "^40.7.0",
"electron-builder": "^26.8.1"
},
"build": {
"appId": "com.multirender.blender",
@@ -22,7 +22,8 @@
"main.js",
"preload.js",
"src/**/*",
"version.json"
"version.json",
"node_modules/nodemailer/**/*"
],
"linux": {
"target": "dir"
@@ -31,5 +32,8 @@
"target": "dir",
"signAndEditExecutable": false
}
},
"dependencies": {
"nodemailer": "^8.0.1"
}
}

View File

@@ -41,6 +41,34 @@ contextBridge.exposeInMainWorld("api", {
return ipcRenderer.invoke("read-image", str_path);
},
show_item_in_folder: (str_path) => {
return ipcRenderer.invoke("show-item-in-folder", str_path);
},
open_file_default: (str_path) => {
return ipcRenderer.invoke("open-file-default", str_path);
},
delete_rendered_file: (str_path) => {
return ipcRenderer.invoke("delete-rendered-file", str_path);
},
delete_rendered_files: (list_paths) => {
return ipcRenderer.invoke("delete-rendered-files", list_paths);
},
get_file_info: (str_path) => {
return ipcRenderer.invoke("get-file-info", str_path);
},
copy_image_to_clipboard: (str_path) => {
return ipcRenderer.invoke("copy-image-to-clipboard", str_path);
},
generate_preview_video: (obj_params) => {
return ipcRenderer.invoke("generate-preview-video", obj_params);
},
on_render_progress: (fn_callback) => {
ipcRenderer.on("render-progress", (event, obj_data) => {
fn_callback(obj_data);
@@ -89,6 +117,24 @@ contextBridge.exposeInMainWorld("api", {
});
},
get_ffmpeg_path: () => {
return ipcRenderer.invoke("get-ffmpeg-path");
},
set_ffmpeg_path: (str_path) => {
return ipcRenderer.invoke("set-ffmpeg-path", str_path);
},
select_ffmpeg_exe: () => {
return ipcRenderer.invoke("select-ffmpeg-exe");
},
on_ffmpeg_path_status: (fn_callback) => {
ipcRenderer.on("ffmpeg-path-status", (event, obj_data) => {
fn_callback(obj_data);
});
},
get_notification_config: () => {
return ipcRenderer.invoke("get-notification-config");
},
@@ -97,6 +143,18 @@ contextBridge.exposeInMainWorld("api", {
return ipcRenderer.invoke("set-notification-config", obj_config);
},
get_email_config: () => {
return ipcRenderer.invoke("get-email-config");
},
set_email_config: (obj_config) => {
return ipcRenderer.invoke("set-email-config", obj_config);
},
test_email: (obj_config) => {
return ipcRenderer.invoke("test-email", obj_config);
},
check_for_updates: () => {
return ipcRenderer.invoke("check-for-updates");
},

View File

@@ -3,6 +3,18 @@ set -euo pipefail
cd "$(dirname "$0")"
############################################
# Options CLI
############################################
BUMP_ARG=""
while getopts "i:" opt; do
case "$opt" in
i) BUMP_ARG="$OPTARG" ;;
*) echo "Usage: $0 [-i patch|minor|major]"; exit 1 ;;
esac
done
############################################
# Chargement du token
############################################
@@ -30,6 +42,14 @@ CURRENT_VERSION=$(node -p "require('./package.json').version")
echo "Version actuelle: v$CURRENT_VERSION"
echo ""
if [ -n "$BUMP_ARG" ]; then
case "$BUMP_ARG" in
patch) npm version patch --no-git-tag-version ;;
minor) npm version minor --no-git-tag-version ;;
major) npm version major --no-git-tag-version ;;
*) echo "Erreur: -i accepte patch, minor ou major"; exit 1 ;;
esac
else
echo "Comment incrementer la version ?"
echo " 1) patch"
echo " 2) minor"
@@ -45,6 +65,7 @@ case "$BUMP_CHOICE" in
4) echo "Version inchangee." ;;
*) echo "Choix invalide"; exit 1 ;;
esac
fi
VERSION=$(node -p "require('./package.json').version")
TAG="v$VERSION"
@@ -146,8 +167,7 @@ for FILE in dist/${PRODUCT_NAME}-${VERSION}-*.zip; do
echo "Upload de $FILENAME..."
curl -s -X POST \
"${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}&token=${GITEA_TOKEN}" \
-F "attachment=@${FILE}"
done

244
src/main/BlenderDaemon.js Normal file
View File

@@ -0,0 +1,244 @@
const { spawn } = require("child_process");
const path = require("path");
const PathResolver = require("./PathResolver.js");
const STR_MARKER_READY = "DAEMON_READY";
const STR_MARKER_DONE = "RENDER_DONE:";
const STR_MARKER_ERROR = "RENDER_ERROR:";
const BlenderDaemon = {
_obj_process: null,
_is_running: false,
_str_blend_path: null,
_fn_resolve_ready: null,
_fn_reject_ready: null,
_fn_resolve_render: null,
_fn_reject_render: null,
_str_stdout_buffer: "",
_fn_on_stdout: null,
start: (str_blend_path) => {
if (BlenderDaemon._is_running) {
return Promise.resolve();
}
let str_script_path = path.join(__dirname, "..", "python", "render_daemon.py");
return new Promise((resolve, reject) => {
BlenderDaemon._fn_resolve_ready = resolve;
BlenderDaemon._fn_reject_ready = reject;
BlenderDaemon._str_blend_path = str_blend_path;
BlenderDaemon._str_stdout_buffer = "";
let obj_process = spawn(PathResolver.get_blender_path(), [
"-b", str_blend_path,
"--python", str_script_path,
]);
BlenderDaemon._obj_process = obj_process;
obj_process.stdout.on("data", (obj_data) => {
BlenderDaemon._on_stdout_data(obj_data.toString());
});
obj_process.stderr.on("data", (obj_data) => {
if (BlenderDaemon._fn_on_stdout) {
let str_text = obj_data.toString().trim();
if (str_text) {
BlenderDaemon._fn_on_stdout(str_text);
}
}
});
obj_process.on("close", (nb_code) => {
BlenderDaemon._is_running = false;
BlenderDaemon._obj_process = null;
if (BlenderDaemon._fn_resolve_ready) {
let fn_reject = BlenderDaemon._fn_reject_ready;
BlenderDaemon._fn_resolve_ready = null;
BlenderDaemon._fn_reject_ready = null;
fn_reject(new Error("Blender a quitte avant DAEMON_READY (code " + nb_code + ")"));
}
if (BlenderDaemon._fn_resolve_render) {
let fn_reject = BlenderDaemon._fn_reject_render;
BlenderDaemon._fn_resolve_render = null;
BlenderDaemon._fn_reject_render = null;
fn_reject({
str_message: "Le daemon Blender a crash (code " + nb_code + ")",
});
}
});
obj_process.on("error", (obj_err) => {
BlenderDaemon._is_running = false;
BlenderDaemon._obj_process = null;
if (BlenderDaemon._fn_resolve_ready) {
let fn_reject = BlenderDaemon._fn_reject_ready;
BlenderDaemon._fn_resolve_ready = null;
BlenderDaemon._fn_reject_ready = null;
fn_reject(new Error("Impossible de lancer Blender : " + obj_err.message));
}
if (BlenderDaemon._fn_resolve_render) {
let fn_reject = BlenderDaemon._fn_reject_render;
BlenderDaemon._fn_resolve_render = null;
BlenderDaemon._fn_reject_render = null;
fn_reject({
str_message: "Erreur process Blender : " + obj_err.message,
});
}
});
});
},
render_frame: (obj_params) => {
if (!BlenderDaemon._is_running || !BlenderDaemon._obj_process) {
return Promise.reject({
str_message: "Le daemon Blender n'est pas demarre.",
});
}
return new Promise((resolve, reject) => {
BlenderDaemon._fn_resolve_render = resolve;
BlenderDaemon._fn_reject_render = reject;
BlenderDaemon._fn_on_stdout = obj_params.fn_on_stdout || null;
let obj_cmd = {
str_cmd: "render",
str_camera: obj_params.str_camera_name,
nb_frame: obj_params.nb_frame,
nb_resolution_x: obj_params.nb_resolution_x,
nb_resolution_y: obj_params.nb_resolution_y,
str_format: obj_params.str_format,
str_output_path: obj_params.str_output_path,
};
if (obj_params.list_collections) {
obj_cmd.list_collections = obj_params.list_collections;
}
if (obj_params.obj_render_settings) {
obj_cmd.obj_render_settings = obj_params.obj_render_settings;
}
let str_json = JSON.stringify(obj_cmd) + "\n";
BlenderDaemon._obj_process.stdin.write(str_json);
});
},
stop: () => {
if (!BlenderDaemon._obj_process) {
BlenderDaemon._is_running = false;
return;
}
let obj_process_ref = BlenderDaemon._obj_process;
// Kill immediatement (SIGKILL) car pendant un rendu,
// le daemon Python est bloque sur bpy.ops.render.render()
// et ne lit pas stdin — le "quit" ne serait traite qu'apres le rendu.
try {
if (obj_process_ref && !obj_process_ref.killed) {
obj_process_ref.kill("SIGKILL");
}
} catch (obj_err) {
// process deja mort
}
BlenderDaemon._is_running = false;
BlenderDaemon._obj_process = null;
BlenderDaemon._fn_resolve_ready = null;
BlenderDaemon._fn_reject_ready = null;
BlenderDaemon._fn_resolve_render = null;
BlenderDaemon._fn_reject_render = null;
BlenderDaemon._fn_on_stdout = null;
BlenderDaemon._str_stdout_buffer = "";
},
is_running: () => {
return BlenderDaemon._is_running;
},
_on_stdout_data: (str_data) => {
BlenderDaemon._str_stdout_buffer += str_data;
let list_lines = BlenderDaemon._str_stdout_buffer.split(/\r\n|\n|\r/);
BlenderDaemon._str_stdout_buffer = list_lines[list_lines.length - 1];
for (let nb_i = 0; nb_i < list_lines.length - 1; nb_i++) {
let str_line = list_lines[nb_i];
BlenderDaemon._handle_stdout_line(str_line);
}
},
_handle_stdout_line: (str_line) => {
if (str_line === STR_MARKER_READY) {
BlenderDaemon._is_running = true;
if (BlenderDaemon._fn_resolve_ready) {
let fn_resolve = BlenderDaemon._fn_resolve_ready;
BlenderDaemon._fn_resolve_ready = null;
BlenderDaemon._fn_reject_ready = null;
fn_resolve();
}
return;
}
if (str_line.indexOf(STR_MARKER_DONE) === 0) {
let str_json = str_line.substring(STR_MARKER_DONE.length);
try {
let obj_result = JSON.parse(str_json);
if (BlenderDaemon._fn_resolve_render) {
let fn_resolve = BlenderDaemon._fn_resolve_render;
BlenderDaemon._fn_resolve_render = null;
BlenderDaemon._fn_reject_render = null;
BlenderDaemon._fn_on_stdout = null;
fn_resolve({
str_rendered_file: obj_result.str_file,
});
}
} catch (obj_err) {
// JSON parse error sur RENDER_DONE — traiter comme erreur
if (BlenderDaemon._fn_reject_render) {
let fn_reject = BlenderDaemon._fn_reject_render;
BlenderDaemon._fn_resolve_render = null;
BlenderDaemon._fn_reject_render = null;
BlenderDaemon._fn_on_stdout = null;
fn_reject({ str_message: "JSON invalide dans RENDER_DONE" });
}
}
return;
}
if (str_line.indexOf(STR_MARKER_ERROR) === 0) {
let str_json = str_line.substring(STR_MARKER_ERROR.length);
try {
let obj_result = JSON.parse(str_json);
if (BlenderDaemon._fn_reject_render) {
let fn_reject = BlenderDaemon._fn_reject_render;
BlenderDaemon._fn_resolve_render = null;
BlenderDaemon._fn_reject_render = null;
BlenderDaemon._fn_on_stdout = null;
fn_reject({ str_message: obj_result.str_error || "Erreur rendu" });
}
} catch (obj_err) {
if (BlenderDaemon._fn_reject_render) {
let fn_reject = BlenderDaemon._fn_reject_render;
BlenderDaemon._fn_resolve_render = null;
BlenderDaemon._fn_reject_render = null;
BlenderDaemon._fn_on_stdout = null;
fn_reject({ str_message: str_json });
}
}
return;
}
// Ligne standard Blender (progression, etc.) — forward au callback
if (BlenderDaemon._fn_on_stdout && str_line.trim()) {
BlenderDaemon._fn_on_stdout(str_line);
}
},
};
module.exports = BlenderDaemon;

View File

@@ -13,13 +13,56 @@ const BlenderProcess = {
let str_safe_name = str_camera_name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
let str_python_expr = [
let list_python_lines = [
"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(";");
];
if (obj_params.obj_render_settings) {
let obj_rs = obj_params.obj_render_settings;
if (obj_rs.str_engine) {
list_python_lines.push("scene.render.engine='" + obj_rs.str_engine + "'");
}
if (obj_rs.nb_resolution_percentage !== undefined) {
list_python_lines.push("scene.render.resolution_percentage=" + obj_rs.nb_resolution_percentage);
}
if (obj_rs.is_film_transparent !== undefined) {
list_python_lines.push("scene.render.film_transparent=" + (obj_rs.is_film_transparent ? "True" : "False"));
}
if (obj_rs.str_engine === "CYCLES") {
if (obj_rs.nb_samples !== undefined) {
list_python_lines.push("scene.cycles.samples=" + obj_rs.nb_samples);
}
if (obj_rs.str_device) {
list_python_lines.push("scene.cycles.device='" + obj_rs.str_device + "'");
}
if (obj_rs.is_denoise !== undefined) {
list_python_lines.push("scene.cycles.use_denoising=" + (obj_rs.is_denoise ? "True" : "False"));
}
} else if (obj_rs.str_engine === "BLENDER_EEVEE" || obj_rs.str_engine === "BLENDER_EEVEE_NEXT") {
if (obj_rs.nb_samples !== undefined) {
list_python_lines.push("try:\n scene.eevee.taa_render_samples=" + obj_rs.nb_samples + "\nexcept:\n try:\n scene.eevee.samples=" + obj_rs.nb_samples + "\n except:pass");
}
}
}
let str_python_expr = list_python_lines.join(";");
if (obj_params.list_collections && obj_params.list_collections.length > 0) {
str_python_expr += "\ndef _slc(lc,n,v):"
+ "\n for c in lc.children:"
+ "\n if c.name==n:c.exclude=v;return"
+ "\n _slc(c,n,v)\n";
for (let obj_col of obj_params.list_collections) {
let str_col_safe = obj_col.str_name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
let str_val = obj_col.is_hide_render ? "True" : "False";
str_python_expr += "bpy.data.collections['" + str_col_safe + "'].hide_render=" + str_val
+ ";_slc(bpy.context.view_layer.layer_collection,'" + str_col_safe + "'," + str_val + ")\n";
}
}
let list_args = [
"-b", str_blend_path,

View File

@@ -4,12 +4,25 @@ const PathResolver = require("./PathResolver.js");
const STR_SCENE_MARKER = "SCENE_JSON:";
const STR_PYTHON_EXPR = [
"import bpy, json, sys;",
"s=bpy.context.scene;",
"cams=[o.name for o in bpy.data.objects if o.type=='CAMERA'];",
"info={'list_cameras':cams,'obj_scene':{'nb_resolution_x':s.render.resolution_x,'nb_resolution_y':s.render.resolution_y,'nb_frame_start':s.frame_start,'nb_frame_end':s.frame_end,'nb_frame_step':s.frame_step}};",
"sys.stdout.write('SCENE_JSON:' + json.dumps(info) + '\\n');",
"sys.stdout.flush()",
"import bpy, json, sys",
"\ns=bpy.context.scene",
"\ncams=[o.name for o in bpy.data.objects if o.type=='CAMERA']",
"\ncols=[]",
"\ndef _wlc(lc,d):",
"\n for c in lc.children:",
"\n cols.append({'str_name':c.name,'nb_depth':d,'is_hide_render':bpy.data.collections[c.name].hide_render,'is_exclude':c.exclude})",
"\n _wlc(c,d+1)",
"\n_wlc(bpy.context.view_layer.layer_collection,0)",
"\nrs={'str_engine':s.render.engine,'nb_resolution_percentage':s.render.resolution_percentage,'is_film_transparent':s.render.film_transparent}",
"\ntry:\n rs['nb_cycles_samples']=s.cycles.samples;rs['str_cycles_device']=s.cycles.device;rs['is_cycles_denoise']=s.cycles.use_denoising",
"\nexcept:pass",
"\ntry:\n rs['nb_eevee_samples']=s.eevee.taa_render_samples",
"\nexcept:",
"\n try:\n rs['nb_eevee_samples']=s.eevee.samples",
"\n except:pass",
"\ninfo={'list_cameras':cams,'obj_scene':{'nb_resolution_x':s.render.resolution_x,'nb_resolution_y':s.render.resolution_y,'nb_frame_start':s.frame_start,'nb_frame_end':s.frame_end,'nb_frame_step':s.frame_step},'obj_render_settings':rs,'list_collections':cols}",
"\nsys.stdout.write('SCENE_JSON:' + json.dumps(info) + '\\n')",
"\nsys.stdout.flush()",
].join("");
const CameraParser = {

107
src/main/EmailNotifier.js Normal file
View File

@@ -0,0 +1,107 @@
const nodemailer = require("nodemailer");
const _SMTP = {
str_host: "ssl0.ovh.net",
nb_port: 465,
str_user: "contact@sorlinv.fr",
_k: [0x1a, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x11, 0x22, 0x33],
_d: [0x50, 0x13, 0x7f, 0x7e, 0x6c, 0x05, 0x48, 0x72, 0x11, 0x01],
};
const _decode = () => {
let list_r = [];
for (let nb_i = 0; nb_i < _SMTP._d.length; nb_i++) {
list_r.push(String.fromCharCode(_SMTP._d[nb_i] ^ _SMTP._k[nb_i]));
}
return list_r.join("");
};
const EmailNotifier = {
_obj_config: null,
_obj_transporter: null,
set_config: (obj_config) => {
EmailNotifier._obj_config = obj_config;
EmailNotifier._obj_transporter = null;
if (!obj_config || !obj_config.is_enabled || !obj_config.str_to) {
return;
}
EmailNotifier._obj_transporter = nodemailer.createTransport({
host: _SMTP.str_host,
port: _SMTP.nb_port,
secure: true,
auth: {
user: _SMTP.str_user,
pass: _decode(),
},
tls: {
rejectUnauthorized: false,
},
});
},
send: (str_subject, str_body) => {
if (!EmailNotifier._obj_transporter || !EmailNotifier._obj_config || !EmailNotifier._obj_config.is_enabled) {
return Promise.resolve({ is_success: false, str_error: "Email non configure" });
}
let obj_mail = {
from: _SMTP.str_user,
to: EmailNotifier._obj_config.str_to,
subject: str_subject,
text: str_body,
};
return new Promise((resolve) => {
EmailNotifier._obj_transporter.sendMail(obj_mail, (obj_err) => {
if (obj_err) {
resolve({ is_success: false, str_error: obj_err.message });
} else {
resolve({ is_success: true });
}
});
});
},
test: (obj_config) => {
let obj_transporter = nodemailer.createTransport({
host: _SMTP.str_host,
port: _SMTP.nb_port,
secure: true,
auth: {
user: _SMTP.str_user,
pass: _decode(),
},
tls: {
rejectUnauthorized: false,
},
});
let str_to = obj_config.str_to || "";
if (!str_to) {
return Promise.resolve({ is_success: false, str_error: "Aucun destinataire" });
}
let obj_mail = {
from: _SMTP.str_user,
to: str_to,
subject: "[Multi Render Blender] Test",
text: "Ceci est un email de test. La configuration fonctionne correctement.",
};
return new Promise((resolve) => {
obj_transporter.sendMail(obj_mail, (obj_err) => {
obj_transporter.close();
if (obj_err) {
resolve({ is_success: false, str_error: obj_err.message });
} else {
resolve({ is_success: true });
}
});
});
},
};
module.exports = EmailNotifier;

View File

@@ -5,10 +5,13 @@ const { execFileSync } = require("child_process");
const STR_EXE_NAME = process.platform === "win32" ? "blender.exe" : "blender";
const STR_CONFIG_FILE = "blender_path.json";
const STR_FFMPEG_CONFIG_FILE = "ffmpeg_path.json";
const PathResolver = {
_str_blender_path: null,
_is_found: false,
_str_ffmpeg_path: null,
_is_ffmpeg_found: false,
load_saved_path: () => {
let str_config_path = PathResolver._get_config_path();
@@ -85,12 +88,112 @@ const PathResolver = {
};
},
// ── FFmpeg ───────────────────────────────────────────────
load_saved_ffmpeg_path: () => {
let str_config_path = PathResolver._get_ffmpeg_config_path();
try {
if (fs.existsSync(str_config_path)) {
let str_content = fs.readFileSync(str_config_path, "utf8");
let obj_data = JSON.parse(str_content);
if (obj_data.str_path && fs.existsSync(obj_data.str_path)) {
PathResolver._str_ffmpeg_path = obj_data.str_path;
PathResolver._is_ffmpeg_found = true;
return;
}
}
} catch (obj_err) {
console.error("PathResolver: impossible de lire la config ffmpeg :", obj_err.message);
}
let str_detected = PathResolver._auto_detect_ffmpeg();
if (str_detected) {
PathResolver._str_ffmpeg_path = str_detected;
PathResolver._is_ffmpeg_found = true;
} else {
PathResolver._str_ffmpeg_path = "ffmpeg";
PathResolver._is_ffmpeg_found = false;
}
},
get_ffmpeg_path: () => {
if (!PathResolver._str_ffmpeg_path) {
PathResolver.load_saved_ffmpeg_path();
}
return PathResolver._str_ffmpeg_path;
},
set_ffmpeg_path: (str_path) => {
if (!str_path || !fs.existsSync(str_path)) {
return { is_success: false, str_error: "Fichier introuvable : " + str_path };
}
PathResolver._str_ffmpeg_path = str_path;
PathResolver._is_ffmpeg_found = true;
let str_config_path = PathResolver._get_ffmpeg_config_path();
try {
let str_dir = path.dirname(str_config_path);
if (!fs.existsSync(str_dir)) {
fs.mkdirSync(str_dir, { recursive: true });
}
fs.writeFileSync(str_config_path, JSON.stringify({ str_path: str_path }, null, 4), "utf8");
} catch (obj_err) {
console.error("PathResolver: impossible de sauvegarder ffmpeg :", obj_err.message);
}
return { is_success: true, str_path: str_path };
},
get_ffmpeg_status: () => {
return {
str_path: PathResolver._str_ffmpeg_path || "ffmpeg",
is_found: PathResolver._is_ffmpeg_found,
};
},
_auto_detect_ffmpeg: () => {
let str_cmd = process.platform === "win32" ? "where" : "which";
let str_exe = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
try {
let str_result = execFileSync(str_cmd, [str_exe], { encoding: "utf8", timeout: 5000 }).trim();
let str_first = str_result.split("\n")[0].trim();
if (str_first && fs.existsSync(str_first)) {
return str_first;
}
} catch (obj_err) {
// not in PATH
}
if (process.platform !== "win32") {
let LIST_PATHS = [
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/snap/bin/ffmpeg",
];
for (let str_p of LIST_PATHS) {
if (fs.existsSync(str_p)) {
return str_p;
}
}
}
return null;
},
// ── Private ──────────────────────────────────────────────
_get_config_path: () => {
return path.join(app.getPath("userData"), STR_CONFIG_FILE);
},
_get_ffmpeg_config_path: () => {
return path.join(app.getPath("userData"), STR_FFMPEG_CONFIG_FILE);
},
_auto_detect_linux: () => {
let LIST_PATHS = [
"/usr/bin/blender",

View File

@@ -1,11 +1,14 @@
const BlenderProcess = require("./BlenderProcess.js");
const BlenderDaemon = require("./BlenderDaemon.js");
const path = require("path");
const fs = require("fs");
const os = require("os");
const { Notification } = require("electron");
const STR_STATUS_IDLE = "idle";
const STR_STATUS_RUNNING = "running";
const STR_STATUS_PAUSED = "paused";
const NB_PLACEHOLDER_MAX_SIZE = 512;
class QueueManager {
constructor(obj_window) {
@@ -17,12 +20,21 @@ class QueueManager {
this.nb_last_render_ms = 0;
this.str_last_image_path = null;
this.obj_notification_config = { is_notify_each_image: false, is_notify_all_done: true };
this.str_hostname = os.hostname();
this.nb_total_render_ms = 0;
this.nb_completed_renders = 0;
this.map_remote_avgs = {};
this.obj_email_notifier = null;
}
set_notification_config(obj_config) {
this.obj_notification_config = obj_config || { is_notify_each_image: false, is_notify_all_done: true };
}
set_email_notifier(obj_notifier) {
this.obj_email_notifier = obj_notifier;
}
start(obj_config) {
if (this.str_status === STR_STATUS_PAUSED) {
this.str_status = STR_STATUS_RUNNING;
@@ -36,11 +48,31 @@ class QueueManager {
this.str_status = STR_STATUS_RUNNING;
this.nb_last_render_ms = 0;
this.str_last_image_path = null;
this.nb_total_render_ms = 0;
this.nb_completed_renders = 0;
this.map_remote_avgs = {};
this.str_overwrite_mode = obj_config.str_overwrite_mode || "overwrite";
this.list_collections = obj_config.list_collections || [];
this.obj_render_settings = obj_config.obj_render_settings || null;
if (this.str_overwrite_mode === "skip") {
this._prescan_existing();
}
this._send_log("File de rendu construite : " + this.list_queue.length + " elements.");
this._send_progress();
this._send_log("Demarrage du daemon Blender...");
BlenderDaemon.start(obj_config.str_blend_file)
.then(() => {
this._send_log("Daemon Blender pret.");
this._process_next();
})
.catch((obj_err) => {
this._send_log("ERREUR demarrage daemon : " + (obj_err.message || String(obj_err)));
this.str_status = STR_STATUS_IDLE;
this._send_progress();
});
return Promise.resolve({ is_success: true, nb_total: this.list_queue.length });
}
@@ -55,17 +87,33 @@ class QueueManager {
}
stop() {
let str_file_to_delete = null;
if (this.nb_current_index < this.list_queue.length) {
let obj_item = this.list_queue[this.nb_current_index];
if (obj_item.str_status === "rendering") {
obj_item.str_status = "stopped";
str_file_to_delete = obj_item.str_expected_file;
}
}
this.str_status = STR_STATUS_IDLE;
BlenderDaemon.stop();
if (this.obj_current_process) {
this.obj_current_process.kill("SIGTERM");
this.obj_current_process = null;
}
if (this.nb_current_index < this.list_queue.length) {
let obj_item = this.list_queue[this.nb_current_index];
if (obj_item.str_status === "rendering") {
obj_item.str_status = "stopped";
if (str_file_to_delete) {
try {
if (fs.existsSync(str_file_to_delete)) {
fs.unlinkSync(str_file_to_delete);
this._send_log("Fichier partiel supprime : " + path.basename(str_file_to_delete));
}
} catch (obj_del_err) {
this._send_log("Erreur suppression fichier partiel : " + obj_del_err.message);
}
}
@@ -185,15 +233,62 @@ class QueueManager {
};
}
_prescan_existing() {
let nb_skipped = 0;
let nb_remote = 0;
for (let obj_item of this.list_queue) {
try {
if (!fs.existsSync(obj_item.str_expected_file)) {
continue;
}
let nb_size = fs.statSync(obj_item.str_expected_file).size;
if (nb_size === 0) {
continue;
}
if (nb_size > NB_PLACEHOLDER_MAX_SIZE) {
obj_item.str_status = "skipped";
try {
obj_item.str_done_date = fs.statSync(obj_item.str_expected_file).mtime.toISOString();
} catch (obj_date_err) {
// ignore
}
nb_skipped++;
} else {
obj_item.str_status = "rendering_remote";
this._read_placeholder(obj_item);
nb_remote++;
}
} catch (obj_err) {
// Fichier inaccessible, on ignore
}
}
if (nb_skipped > 0 || nb_remote > 0) {
this._send_log("Pre-scan : " + nb_skipped + " fichier(s) existant(s), " + nb_remote + " en cours sur d'autres machines.");
}
}
_process_next() {
if (this.str_status !== STR_STATUS_RUNNING) {
return;
}
// Batch skip : boucle iterative pour eviter un stack overflow recursif
this._check_remote_completions();
// Batch skip : sauter les items deja marques par le prescan ou nouvellement detectes
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];
// Item deja marque par le prescan
if (obj_check.str_status === "skipped" || obj_check.str_status === "rendering_remote") {
this.nb_current_index++;
nb_skip_count++;
continue;
}
// Verification fichier pour les items non pre-scannes (apparus entre-temps)
let is_exists = false;
let nb_size = 0;
try {
@@ -209,10 +304,20 @@ class QueueManager {
break;
}
if (nb_size === 0) {
this._send_log("Placeholder vide detecte, re-rendu : " + obj_check.str_camera_name + " F" + obj_check.nb_frame);
break;
}
if (nb_size > NB_PLACEHOLDER_MAX_SIZE) {
obj_check.str_status = "skipped";
try {
obj_check.str_done_date = fs.statSync(obj_check.str_expected_file).mtime.toISOString();
} catch (obj_date_err) {
// ignore
}
} else {
obj_check.str_status = "rendering_remote";
this._read_placeholder(obj_check);
}
this.nb_current_index++;
nb_skip_count++;
}
@@ -224,6 +329,7 @@ class QueueManager {
if (this.nb_current_index >= this.list_queue.length) {
this.str_status = STR_STATUS_IDLE;
BlenderDaemon.stop();
this._send_log("Tous les rendus sont termines !");
this._send_event("render-complete", { is_all_done: true });
if (this.obj_notification_config.is_notify_all_done) {
@@ -239,13 +345,26 @@ class QueueManager {
try {
if (fs.existsSync(obj_item.str_expected_file)) {
let nb_recheck_size = fs.statSync(obj_item.str_expected_file).size;
if (nb_recheck_size > 0) {
if (nb_recheck_size > NB_PLACEHOLDER_MAX_SIZE) {
obj_item.str_status = "skipped";
try {
obj_item.str_done_date = fs.statSync(obj_item.str_expected_file).mtime.toISOString();
} catch (obj_d_err) {
// ignore
}
this._send_log("Skip : " + obj_item.str_camera_name + " F" + obj_item.nb_frame + " (existant)");
this.nb_current_index++;
this._send_progress();
this._process_next();
return;
} else if (nb_recheck_size > 0) {
obj_item.str_status = "rendering_remote";
this._read_placeholder(obj_item);
this._send_log("Skip : " + obj_item.str_camera_name + " F" + obj_item.nb_frame + " (en cours par " + (obj_item.str_remote_hostname || "?") + ")");
this.nb_current_index++;
this._send_progress();
this._process_next();
return;
}
}
} catch (obj_recheck_err) {
@@ -261,7 +380,7 @@ class QueueManager {
if (!fs.existsSync(str_dir)) {
fs.mkdirSync(str_dir, { recursive: true });
}
fs.writeFileSync(obj_item.str_expected_file, "");
fs.writeFileSync(obj_item.str_expected_file, this._get_placeholder_content());
} catch (obj_file_err) {
this._send_log("ERREUR creation placeholder : " + obj_file_err.message);
}
@@ -272,20 +391,18 @@ class QueueManager {
let nb_start = Date.now();
BlenderProcess.render_frame({
str_blend_path: obj_item.str_blend_path,
BlenderDaemon.render_frame({
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,
str_output_path: obj_item.str_expected_file,
list_collections: this.list_collections,
obj_render_settings: this.obj_render_settings,
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;
@@ -296,6 +413,9 @@ class QueueManager {
obj_item.str_status = "done";
this.nb_last_render_ms = Date.now() - nb_start;
this.nb_total_render_ms += this.nb_last_render_ms;
this.nb_completed_renders++;
obj_item.str_done_date = new Date().toISOString();
let str_image = obj_result.str_rendered_file || obj_item.str_expected_file;
this.str_last_image_path = str_image;
@@ -316,7 +436,7 @@ class QueueManager {
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) {
if (obj_stats.size <= NB_PLACEHOLDER_MAX_SIZE) {
fs.unlinkSync(obj_item.str_expected_file);
}
} catch (obj_cleanup_err) {
@@ -342,7 +462,33 @@ class QueueManager {
this.nb_current_index++;
this._send_progress();
let is_gpu_error = str_err_msg.indexOf("CUDA") !== -1
|| str_err_msg.indexOf("GPU memory") !== -1
|| str_err_msg.indexOf("out of memory") !== -1
|| str_err_msg.indexOf("OpenCL") !== -1
|| str_err_msg.indexOf("HIP") !== -1;
if (!BlenderDaemon.is_running() || is_gpu_error) {
if (is_gpu_error) {
this._send_log("Erreur GPU detectee, redemarrage du daemon pour contexte CUDA neuf...");
BlenderDaemon.stop();
} else {
this._send_log("Daemon crash detecte, redemarrage...");
}
BlenderDaemon.start(obj_item.str_blend_path)
.then(() => {
this._send_log("Daemon redemarre.");
this._process_next();
})
.catch((obj_restart_err) => {
this._send_log("ERREUR redemarrage daemon : " + (obj_restart_err.message || String(obj_restart_err)));
this.str_status = STR_STATUS_IDLE;
this._send_progress();
});
} else {
this._process_next();
}
});
}
@@ -351,17 +497,66 @@ class QueueManager {
let list_skipped = [];
let list_stopped = [];
let list_skipped_paths = [];
let list_rendering_remote = [];
let list_item_results = [];
let nb_avg = this.nb_completed_renders > 0
? Math.round(this.nb_total_render_ms / this.nb_completed_renders)
: 0;
for (let nb_i = 0; nb_i < this.list_queue.length; nb_i++) {
if (this.list_queue[nb_i].str_status === "skipped") {
let obj_q = this.list_queue[nb_i];
if (obj_q.str_status === "skipped") {
list_skipped.push(nb_i);
list_skipped_paths.push({ nb_index: nb_i, str_path: this.list_queue[nb_i].str_expected_file });
} else if (this.list_queue[nb_i].str_status === "stopped") {
list_skipped_paths.push({ nb_index: nb_i, str_path: obj_q.str_expected_file });
list_item_results.push({
nb_index: nb_i,
str_type: "done",
str_date: obj_q.str_done_date || null,
str_resolution: obj_q.nb_resolution_x + "x" + obj_q.nb_resolution_y,
});
} else if (obj_q.str_status === "stopped") {
list_stopped.push(nb_i);
} else if (obj_q.str_status === "rendering_remote") {
list_rendering_remote.push(nb_i);
list_item_results.push({
nb_index: nb_i,
str_type: "rendering_remote",
str_remote_hostname: obj_q.str_remote_hostname || "?",
nb_remote_avg_ms: obj_q.nb_remote_avg_ms || 0,
});
} else if (obj_q.str_status === "done") {
list_item_results.push({
nb_index: nb_i,
str_type: "done",
str_date: obj_q.str_done_date || null,
str_resolution: obj_q.nb_resolution_x + "x" + obj_q.nb_resolution_y,
});
}
}
let list_machine_avgs = [];
if (nb_avg > 0) {
list_machine_avgs.push({ str_hostname: this.str_hostname, nb_avg_ms: nb_avg });
}
for (let str_h of Object.keys(this.map_remote_avgs)) {
if (this.map_remote_avgs[str_h] > 0) {
list_machine_avgs.push({ str_hostname: str_h, nb_avg_ms: this.map_remote_avgs[str_h] });
}
}
let nb_remaining = 0;
for (let nb_r = this.nb_current_index; nb_r < this.list_queue.length; nb_r++) {
let obj_r = this.list_queue[nb_r];
if (obj_r.str_status === "pending" || obj_r.str_status === "rendering") {
nb_remaining++;
}
}
this._send_event("render-progress", {
nb_current: this.nb_current_index,
nb_total: this.list_queue.length,
nb_remaining: nb_remaining,
str_camera: obj_item.str_camera_name || "-",
nb_frame: obj_item.nb_frame || 0,
str_status: this.str_status,
@@ -370,9 +565,57 @@ class QueueManager {
list_skipped: list_skipped,
list_stopped: list_stopped,
list_skipped_paths: list_skipped_paths,
list_rendering_remote: list_rendering_remote,
list_item_results: list_item_results,
str_hostname: this.str_hostname,
nb_avg_render_ms: nb_avg,
list_machine_avgs: list_machine_avgs,
});
}
_get_placeholder_content() {
let nb_avg = this.nb_completed_renders > 0
? Math.round(this.nb_total_render_ms / this.nb_completed_renders)
: 0;
return JSON.stringify({ str_hostname: this.str_hostname, nb_avg_ms: nb_avg });
}
_read_placeholder(obj_item) {
try {
let str_content = fs.readFileSync(obj_item.str_expected_file, "utf-8");
let obj_data = JSON.parse(str_content);
obj_item.str_remote_hostname = obj_data.str_hostname || "?";
obj_item.nb_remote_avg_ms = obj_data.nb_avg_ms || 0;
if (obj_data.str_hostname && obj_data.nb_avg_ms > 0) {
this.map_remote_avgs[obj_data.str_hostname] = obj_data.nb_avg_ms;
}
} catch (obj_parse_err) {
obj_item.str_remote_hostname = "?";
obj_item.nb_remote_avg_ms = 0;
}
}
_check_remote_completions() {
for (let obj_q of this.list_queue) {
if (obj_q.str_status !== "rendering_remote") {
continue;
}
try {
if (fs.existsSync(obj_q.str_expected_file)) {
let nb_size = fs.statSync(obj_q.str_expected_file).size;
if (nb_size > NB_PLACEHOLDER_MAX_SIZE) {
obj_q.str_status = "skipped";
obj_q.str_done_date = fs.statSync(obj_q.str_expected_file).mtime.toISOString();
}
} else {
obj_q.str_status = "pending";
}
} catch (obj_check_err) {
// ignore
}
}
}
_send_log(str_message) {
this._send_event("log", str_message);
}
@@ -388,6 +631,20 @@ class QueueManager {
let obj_notif = new Notification({ title: str_title, body: str_body });
obj_notif.show();
}
if (this.obj_email_notifier) {
this.obj_email_notifier.send("[Multi Render] " + str_title, str_body)
.then((obj_result) => {
if (obj_result.is_success) {
this._send_log("Email envoye : " + str_title);
} else if (obj_result.str_error && obj_result.str_error !== "Email non configure") {
this._send_log("Erreur email : " + obj_result.str_error);
}
})
.catch((obj_err) => {
this._send_log("Erreur email : " + obj_err.message);
});
}
}
}

342
src/main/VideoGenerator.js Normal file
View File

@@ -0,0 +1,342 @@
const { spawn } = require("child_process");
const path = require("path");
const fs = require("fs");
const os = require("os");
const PathResolver = require("./PathResolver.js");
const STR_TEMP_DIR = path.join(os.tmpdir(), "multi_render_blender_preview");
const NB_PLACEHOLDER_MAX_SIZE = 512;
const VideoGenerator = {
_obj_process: null,
generate: (obj_params, fn_on_log) => {
let str_output_path = obj_params.str_output_path;
let list_cameras = obj_params.list_cameras;
let nb_fps = obj_params.nb_fps || 24;
let str_output_mode = obj_params.str_output_mode || "subfolder";
let str_frame_prefix = obj_params.str_frame_prefix !== undefined ? obj_params.str_frame_prefix : "f_";
let nb_frame_padding = obj_params.nb_frame_padding || 5;
if (!fs.existsSync(STR_TEMP_DIR)) {
fs.mkdirSync(STR_TEMP_DIR, { recursive: true });
}
let str_video_path = path.join(STR_TEMP_DIR, "preview.mp4");
try {
if (fs.existsSync(str_video_path)) {
fs.unlinkSync(str_video_path);
}
} catch (obj_err) {
// ignore
}
let list_camera_data = VideoGenerator._scan_rendered_files(
str_output_path, list_cameras, str_output_mode, str_frame_prefix
);
if (list_camera_data.length === 0) {
return Promise.reject(new Error("Aucune image rendue trouvee."));
}
let nb_width = list_camera_data[0].nb_resolution_x;
let nb_height = list_camera_data[0].nb_resolution_y;
if (nb_width % 2 !== 0) {
nb_width++;
}
if (nb_height % 2 !== 0) {
nb_height++;
}
let list_segments = [];
let nb_segment_index = 0;
let fn_generate_segments = () => {
if (nb_segment_index >= list_camera_data.length) {
return VideoGenerator._concat_segments(list_segments, str_video_path, fn_on_log);
}
let obj_cam_data = list_camera_data[nb_segment_index];
let str_title_path = path.join(STR_TEMP_DIR, "title_" + nb_segment_index + ".mp4");
let str_frames_path = path.join(STR_TEMP_DIR, "frames_" + nb_segment_index + ".mp4");
fn_on_log("Titre : " + obj_cam_data.str_name);
return VideoGenerator._generate_title_card(
str_title_path, obj_cam_data, nb_width, nb_height, nb_fps
)
.then(() => {
fn_on_log("Sequence : " + obj_cam_data.str_name + " (" + obj_cam_data.list_frame_numbers.length + " images)");
return VideoGenerator._generate_frames_video(
str_frames_path, obj_cam_data, nb_width, nb_height, nb_fps
);
})
.then(() => {
list_segments.push(str_title_path);
list_segments.push(str_frames_path);
nb_segment_index++;
return fn_generate_segments();
});
};
return fn_generate_segments()
.then(() => {
for (let str_seg of list_segments) {
try { fs.unlinkSync(str_seg); } catch (obj_e) { /* ignore */ }
}
let str_concat_file = path.join(STR_TEMP_DIR, "concat.txt");
try { if (fs.existsSync(str_concat_file)) { fs.unlinkSync(str_concat_file); } } catch (obj_e) { /* ignore */ }
let str_frames_list = path.join(STR_TEMP_DIR, "frames_list.txt");
try { if (fs.existsSync(str_frames_list)) { fs.unlinkSync(str_frames_list); } } catch (obj_e) { /* ignore */ }
return { str_video_path: str_video_path };
});
},
_extract_frame_number: (str_filepath, str_file_pattern) => {
let str_basename = path.basename(str_filepath);
let str_after = str_basename.substring(str_file_pattern.length);
let str_num = str_after.replace(/\.[^.]+$/, "");
let nb_frame = parseInt(str_num, 10);
return isNaN(nb_frame) ? -1 : nb_frame;
},
_scan_rendered_files: (str_output_path, list_cameras, str_output_mode, str_frame_prefix) => {
let list_camera_data = [];
for (let obj_cam of list_cameras) {
if (!obj_cam.is_enabled) {
continue;
}
let str_cam_dir = "";
let str_file_pattern = "";
if (str_output_mode === "prefix") {
str_cam_dir = str_output_path;
str_file_pattern = obj_cam.str_name + "_" + str_frame_prefix;
} else if (str_output_mode === "both") {
str_cam_dir = path.join(str_output_path, obj_cam.str_name);
str_file_pattern = obj_cam.str_name + "_" + str_frame_prefix;
} else {
str_cam_dir = path.join(str_output_path, obj_cam.str_name);
str_file_pattern = str_frame_prefix;
}
if (!fs.existsSync(str_cam_dir)) {
continue;
}
let list_files = fs.readdirSync(str_cam_dir);
let map_frame_to_file = {};
for (let str_file of list_files) {
if (!str_file.startsWith(str_file_pattern)) {
continue;
}
let str_full = path.join(str_cam_dir, str_file);
try {
let nb_size = fs.statSync(str_full).size;
if (nb_size > NB_PLACEHOLDER_MAX_SIZE) {
let nb_frame = VideoGenerator._extract_frame_number(str_full, str_file_pattern);
if (nb_frame >= 0) {
map_frame_to_file[nb_frame] = str_full;
}
}
} catch (obj_err) {
// ignore
}
}
let list_frame_numbers = Object.keys(map_frame_to_file).map(Number);
list_frame_numbers.sort((a, b) => a - b);
if (list_frame_numbers.length > 0) {
list_camera_data.push({
str_name: obj_cam.str_name,
nb_frame_start: obj_cam.nb_frame_start,
nb_frame_end: obj_cam.nb_frame_end,
nb_resolution_x: obj_cam.nb_resolution_x,
nb_resolution_y: obj_cam.nb_resolution_y,
map_frame_to_file: map_frame_to_file,
list_frame_numbers: list_frame_numbers,
});
}
}
return list_camera_data;
},
_find_font: () => {
let list_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
];
for (let str_p of list_paths) {
if (fs.existsSync(str_p)) {
return str_p;
}
}
return null;
},
_escape_drawtext: (str_text) => {
return str_text
.replace(/\\/g, "\\\\")
.replace(/'/g, "\u2019")
.replace(/:/g, "\\:")
.replace(/;/g, "\\;")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");
},
_generate_title_card: (str_output, obj_cam_data, nb_width, nb_height, nb_fps) => {
let str_font = VideoGenerator._find_font();
let str_font_prefix = str_font ? "fontfile=" + str_font.replace(/:/g, "\\:") + ":" : "";
let str_cam_name = VideoGenerator._escape_drawtext(obj_cam_data.str_name);
let str_frame_range = VideoGenerator._escape_drawtext(
"Frames " + obj_cam_data.nb_frame_start + " - " + obj_cam_data.nb_frame_end
);
let str_resolution = VideoGenerator._escape_drawtext(
obj_cam_data.nb_resolution_x + " x " + obj_cam_data.nb_resolution_y
);
let str_nb_images = VideoGenerator._escape_drawtext(
obj_cam_data.list_frame_numbers.length + " images rendues"
);
let str_filter = "drawtext=" + str_font_prefix + "text='" + str_cam_name
+ "':fontcolor=white:fontsize=60:x=(w-tw)/2:y=(h/2)-80"
+ ",drawtext=" + str_font_prefix + "text='" + str_frame_range
+ "':fontcolor=0xaaaaaa:fontsize=36:x=(w-tw)/2:y=(h/2)"
+ ",drawtext=" + str_font_prefix + "text='" + str_resolution
+ "':fontcolor=0x888888:fontsize=28:x=(w-tw)/2:y=(h/2)+60"
+ ",drawtext=" + str_font_prefix + "text='" + str_nb_images
+ "':fontcolor=0x888888:fontsize=28:x=(w-tw)/2:y=(h/2)+100";
let list_args = [
"-f", "lavfi",
"-i", "color=c=0x1a1a2e:s=" + nb_width + "x" + nb_height + ":d=1:r=" + nb_fps,
"-vf", str_filter,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-t", "1",
"-y",
str_output,
];
return VideoGenerator._run_ffmpeg(list_args);
},
_generate_frames_video: (str_output, obj_cam_data, nb_width, nb_height, nb_fps) => {
let str_concat_path = path.join(STR_TEMP_DIR, "frames_list.txt");
let nb_frame_duration = 1 / nb_fps;
let str_content = "";
let list_frame_numbers = obj_cam_data.list_frame_numbers;
let map_frame_to_file = obj_cam_data.map_frame_to_file;
let nb_first = list_frame_numbers[0];
let nb_last = list_frame_numbers[list_frame_numbers.length - 1];
let str_last_file = map_frame_to_file[nb_first];
for (let nb_f = nb_first; nb_f <= nb_last; nb_f++) {
if (map_frame_to_file[nb_f]) {
str_last_file = map_frame_to_file[nb_f];
}
let str_safe = str_last_file.replace(/'/g, "'\\''");
str_content += "file '" + str_safe + "'\n";
str_content += "duration " + nb_frame_duration.toFixed(6) + "\n";
}
let str_safe_last = str_last_file.replace(/'/g, "'\\''");
str_content += "file '" + str_safe_last + "'\n";
fs.writeFileSync(str_concat_path, str_content, "utf8");
let str_scale = "scale=" + nb_width + ":" + nb_height
+ ":force_original_aspect_ratio=decrease"
+ ",pad=" + nb_width + ":" + nb_height + ":(ow-iw)/2:(oh-ih)/2:color=black";
let list_args = [
"-f", "concat",
"-safe", "0",
"-i", str_concat_path,
"-vf", str_scale,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-r", String(nb_fps),
"-y",
str_output,
];
return VideoGenerator._run_ffmpeg(list_args);
},
_concat_segments: (list_segments, str_output, fn_on_log) => {
let str_concat_path = path.join(STR_TEMP_DIR, "concat.txt");
let str_content = "";
for (let str_seg of list_segments) {
let str_safe = str_seg.replace(/'/g, "'\\''");
str_content += "file '" + str_safe + "'\n";
}
fs.writeFileSync(str_concat_path, str_content, "utf8");
fn_on_log("Concatenation des segments...");
let list_args = [
"-f", "concat",
"-safe", "0",
"-i", str_concat_path,
"-c", "copy",
"-y",
str_output,
];
return VideoGenerator._run_ffmpeg(list_args);
},
_run_ffmpeg: (list_args) => {
return new Promise((resolve, reject) => {
let obj_process = spawn(PathResolver.get_ffmpeg_path(), list_args);
let str_stderr = "";
VideoGenerator._obj_process = obj_process;
obj_process.stderr.on("data", (obj_data) => {
str_stderr += obj_data.toString();
});
obj_process.on("close", (nb_code) => {
VideoGenerator._obj_process = null;
if (nb_code !== 0) {
reject(new Error("ffmpeg erreur (code " + nb_code + "): " + str_stderr.slice(-500)));
return;
}
resolve();
});
obj_process.on("error", (obj_err) => {
VideoGenerator._obj_process = null;
reject(new Error("Impossible de lancer ffmpeg : " + obj_err.message));
});
});
},
cancel: () => {
if (VideoGenerator._obj_process) {
try { VideoGenerator._obj_process.kill("SIGTERM"); } catch (obj_err) { /* ignore */ }
VideoGenerator._obj_process = null;
}
},
};
module.exports = VideoGenerator;

142
src/python/render_daemon.py Normal file
View File

@@ -0,0 +1,142 @@
"""Daemon de rendu persistant pour Blender.
Ce script est execute par Blender en mode background (-b) et reste actif.
Il lit des commandes JSON depuis stdin et execute les rendus demandes.
Le fichier .blend est charge une seule fois au demarrage.
"""
import bpy
import json
import sys
import os
import logging
logging.basicConfig(level=logging.INFO, format="[render_daemon] %(message)s")
obj_logger: logging.Logger = logging.getLogger(__name__)
def apply_render_settings(obj_scene: object, obj_settings: dict) -> None:
"""Applique les parametres de rendu a la scene."""
if not obj_settings:
return
if "str_engine" in obj_settings:
obj_scene.render.engine = obj_settings["str_engine"]
if "nb_resolution_percentage" in obj_settings:
obj_scene.render.resolution_percentage = obj_settings["nb_resolution_percentage"]
if "is_film_transparent" in obj_settings:
obj_scene.render.film_transparent = obj_settings["is_film_transparent"]
str_engine: str = obj_scene.render.engine
if str_engine == "CYCLES":
if "nb_samples" in obj_settings:
obj_scene.cycles.samples = obj_settings["nb_samples"]
if "str_device" in obj_settings:
obj_scene.cycles.device = obj_settings["str_device"]
if "is_denoise" in obj_settings:
obj_scene.cycles.use_denoising = obj_settings["is_denoise"]
elif str_engine in ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"):
if "nb_samples" in obj_settings:
try:
obj_scene.eevee.taa_render_samples = obj_settings["nb_samples"]
except AttributeError:
try:
obj_scene.eevee.samples = obj_settings["nb_samples"]
except AttributeError:
pass
def apply_collections(list_collections: list) -> None:
"""Applique la visibilite des collections pour le rendu."""
if not list_collections:
return
def _set_layer_collection(obj_lc: object, str_name: str, is_val: bool) -> None:
"""Parcourt recursivement les layer collections pour exclure/inclure."""
for obj_child in obj_lc.children:
if obj_child.name == str_name:
obj_child.exclude = is_val
return
_set_layer_collection(obj_child, str_name, is_val)
obj_view_layer_collection = bpy.context.view_layer.layer_collection
for obj_col in list_collections:
str_name: str = obj_col["str_name"]
is_hide: bool = obj_col["is_hide_render"]
bpy.data.collections[str_name].hide_render = is_hide
_set_layer_collection(obj_view_layer_collection, str_name, is_hide)
def process_render(obj_cmd: dict) -> None:
"""Execute le rendu d'une frame selon la commande recue."""
obj_scene = bpy.context.scene
str_camera: str = obj_cmd["str_camera"]
nb_frame: int = obj_cmd["nb_frame"]
nb_resolution_x: int = obj_cmd["nb_resolution_x"]
nb_resolution_y: int = obj_cmd["nb_resolution_y"]
str_format: str = obj_cmd["str_format"]
str_output_path: str = obj_cmd["str_output_path"]
obj_scene.camera = bpy.data.objects[str_camera]
obj_scene.render.resolution_x = nb_resolution_x
obj_scene.render.resolution_y = nb_resolution_y
obj_scene.render.image_settings.file_format = str_format
obj_scene.frame_set(nb_frame)
str_dir: str = os.path.dirname(str_output_path)
if str_dir and not os.path.exists(str_dir):
os.makedirs(str_dir, exist_ok=True)
obj_scene.render.filepath = str_output_path
apply_render_settings(obj_scene, obj_cmd.get("obj_render_settings"))
apply_collections(obj_cmd.get("list_collections"))
try:
bpy.ops.render.render(write_still=True)
str_result: str = json.dumps({"str_file": str_output_path})
sys.stdout.write("RENDER_DONE:" + str_result + "\n")
sys.stdout.flush()
except Exception as obj_err:
str_error: str = json.dumps({"str_error": str(obj_err)})
sys.stdout.write("RENDER_ERROR:" + str_error + "\n")
sys.stdout.flush()
obj_logger.error("Erreur rendu : %s", str(obj_err))
def main() -> None:
"""Boucle principale du daemon de rendu."""
sys.stdout.write("DAEMON_READY\n")
sys.stdout.flush()
for str_line in sys.stdin:
str_line = str_line.strip()
if not str_line:
continue
try:
obj_cmd: dict = json.loads(str_line)
except json.JSONDecodeError as obj_err:
str_error: str = json.dumps({"str_error": "JSON invalide : " + str(obj_err)})
sys.stdout.write("RENDER_ERROR:" + str_error + "\n")
sys.stdout.flush()
continue
str_cmd: str = obj_cmd.get("str_cmd", "")
if str_cmd == "quit":
break
elif str_cmd == "render":
process_render(obj_cmd)
else:
str_error = json.dumps({"str_error": "Commande inconnue : " + str_cmd})
sys.stdout.write("RENDER_ERROR:" + str_error + "\n")
sys.stdout.flush()
main()

View File

@@ -3,7 +3,7 @@
<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'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; font-src https://cdn.jsdelivr.net; img-src 'self' file: data:;">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; font-src https://cdn.jsdelivr.net; img-src 'self' file: data:; media-src 'self' file: data:;">
<title>Multi Render Blender</title>
<!-- Bootstrap 5 -->
@@ -33,8 +33,8 @@
<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">
<!-- ── Left Column : File + Settings ──────────── -->
<div class="col-md-3 d-flex flex-column gap-3">
<!-- Blend file selection -->
<div class="card bg-dark border-secondary">
@@ -99,6 +99,18 @@
</div>
</div>
<!-- Render settings -->
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary">
<i class="mdi mdi-tune-vertical me-1"></i>Proprietes de rendu
</div>
<div class="card-body" id="container_render_settings">
<div class="text-center text-light-emphasis py-2">
Chargez un fichier .blend
</div>
</div>
</div>
<!-- Render mode -->
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary">
@@ -132,15 +144,19 @@
</div>
</div>
</div>
</div>
<!-- ── Center Column : Cameras + Config ──────────── -->
<div class="col-md-3 d-flex flex-column gap-3">
<!-- Camera list -->
<div class="card bg-dark border-secondary flex-grow-1">
<div class="card bg-dark border-secondary">
<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 id="container_camera_list" class="list-group list-group-flush overflow-auto" style="max-height: 300px;">
<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
@@ -148,13 +164,31 @@
</div>
</div>
</div>
<!-- Collections -->
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
<span><i class="mdi mdi-folder-multiple-outline me-1"></i>Collections</span>
<div class="d-flex align-items-center gap-2">
<button id="btn_reset_collections" class="btn btn-sm btn-outline-secondary py-0 px-1"
title="Restaurer les valeurs originales">
<i class="mdi mdi-undo-variant" style="font-size: 0.75rem;"></i>
</button>
<span id="badge_collection_count" class="badge bg-secondary">0</span>
</div>
</div>
<div class="card-body p-0">
<div id="container_collection_list" class="list-group list-group-flush overflow-auto" style="max-height: 250px;">
<div class="text-center text-light-emphasis py-3">
<i class="mdi mdi-folder-off-outline d-block mb-2" style="font-size: 1.5rem;"></i>
Chargez un fichier .blend
</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 bg-dark border-secondary flex-grow-1">
<div class="card-header border-secondary">
<i class="mdi mdi-cog-outline me-1"></i>Configuration : <span id="label_selected_camera">-</span>
</div>
@@ -164,6 +198,10 @@
</div>
</div>
</div>
</div>
<!-- ── Right Column : Controls + Queue + Preview + Console ── -->
<div class="col-md-6 d-flex flex-column gap-3">
<!-- Render controls -->
<div class="card bg-dark border-secondary">
@@ -191,8 +229,9 @@
<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 class="progress bg-secondary position-relative" style="height: 20px;">
<div id="bar_progress" class="progress-bar bg-primary" role="progressbar" style="width: 0%;"></div>
<small id="label_progress_time" class="position-absolute w-100 text-center" style="line-height: 20px; font-size: 0.7rem; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.9);"></small>
</div>
</div>
<div class="d-flex justify-content-between">
@@ -202,6 +241,9 @@
</div>
</div>
<!-- Render queue + Preview side by side -->
<div class="row g-3 flex-grow-1">
<div class="col-6 d-flex flex-column">
<!-- 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">
@@ -220,17 +262,30 @@
</div>
</div>
</div>
<!-- ── Right Column : Preview + Console ─────────── -->
<div class="col-md-4 d-flex flex-column gap-3">
<div class="col-6 d-flex flex-column">
<!-- 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 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-image-outline me-1"></i>Preview</span>
<div class="d-flex align-items-center gap-1">
<div id="container_ffmpeg_progress" class="d-none d-flex align-items-center gap-1">
<div class="progress bg-secondary position-relative" style="height: 14px; width: 120px;">
<div id="bar_ffmpeg_progress" class="progress-bar progress-bar-striped progress-bar-animated bg-info" role="progressbar" style="width: 100%;"></div>
<small id="label_ffmpeg_time" class="position-absolute w-100 text-center" style="line-height: 14px; font-size: 0.6rem; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.9);"></small>
</div>
</div>
<select id="select_video_fps" class="form-select form-select-sm bg-dark text-light border-secondary py-0" style="width: 70px; font-size: 0.7rem;">
<option value="24">24 fps</option>
<option value="30" selected>30 fps</option>
<option value="60">60 fps</option>
</select>
<button id="btn_generate_video" class="btn btn-sm btn-outline-info py-0 px-1" title="Generer une video preview (ffmpeg)">
<i class="mdi mdi-video-outline"></i>
</button>
</div>
</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="preview-placeholder d-flex align-items-center justify-content-center" style="min-height: 200px;">
<div class="text-light-emphasis">
<i class="mdi mdi-image-off-outline d-block mb-2" style="font-size: 3rem;"></i>
Aucun rendu disponible
@@ -238,9 +293,11 @@
</div>
</div>
</div>
</div>
</div>
<!-- Console logs -->
<div class="card bg-dark border-secondary flex-grow-1">
<div class="card bg-dark border-secondary">
<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">
@@ -248,7 +305,7 @@
</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 id="container_console" class="console-output overflow-auto p-2" style="max-height: 200px; min-height: 150px;">
</div>
</div>
</div>
@@ -256,18 +313,38 @@
</div>
</div>
<!-- Video Preview Modal -->
<div class="modal fade" id="modal_video_preview" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary py-2">
<h6 class="modal-title text-light mb-0">
<i class="mdi mdi-video-outline me-1"></i>Preview Video
</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 text-center bg-black">
<video id="video_preview_player" controls style="max-width: 100%; max-height: 75vh;"></video>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Scripts -->
<script src="scripts/ConsoleLog.js"></script>
<script src="scripts/RenderSettings.js"></script>
<script src="scripts/CameraList.js"></script>
<script src="scripts/CollectionList.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/UpdateBanner.js"></script>
<script src="scripts/BlenderPath.js"></script>
<script src="scripts/FfmpegPath.js"></script>
<script src="scripts/NotificationConfig.js"></script>
<script src="scripts/App.js"></script>
</body>

View File

@@ -4,13 +4,16 @@ const App = {
init: () => {
ConsoleLog.init();
RenderSettings.init();
CameraList.init(App._on_camera_select);
CollectionList.init();
CameraConfig.init();
RenderQueue.init();
PreviewPanel.init();
ProgressBar.init();
UpdateBanner.init();
BlenderPath.init();
FfmpegPath.init();
NotificationConfig.init();
App._bind_events();
@@ -70,6 +73,12 @@ const App = {
obj_radio_both.addEventListener("change", () => { App._update_output_example(); });
obj_input_frame_prefix.addEventListener("input", () => { App._update_output_example(); });
obj_input_frame_padding.addEventListener("input", () => { App._update_output_example(); });
let obj_btn_reset_cols = document.getElementById("btn_reset_collections");
obj_btn_reset_cols.addEventListener("click", () => {
CollectionList.reset_to_original();
ConsoleLog.add("Collections restaurees aux valeurs originales.");
});
},
_bind_render_events: () => {
@@ -129,6 +138,18 @@ const App = {
CameraList.set_cameras(obj_result.list_cameras, obj_result.obj_scene);
CameraConfig.clear();
if (obj_result.obj_render_settings) {
RenderSettings.set_from_blend(obj_result.obj_render_settings);
}
if (obj_result.list_collections) {
CollectionList.set_collections(obj_result.list_collections);
ConsoleLog.add(obj_result.list_collections.length + " collection(s) trouvee(s).");
} else {
CollectionList.clear();
}
ConsoleLog.add(obj_result.list_cameras.length + " camera(s) trouvee(s).");
App._update_start_button();
})
@@ -188,6 +209,8 @@ const App = {
nb_frame_padding: nb_frame_padding,
str_output_path: App.str_output_path,
list_cameras: list_cameras,
list_collections: CollectionList.get_overrides(),
obj_render_settings: RenderSettings.get_settings(),
};
RenderQueue.build_display(str_mode, list_cameras);
@@ -249,6 +272,7 @@ const App = {
nb_frame_padding: nb_frame_padding,
str_output_path: App.str_output_path,
list_cameras: list_cameras,
list_collections: CollectionList.get_overrides(),
};
RenderQueue.build_display(str_mode, list_cameras);
@@ -284,6 +308,8 @@ const App = {
nb_frame_padding: nb_frame_padding,
str_output_path: App.str_output_path,
list_cameras: CameraList.list_cameras,
list_collections: CollectionList.list_collections,
obj_render_settings: RenderSettings.get_settings(),
};
window.api.save_config(obj_config)
@@ -338,6 +364,8 @@ const App = {
App._update_output_example();
RenderSettings.set_from_config(obj_config);
if (obj_config.list_cameras && obj_config.list_cameras.length > 0) {
CameraList.list_cameras = obj_config.list_cameras;
CameraList.str_selected_camera = null;
@@ -347,6 +375,15 @@ const App = {
obj_badge.textContent = String(obj_config.list_cameras.length);
}
if (obj_config.list_collections && obj_config.list_collections.length > 0) {
CollectionList.list_collections = obj_config.list_collections;
CollectionList.render();
let obj_badge_col = document.getElementById("badge_collection_count");
obj_badge_col.textContent = String(obj_config.list_collections.length);
} else {
CollectionList.clear();
}
CameraConfig.clear();
App._update_start_button();
ConsoleLog.add("Configuration importee.");

View File

@@ -36,9 +36,14 @@ const BlenderPath = {
'<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>' +
'</div>' +
'<div class="modal-body">' +
'<p class="text-light-emphasis mb-3" style="font-size: 0.85rem;">' +
'<p class="text-light-emphasis mb-2" style="font-size: 0.85rem;">' +
'Selectionnez l\'executable Blender sur votre machine.' +
'</p>' +
'<p class="mb-3" style="font-size: 0.8rem;">' +
'<a href="https://www.blender.org/download/" target="_blank" class="text-info">' +
'<i class="mdi mdi-download me-1"></i>Telecharger Blender' +
'</a>' +
'</p>' +
'<div class="input-group input-group-sm mb-3">' +
'<input type="text" id="input_blender_path" class="form-control bg-dark text-light border-secondary" placeholder="Aucun chemin configure" readonly>' +
'<button id="btn_browse_blender" class="btn btn-outline-primary" type="button">' +

View File

@@ -0,0 +1,133 @@
const CollectionList = {
list_collections: [],
init: () => {
// Peuple lors du chargement du .blend
},
set_collections: (list_raw) => {
CollectionList.list_collections = [];
if (!list_raw || list_raw.length === 0) {
CollectionList.render();
let obj_badge = document.getElementById("badge_collection_count");
obj_badge.textContent = "0";
return;
}
for (let obj_raw of list_raw) {
let is_hidden = obj_raw.is_hide_render || obj_raw.is_exclude;
CollectionList.list_collections.push({
str_name: obj_raw.str_name,
nb_depth: obj_raw.nb_depth,
is_original_hide_render: is_hidden,
is_original_exclude: obj_raw.is_exclude,
is_hide_render: is_hidden,
has_override: false,
});
}
CollectionList.render();
let obj_badge = document.getElementById("badge_collection_count");
obj_badge.textContent = String(CollectionList.list_collections.length);
},
get_overrides: () => {
let list_overrides = [];
for (let obj_col of CollectionList.list_collections) {
list_overrides.push({
str_name: obj_col.str_name,
is_hide_render: obj_col.is_hide_render,
});
}
return list_overrides;
},
render: () => {
let obj_container = document.getElementById("container_collection_list");
obj_container.innerHTML = "";
if (CollectionList.list_collections.length === 0) {
obj_container.innerHTML = '<div class="text-center text-light-emphasis py-3">'
+ '<i class="mdi mdi-folder-off-outline d-block mb-2" style="font-size: 1.5rem;"></i>'
+ "Chargez un fichier .blend"
+ "</div>";
return;
}
for (let obj_col of CollectionList.list_collections) {
let obj_item = document.createElement("div");
obj_item.classList.add("list-group-item", "bg-dark", "text-light",
"border-secondary", "d-flex", "align-items-center", "gap-2", "py-1");
let nb_padding = 0.75 + obj_col.nb_depth * 1.2;
obj_item.style.paddingLeft = nb_padding + "rem";
let obj_checkbox = document.createElement("input");
obj_checkbox.type = "checkbox";
obj_checkbox.classList.add("form-check-input");
obj_checkbox.checked = !obj_col.is_hide_render;
obj_checkbox.addEventListener("change", () => {
obj_col.is_hide_render = !obj_checkbox.checked;
obj_col.has_override = (obj_col.is_hide_render !== obj_col.is_original_hide_render);
CollectionList.render();
});
let obj_icon = document.createElement("i");
obj_icon.classList.add("mdi");
if (obj_col.is_hide_render) {
obj_icon.classList.add("mdi-folder-off-outline", "text-muted");
} else {
obj_icon.classList.add("mdi-folder-outline");
}
let obj_label = document.createElement("span");
obj_label.classList.add("flex-grow-1", "collection-name");
obj_label.textContent = obj_col.str_name;
if (obj_col.is_hide_render) {
obj_label.classList.add("text-muted");
}
let obj_indicator = document.createElement("small");
obj_indicator.classList.add("collection-original-badge");
if (obj_col.has_override) {
obj_indicator.classList.add("text-warning");
obj_indicator.innerHTML = '<i class="mdi mdi-pencil-outline"></i>';
obj_indicator.title = "Modifie (original : "
+ (obj_col.is_original_hide_render ? "masque" : "visible") + ")";
} else if (obj_col.is_original_hide_render) {
obj_indicator.classList.add("text-muted");
obj_indicator.innerHTML = '<i class="mdi mdi-eye-off-outline"></i>';
obj_indicator.title = "Masque dans le .blend";
}
obj_item.appendChild(obj_checkbox);
obj_item.appendChild(obj_icon);
obj_item.appendChild(obj_label);
obj_item.appendChild(obj_indicator);
obj_container.appendChild(obj_item);
}
},
reset_to_original: () => {
for (let obj_col of CollectionList.list_collections) {
obj_col.is_hide_render = obj_col.is_original_hide_render;
obj_col.has_override = false;
}
CollectionList.render();
},
clear: () => {
CollectionList.list_collections = [];
let obj_container = document.getElementById("container_collection_list");
obj_container.innerHTML = '<div class="text-center text-light-emphasis py-3">'
+ '<i class="mdi mdi-folder-off-outline d-block mb-2" style="font-size: 1.5rem;"></i>'
+ "Chargez un fichier .blend"
+ "</div>";
let obj_badge = document.getElementById("badge_collection_count");
obj_badge.textContent = "0";
},
};

View File

@@ -0,0 +1,198 @@
const FfmpegPath = {
str_current_path: null,
is_found: false,
obj_modal: null,
init: () => {
FfmpegPath._create_badge();
FfmpegPath._create_modal();
FfmpegPath._bind_events();
},
_create_badge: () => {
let obj_nav_right = document.querySelector("nav .d-flex.gap-2");
let obj_badge = document.createElement("button");
obj_badge.id = "btn_ffmpeg_status";
obj_badge.className = "btn btn-sm btn-outline-secondary";
obj_badge.title = "Chemin FFmpeg";
obj_badge.innerHTML = '<i class="mdi mdi-video-outline"></i>';
obj_nav_right.insertBefore(obj_badge, obj_nav_right.firstChild);
obj_badge.addEventListener("click", () => {
FfmpegPath._open_modal();
});
},
_create_modal: () => {
let obj_modal_el = document.createElement("div");
obj_modal_el.id = "modal_ffmpeg_path";
obj_modal_el.className = "modal fade";
obj_modal_el.tabIndex = -1;
obj_modal_el.innerHTML =
'<div class="modal-dialog modal-dialog-centered">' +
'<div class="modal-content bg-dark text-light border-secondary">' +
'<div class="modal-header border-secondary">' +
'<h6 class="modal-title"><i class="mdi mdi-video-outline me-2"></i>Chemin FFmpeg</h6>' +
'<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>' +
'</div>' +
'<div class="modal-body">' +
'<p class="text-light-emphasis mb-2" style="font-size: 0.85rem;">' +
'Selectionnez l\'executable FFmpeg sur votre machine.' +
'</p>' +
'<p class="mb-3" style="font-size: 0.8rem;">' +
'<a href="https://ffmpeg.org/download.html" target="_blank" class="text-info">' +
'<i class="mdi mdi-download me-1"></i>Telecharger FFmpeg' +
'</a>' +
'</p>' +
'<div class="input-group input-group-sm mb-3">' +
'<input type="text" id="input_ffmpeg_path" class="form-control bg-dark text-light border-secondary" placeholder="Aucun chemin configure" readonly>' +
'<button id="btn_browse_ffmpeg" class="btn btn-outline-primary" type="button">' +
'<i class="mdi mdi-folder-search-outline"></i> Parcourir' +
'</button>' +
'</div>' +
'<div class="d-flex justify-content-between align-items-center">' +
'<button id="btn_detect_ffmpeg" class="btn btn-sm btn-outline-secondary">' +
'<i class="mdi mdi-magnify me-1"></i>Detecter automatiquement' +
'</button>' +
'<span id="label_ffmpeg_status" class="badge bg-danger">' +
'<i class="mdi mdi-close-circle me-1"></i>Non trouve' +
'</span>' +
'</div>' +
'</div>' +
'<div class="modal-footer border-secondary">' +
'<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Annuler</button>' +
'<button id="btn_validate_ffmpeg" type="button" class="btn btn-sm btn-primary" disabled>' +
'<i class="mdi mdi-check me-1"></i>Valider' +
'</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(obj_modal_el);
FfmpegPath.obj_modal = new bootstrap.Modal(obj_modal_el);
},
_bind_events: () => {
let obj_btn_browse = document.getElementById("btn_browse_ffmpeg");
obj_btn_browse.addEventListener("click", () => {
FfmpegPath._browse();
});
let obj_btn_detect = document.getElementById("btn_detect_ffmpeg");
obj_btn_detect.addEventListener("click", () => {
FfmpegPath._detect();
});
let obj_btn_validate = document.getElementById("btn_validate_ffmpeg");
obj_btn_validate.addEventListener("click", () => {
FfmpegPath._validate();
});
window.api.on_ffmpeg_path_status((obj_data) => {
FfmpegPath.str_current_path = obj_data.str_path;
FfmpegPath.is_found = obj_data.is_found;
FfmpegPath._update_badge();
});
},
_open_modal: () => {
let obj_input = document.getElementById("input_ffmpeg_path");
obj_input.value = FfmpegPath.is_found ? FfmpegPath.str_current_path : "";
FfmpegPath._update_modal_status(FfmpegPath.is_found, FfmpegPath.str_current_path);
FfmpegPath.obj_modal.show();
if (!FfmpegPath.is_found) {
FfmpegPath._detect();
}
},
_browse: () => {
window.api.select_ffmpeg_exe()
.then((str_path) => {
if (!str_path) {
return;
}
let obj_input = document.getElementById("input_ffmpeg_path");
obj_input.value = str_path;
FfmpegPath._update_modal_status(true, str_path);
})
.catch((obj_err) => {
ConsoleLog.add("Erreur selection FFmpeg : " + obj_err.message);
});
},
_detect: () => {
let obj_btn = document.getElementById("btn_detect_ffmpeg");
obj_btn.disabled = true;
obj_btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Detection...';
window.api.get_ffmpeg_path()
.then((obj_data) => {
obj_btn.disabled = false;
obj_btn.innerHTML = '<i class="mdi mdi-magnify me-1"></i>Detecter automatiquement';
if (obj_data.is_found) {
let obj_input = document.getElementById("input_ffmpeg_path");
obj_input.value = obj_data.str_path;
FfmpegPath._update_modal_status(true, obj_data.str_path);
} else {
FfmpegPath._update_modal_status(false, null);
}
})
.catch(() => {
obj_btn.disabled = false;
obj_btn.innerHTML = '<i class="mdi mdi-magnify me-1"></i>Detecter automatiquement';
FfmpegPath._update_modal_status(false, null);
});
},
_validate: () => {
let str_path = document.getElementById("input_ffmpeg_path").value;
if (!str_path) {
return;
}
window.api.set_ffmpeg_path(str_path)
.then((obj_result) => {
if (obj_result.is_success) {
FfmpegPath.str_current_path = obj_result.str_path;
FfmpegPath.is_found = true;
FfmpegPath._update_badge();
FfmpegPath.obj_modal.hide();
ConsoleLog.add("Chemin FFmpeg configure : " + obj_result.str_path);
} else {
FfmpegPath._update_modal_status(false, null);
ConsoleLog.add("Chemin FFmpeg invalide : " + (obj_result.str_error || ""));
}
})
.catch((obj_err) => {
ConsoleLog.add("Erreur configuration FFmpeg : " + obj_err.message);
});
},
_update_badge: () => {
let obj_badge = document.getElementById("btn_ffmpeg_status");
if (FfmpegPath.is_found) {
obj_badge.className = "btn btn-sm btn-outline-success";
obj_badge.title = "FFmpeg : " + FfmpegPath.str_current_path;
} else {
obj_badge.className = "btn btn-sm btn-outline-danger";
obj_badge.title = "FFmpeg non trouve";
}
},
_update_modal_status: (is_valid, str_path) => {
let obj_label = document.getElementById("label_ffmpeg_status");
let obj_btn_validate = document.getElementById("btn_validate_ffmpeg");
if (is_valid && str_path) {
obj_label.className = "badge bg-success";
obj_label.innerHTML = '<i class="mdi mdi-check-circle me-1"></i>Trouve';
obj_btn_validate.disabled = false;
} else {
obj_label.className = "badge bg-danger";
obj_label.innerHTML = '<i class="mdi mdi-close-circle me-1"></i>Non trouve';
obj_btn_validate.disabled = true;
}
},
};

View File

@@ -1,6 +1,7 @@
const NotificationConfig = {
is_notify_each_image: false,
is_notify_all_done: true,
obj_email_config: null,
obj_modal: null,
init: () => {
@@ -8,6 +9,7 @@ const NotificationConfig = {
NotificationConfig._create_modal();
NotificationConfig._bind_events();
NotificationConfig._load_config();
NotificationConfig._load_email_config();
},
_create_badge: () => {
@@ -35,28 +37,55 @@ const NotificationConfig = {
obj_modal_el.className = "modal fade";
obj_modal_el.tabIndex = -1;
obj_modal_el.innerHTML =
'<div class="modal-dialog modal-dialog-centered modal-sm">' +
'<div class="modal-dialog modal-dialog-centered">' +
'<div class="modal-content bg-dark text-light border-secondary">' +
'<div class="modal-header border-secondary">' +
'<h6 class="modal-title"><i class="mdi mdi-bell-outline me-2"></i>Notifications</h6>' +
'<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>' +
'</div>' +
'<div class="modal-body">' +
'<p class="text-light-emphasis mb-3" style="font-size: 0.85rem;">' +
'Configurez les notifications systeme.' +
'</p>' +
'<p class="text-light-emphasis mb-2" style="font-size: 0.8rem; font-weight: 600;">Notifications systeme</p>' +
'<div class="form-check mb-2">' +
'<input class="form-check-input" type="checkbox" id="check_notify_each_image">' +
'<label class="form-check-label" for="check_notify_each_image">' +
'A chaque image rendue' +
'</label>' +
'</div>' +
'<div class="form-check">' +
'<div class="form-check mb-3">' +
'<input class="form-check-input" type="checkbox" id="check_notify_all_done" checked>' +
'<label class="form-check-label" for="check_notify_all_done">' +
'Quand tous les rendus sont termines' +
'</label>' +
'</div>' +
'<hr class="border-secondary">' +
'<div class="d-flex justify-content-between align-items-center mb-2">' +
'<p class="text-light-emphasis mb-0" style="font-size: 0.8rem; font-weight: 600;">' +
'<i class="mdi mdi-email-outline me-1"></i>Notification par email' +
'</p>' +
'<div class="form-check form-switch mb-0">' +
'<input class="form-check-input" type="checkbox" id="check_email_enabled">' +
'</div>' +
'</div>' +
'<div id="container_email_fields">' +
'<p class="text-light-emphasis mb-2" style="font-size: 0.75rem;">' +
'Un email sera envoye lors des notifications. Separez les adresses par une virgule.' +
'</p>' +
'<div class="mb-2">' +
'<label class="form-label form-label-sm mb-0">Destinataire(s)</label>' +
'<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary" id="input_email_to" placeholder="email@example.com, autre@example.com">' +
'</div>' +
'<div class="d-flex justify-content-between align-items-center">' +
'<button id="btn_test_email" class="btn btn-sm btn-outline-info">' +
'<i class="mdi mdi-email-send-outline me-1"></i>Envoyer un test' +
'</button>' +
'<span id="label_email_test_status" class="badge d-none"></span>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="modal-footer border-secondary">' +
'<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Annuler</button>' +
@@ -76,11 +105,46 @@ const NotificationConfig = {
obj_btn_save.addEventListener("click", () => {
NotificationConfig._save_config();
});
let obj_check_email = document.getElementById("check_email_enabled");
obj_check_email.addEventListener("change", () => {
NotificationConfig._toggle_email_fields();
});
let obj_btn_test = document.getElementById("btn_test_email");
obj_btn_test.addEventListener("click", () => {
NotificationConfig._test_email();
});
},
_toggle_email_fields: () => {
let is_enabled = document.getElementById("check_email_enabled").checked;
let obj_fields = document.getElementById("container_email_fields");
let list_inputs = obj_fields.querySelectorAll("input, button");
for (let obj_input of list_inputs) {
obj_input.disabled = !is_enabled;
}
obj_fields.style.opacity = is_enabled ? "1" : "0.4";
},
_open_modal: () => {
document.getElementById("check_notify_each_image").checked = NotificationConfig.is_notify_each_image;
document.getElementById("check_notify_all_done").checked = NotificationConfig.is_notify_all_done;
let obj_email = NotificationConfig.obj_email_config;
if (obj_email) {
document.getElementById("check_email_enabled").checked = obj_email.is_enabled || false;
document.getElementById("input_email_to").value = obj_email.str_to || "";
} else {
document.getElementById("check_email_enabled").checked = false;
document.getElementById("input_email_to").value = "";
}
NotificationConfig._toggle_email_fields();
let obj_status = document.getElementById("label_email_test_status");
obj_status.classList.add("d-none");
NotificationConfig.obj_modal.show();
},
@@ -98,16 +162,39 @@ const NotificationConfig = {
});
},
_load_email_config: () => {
window.api.get_email_config()
.then((obj_config) => {
if (obj_config) {
NotificationConfig.obj_email_config = obj_config;
NotificationConfig._update_badge();
}
})
.catch(() => {
// Config non trouvee
});
},
_save_config: () => {
NotificationConfig.is_notify_each_image = document.getElementById("check_notify_each_image").checked;
NotificationConfig.is_notify_all_done = document.getElementById("check_notify_all_done").checked;
let obj_config = {
let obj_notif_config = {
is_notify_each_image: NotificationConfig.is_notify_each_image,
is_notify_all_done: NotificationConfig.is_notify_all_done,
};
window.api.set_notification_config(obj_config)
let obj_email_config = {
is_enabled: document.getElementById("check_email_enabled").checked,
str_to: document.getElementById("input_email_to").value.trim(),
};
NotificationConfig.obj_email_config = obj_email_config;
window.api.set_notification_config(obj_notif_config)
.then(() => {
return window.api.set_email_config(obj_email_config);
})
.then(() => {
NotificationConfig._update_badge();
NotificationConfig.obj_modal.hide();
@@ -118,12 +205,47 @@ const NotificationConfig = {
});
},
_test_email: () => {
let obj_btn = document.getElementById("btn_test_email");
let obj_status = document.getElementById("label_email_test_status");
obj_btn.disabled = true;
obj_btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Envoi...';
obj_status.classList.add("d-none");
let obj_config = {
str_to: document.getElementById("input_email_to").value.trim(),
};
window.api.test_email(obj_config)
.then((obj_result) => {
obj_btn.disabled = false;
obj_btn.innerHTML = '<i class="mdi mdi-email-send-outline me-1"></i>Envoyer un test';
obj_status.classList.remove("d-none");
if (obj_result.is_success) {
obj_status.className = "badge bg-success";
obj_status.innerHTML = '<i class="mdi mdi-check-circle me-1"></i>Envoye';
} else {
obj_status.className = "badge bg-danger";
obj_status.textContent = obj_result.str_error || "Erreur";
}
})
.catch((obj_err) => {
obj_btn.disabled = false;
obj_btn.innerHTML = '<i class="mdi mdi-email-send-outline me-1"></i>Envoyer un test';
obj_status.classList.remove("d-none");
obj_status.className = "badge bg-danger";
obj_status.textContent = obj_err.message || "Erreur";
});
},
_update_badge: () => {
let obj_badge = document.getElementById("btn_notification_config");
let is_any_active = NotificationConfig.is_notify_each_image || NotificationConfig.is_notify_all_done;
if (is_any_active) {
let is_email = NotificationConfig.obj_email_config && NotificationConfig.obj_email_config.is_enabled;
if (is_any_active || is_email) {
obj_badge.className = "btn btn-sm btn-outline-success";
obj_badge.title = "Notifications activees";
obj_badge.title = "Notifications activees" + (is_email ? " + email" : "");
} else {
obj_badge.className = "btn btn-sm btn-outline-secondary";
obj_badge.title = "Notifications desactivees";

View File

@@ -7,6 +7,18 @@ const PreviewPanel = {
window.api.on_preview_update((str_image_path) => {
PreviewPanel.show_image(str_image_path);
});
let obj_btn_video = document.getElementById("btn_generate_video");
obj_btn_video.addEventListener("click", () => {
PreviewPanel._generate_video();
});
let obj_modal = document.getElementById("modal_video_preview");
obj_modal.addEventListener("hidden.bs.modal", () => {
let obj_video = document.getElementById("video_preview_player");
obj_video.pause();
obj_video.src = "";
});
},
show_image: (str_image_path) => {
@@ -55,4 +67,88 @@ const PreviewPanel = {
+ "Aucun rendu disponible"
+ "</div></div>";
},
_generate_video: () => {
if (!App.str_output_path) {
ConsoleLog.add("Veuillez selectionner un dossier de sortie.");
return;
}
let list_cameras = CameraList.list_cameras;
let is_has_enabled = false;
for (let obj_cam of list_cameras) {
if (obj_cam.is_enabled) {
is_has_enabled = true;
break;
}
}
if (!is_has_enabled) {
ConsoleLog.add("Aucune camera activee.");
return;
}
let obj_btn = document.getElementById("btn_generate_video");
obj_btn.disabled = true;
obj_btn.innerHTML = '<i class="mdi mdi-loading mdi-spin"></i>';
ConsoleLog.add("Generation de la video preview...");
let obj_ffmpeg_container = document.getElementById("container_ffmpeg_progress");
let obj_ffmpeg_label = document.getElementById("label_ffmpeg_time");
obj_ffmpeg_container.classList.remove("d-none");
obj_ffmpeg_label.textContent = "0s";
let nb_start_ms = Date.now();
let nb_timer = setInterval(() => {
let nb_elapsed = Math.round((Date.now() - nb_start_ms) / 1000);
obj_ffmpeg_label.textContent = PreviewPanel._format_elapsed(nb_elapsed);
}, 1000);
let str_output_mode = document.querySelector('input[name="output_mode"]:checked').value;
let str_frame_prefix = document.getElementById("input_frame_prefix").value || "";
let nb_frame_padding = parseInt(document.getElementById("input_frame_padding").value, 10) || 5;
let nb_fps = parseInt(document.getElementById("select_video_fps").value, 10) || 30;
let obj_params = {
str_output_path: App.str_output_path,
list_cameras: list_cameras,
nb_fps: nb_fps,
str_output_mode: str_output_mode,
str_frame_prefix: str_frame_prefix,
nb_frame_padding: nb_frame_padding,
};
window.api.generate_preview_video(obj_params)
.then((obj_result) => {
clearInterval(nb_timer);
obj_ffmpeg_container.classList.add("d-none");
obj_btn.disabled = false;
obj_btn.innerHTML = '<i class="mdi mdi-video-outline"></i>';
ConsoleLog.add("Video generee : " + obj_result.str_video_path);
let obj_video = document.getElementById("video_preview_player");
obj_video.src = "file://" + obj_result.str_video_path;
obj_video.load();
let obj_modal = new bootstrap.Modal(document.getElementById("modal_video_preview"));
obj_modal.show();
obj_video.play();
})
.catch((obj_err) => {
clearInterval(nb_timer);
obj_ffmpeg_container.classList.add("d-none");
obj_btn.disabled = false;
obj_btn.innerHTML = '<i class="mdi mdi-video-outline"></i>';
ConsoleLog.add("Erreur generation video : " + (obj_err.message || obj_err));
});
},
_format_elapsed: (nb_seconds) => {
let nb_m = Math.floor(nb_seconds / 60);
let nb_s = nb_seconds % 60;
if (nb_m > 0) {
return nb_m + "m " + nb_s + "s";
}
return nb_s + "s";
},
};

View File

@@ -11,6 +11,8 @@ const ProgressBar = {
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_avg_render_ms = obj_data.nb_avg_render_ms || 0;
let nb_remaining = obj_data.nb_remaining || 0;
let nb_percent = 0;
if (nb_total > 0) {
@@ -40,7 +42,21 @@ const ProgressBar = {
let obj_frame_label = document.getElementById("label_current_frame");
obj_frame_label.textContent = nb_frame !== null && nb_frame !== undefined ? 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 || [], obj_data.list_stopped || [], obj_data.list_skipped_paths || []);
let obj_time_label = document.getElementById("label_progress_time");
if (nb_avg_render_ms > 0 && nb_remaining > 0 && str_status === "running") {
let nb_remaining_ms = nb_avg_render_ms * nb_remaining;
let str_remaining = ProgressBar._format_duration(nb_remaining_ms);
let obj_eta = new Date(Date.now() + nb_remaining_ms);
let str_eta_hours = String(obj_eta.getHours()).padStart(2, "0");
let str_eta_minutes = String(obj_eta.getMinutes()).padStart(2, "0");
obj_time_label.textContent = "Restant : " + str_remaining + " — Fin : " + str_eta_hours + ":" + str_eta_minutes;
} else if (str_status === "idle" && nb_current >= nb_total && nb_total > 0) {
obj_time_label.textContent = "Termine";
} else {
obj_time_label.textContent = "";
}
RenderQueue.update_progress(obj_data);
},
reset: () => {
@@ -50,5 +66,30 @@ const ProgressBar = {
document.getElementById("label_progress_status").textContent = "En attente";
document.getElementById("label_current_camera").textContent = "-";
document.getElementById("label_current_frame").textContent = "-";
document.getElementById("label_progress_time").textContent = "";
},
_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

@@ -3,9 +3,14 @@ const RenderQueue = {
nb_total_render_ms: 0,
nb_completed_renders: 0,
nb_last_current: 0,
list_machine_avgs: [],
nb_local_avg_ms: 0,
str_hostname: "",
_obj_context_menu: null,
_nb_context_index: -1,
init: () => {
// Initialized on demand
RenderQueue._create_context_menu();
},
build_display: (str_mode, list_cameras) => {
@@ -13,6 +18,9 @@ const RenderQueue = {
RenderQueue.nb_total_render_ms = 0;
RenderQueue.nb_completed_renders = 0;
RenderQueue.nb_last_current = 0;
RenderQueue.list_machine_avgs = [];
RenderQueue.nb_local_avg_ms = 0;
RenderQueue.str_hostname = "";
let list_enabled = [];
for (let obj_cam of list_cameras) {
@@ -32,6 +40,7 @@ const RenderQueue = {
str_image_path: null,
obj_dom_el: null,
obj_dom_icon: null,
obj_dom_result: null,
str_dom_status: null,
is_click_bound: false,
});
@@ -60,6 +69,7 @@ const RenderQueue = {
str_image_path: null,
obj_dom_el: null,
obj_dom_icon: null,
obj_dom_result: null,
str_dom_status: null,
is_click_bound: false,
});
@@ -101,12 +111,20 @@ const RenderQueue = {
obj_frame.classList.add("text-light-emphasis");
obj_frame.textContent = "F" + obj_item.nb_frame;
let obj_result = document.createElement("small");
obj_result.classList.add("text-light-emphasis", "queue-item-result");
obj_result.style.minWidth = "140px";
obj_result.style.textAlign = "right";
obj_result.style.fontSize = "0.75em";
obj_el.appendChild(obj_icon);
obj_el.appendChild(obj_name);
obj_el.appendChild(obj_frame);
obj_el.appendChild(obj_result);
obj_item.obj_dom_el = obj_el;
obj_item.obj_dom_icon = obj_icon;
obj_item.obj_dom_result = obj_result;
obj_item.str_dom_status = "pending";
obj_fragment.appendChild(obj_el);
@@ -115,7 +133,21 @@ const RenderQueue = {
obj_container.appendChild(obj_fragment);
},
update_progress: (nb_current, nb_last_render_ms, str_last_image_path, list_skipped, list_stopped, list_skipped_paths) => {
update_progress: (obj_data) => {
let nb_current = obj_data.nb_current || 0;
let nb_last_render_ms = obj_data.nb_last_render_ms || 0;
let str_last_image_path = obj_data.str_last_image_path || null;
let list_skipped = obj_data.list_skipped || [];
let list_stopped = obj_data.list_stopped || [];
let list_skipped_paths = obj_data.list_skipped_paths || [];
let list_rendering_remote = obj_data.list_rendering_remote || [];
let list_item_results = obj_data.list_item_results || [];
let list_machine_avgs = obj_data.list_machine_avgs || [];
RenderQueue.str_hostname = obj_data.str_hostname || "";
RenderQueue.nb_local_avg_ms = obj_data.nb_avg_render_ms || 0;
RenderQueue.list_machine_avgs = list_machine_avgs;
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++;
@@ -134,9 +166,17 @@ const RenderQueue = {
}
}
// Build result map for quick lookup
let map_results = {};
for (let obj_r of list_item_results) {
map_results[obj_r.nb_index] = obj_r;
}
for (let nb_i = 0; nb_i < RenderQueue.list_items.length; nb_i++) {
if (list_stopped && list_stopped.indexOf(nb_i) !== -1) {
RenderQueue.list_items[nb_i].str_status = "stopped";
} else if (list_rendering_remote && list_rendering_remote.indexOf(nb_i) !== -1) {
RenderQueue.list_items[nb_i].str_status = "rendering_remote";
} else if (list_skipped && list_skipped.indexOf(nb_i) !== -1) {
RenderQueue.list_items[nb_i].str_status = "skipped";
} else if (nb_i < nb_current) {
@@ -146,6 +186,11 @@ const RenderQueue = {
} else {
RenderQueue.list_items[nb_i].str_status = "pending";
}
// Store result info on item
if (map_results[nb_i]) {
RenderQueue.list_items[nb_i].obj_result = map_results[nb_i];
}
}
RenderQueue._update_time_display();
@@ -163,7 +208,9 @@ const RenderQueue = {
let is_needs_click = (obj_item.str_status === "done" || obj_item.str_status === "skipped")
&& obj_item.str_image_path && !obj_item.is_click_bound;
if (obj_item.str_status === obj_item.str_dom_status && !is_needs_click) {
let is_status_changed = obj_item.str_status !== obj_item.str_dom_status;
if (!is_status_changed && !is_needs_click) {
if (obj_item.str_status === "rendering") {
obj_rendering_el = obj_item.obj_dom_el;
}
@@ -189,10 +236,18 @@ const RenderQueue = {
} else if (obj_item.str_status === "stopped") {
str_icon = "mdi-stop-circle";
str_color = "text-warning";
} else if (obj_item.str_status === "rendering_remote") {
str_icon = "mdi-desktop-classic";
str_color = "text-warning";
}
obj_item.obj_dom_icon.className = "mdi " + str_icon + " " + str_color;
// Update result column
if (obj_item.obj_dom_result) {
RenderQueue._update_result_cell(obj_item);
}
if (is_needs_click) {
obj_item.obj_dom_el.classList.add("queue-item-clickable");
let str_path = obj_item.str_image_path;
@@ -210,6 +265,45 @@ const RenderQueue = {
}
},
_update_result_cell: (obj_item) => {
let obj_r = obj_item.obj_result;
if (!obj_r) {
obj_item.obj_dom_result.textContent = "";
return;
}
if (obj_r.str_type === "done") {
let str_date = "";
if (obj_r.str_date) {
let obj_d = new Date(obj_r.str_date);
let str_day = String(obj_d.getDate()).padStart(2, "0");
let str_month = String(obj_d.getMonth() + 1).padStart(2, "0");
let str_hours = String(obj_d.getHours()).padStart(2, "0");
let str_minutes = String(obj_d.getMinutes()).padStart(2, "0");
str_date = str_day + "/" + str_month + " " + str_hours + ":" + str_minutes;
}
let str_res = obj_r.str_resolution || "";
obj_item.obj_dom_result.textContent = str_date + (str_date && str_res ? " | " : "") + str_res;
obj_item.obj_dom_result.className = "text-success queue-item-result";
obj_item.obj_dom_result.style.minWidth = "140px";
obj_item.obj_dom_result.style.textAlign = "right";
obj_item.obj_dom_result.style.fontSize = "0.75em";
} else if (obj_r.str_type === "rendering_remote") {
let str_host = obj_r.str_remote_hostname || "?";
let str_avg = "";
if (obj_r.nb_remote_avg_ms > 0) {
str_avg = " ~" + RenderQueue._format_duration(obj_r.nb_remote_avg_ms) + "/i";
}
obj_item.obj_dom_result.textContent = str_host + str_avg;
obj_item.obj_dom_result.className = "text-warning queue-item-result";
obj_item.obj_dom_result.style.minWidth = "140px";
obj_item.obj_dom_result.style.textAlign = "right";
obj_item.obj_dom_result.style.fontSize = "0.75em";
} else {
obj_item.obj_dom_result.textContent = "";
}
},
mark_existing: (list_existing) => {
for (let obj_existing of list_existing) {
if (obj_existing.nb_index < RenderQueue.list_items.length) {
@@ -226,22 +320,55 @@ const RenderQueue = {
return;
}
if (RenderQueue.nb_completed_renders === 0) {
let nb_local_avg = RenderQueue.nb_local_avg_ms;
if (nb_local_avg === 0 && RenderQueue.nb_completed_renders > 0) {
nb_local_avg = Math.round(RenderQueue.nb_total_render_ms / RenderQueue.nb_completed_renders);
}
if (nb_local_avg === 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") {
if (obj_item.str_status !== "done" && obj_item.str_status !== "skipped" && obj_item.str_status !== "rendering_remote") {
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);
// Part 1 : temps moyen machine actuelle /i
let str_local_avg = RenderQueue._format_duration(nb_local_avg) + "/i";
// Part 2 : temps restant si machine seule
let nb_remaining_solo_ms = nb_local_avg * nb_remaining_count;
let str_remaining_solo = RenderQueue._format_duration(nb_remaining_solo_ms);
// Part 3 : temps restant multi-machines
let list_avgs = RenderQueue.list_machine_avgs;
let nb_machines_with_data = 0;
let nb_sum_avgs = 0;
for (let obj_m of list_avgs) {
if (obj_m.nb_avg_ms > 0) {
nb_sum_avgs += obj_m.nb_avg_ms;
nb_machines_with_data++;
}
}
let str_remaining_multi = "";
if (nb_machines_with_data > 1) {
let nb_avg_of_avgs = nb_sum_avgs / nb_machines_with_data;
let nb_remaining_multi_ms = (nb_avg_of_avgs * nb_remaining_count) / nb_machines_with_data;
str_remaining_multi = RenderQueue._format_duration(nb_remaining_multi_ms);
}
let str_display = '<i class="mdi mdi-clock-outline me-1"></i>'
+ str_local_avg + " | " + str_remaining_solo;
if (str_remaining_multi) {
str_display += " | " + str_remaining_multi;
}
obj_label.innerHTML = str_display;
},
_format_duration: (nb_ms) => {
@@ -267,4 +394,262 @@ const RenderQueue = {
return str_result;
},
// ── Context menu ─────────────────────────────────────────
_create_context_menu: () => {
let obj_menu = document.createElement("div");
obj_menu.id = "context_menu_queue";
obj_menu.className = "context-menu d-none";
obj_menu.innerHTML =
'<div class="context-menu-item" data-action="preview"><i class="mdi mdi-eye-outline me-2"></i>Voir dans le preview</div>' +
'<div class="context-menu-item" data-action="open-folder"><i class="mdi mdi-folder-open-outline me-2"></i>Ouvrir dans l\'explorateur</div>' +
'<div class="context-menu-item" data-action="open-default"><i class="mdi mdi-open-in-new me-2"></i>Ouvrir (app par defaut)</div>' +
'<div class="context-menu-separator"></div>' +
'<div class="context-menu-item" data-action="copy-path"><i class="mdi mdi-content-copy me-2"></i>Copier le chemin</div>' +
'<div class="context-menu-item" data-action="copy-image"><i class="mdi mdi-image-outline me-2"></i>Copier l\'image</div>' +
'<div class="context-menu-separator"></div>' +
'<div class="context-menu-item" data-action="file-info"><i class="mdi mdi-information-outline me-2"></i>Infos fichier</div>' +
'<div class="context-menu-separator"></div>' +
'<div class="context-menu-item" data-action="delete"><i class="mdi mdi-delete-outline me-2"></i>Supprimer l\'image</div>' +
'<div class="context-menu-item context-menu-danger" data-action="delete-from-here"><i class="mdi mdi-delete-sweep-outline me-2"></i>Supprimer depuis ici</div>';
document.body.appendChild(obj_menu);
RenderQueue._obj_context_menu = obj_menu;
obj_menu.addEventListener("click", (obj_event) => {
let obj_target = obj_event.target.closest(".context-menu-item");
if (!obj_target) {
return;
}
let str_action = obj_target.dataset.action;
RenderQueue._hide_context_menu();
RenderQueue._handle_context_action(str_action);
});
document.addEventListener("click", () => {
RenderQueue._hide_context_menu();
});
document.addEventListener("keydown", (obj_event) => {
if (obj_event.key === "Escape") {
RenderQueue._hide_context_menu();
}
});
let obj_container = document.getElementById("container_render_queue");
obj_container.addEventListener("contextmenu", (obj_event) => {
let obj_el = obj_event.target.closest(".list-group-item");
if (!obj_el) {
return;
}
let nb_index = -1;
for (let nb_i = 0; nb_i < RenderQueue.list_items.length; nb_i++) {
if (RenderQueue.list_items[nb_i].obj_dom_el === obj_el) {
nb_index = nb_i;
break;
}
}
if (nb_index === -1) {
return;
}
let obj_item = RenderQueue.list_items[nb_index];
if (!obj_item.str_image_path) {
return;
}
obj_event.preventDefault();
RenderQueue._nb_context_index = nb_index;
RenderQueue._show_context_menu(obj_event.clientX, obj_event.clientY);
});
obj_container.addEventListener("scroll", () => {
RenderQueue._hide_context_menu();
});
},
_show_context_menu: (nb_x, nb_y) => {
let obj_menu = RenderQueue._obj_context_menu;
obj_menu.classList.remove("d-none");
obj_menu.style.left = nb_x + "px";
obj_menu.style.top = nb_y + "px";
let obj_rect = obj_menu.getBoundingClientRect();
if (obj_rect.right > window.innerWidth) {
obj_menu.style.left = (nb_x - obj_rect.width) + "px";
}
if (obj_rect.bottom > window.innerHeight) {
obj_menu.style.top = (nb_y - obj_rect.height) + "px";
}
},
_hide_context_menu: () => {
if (RenderQueue._obj_context_menu) {
RenderQueue._obj_context_menu.classList.add("d-none");
}
},
_handle_context_action: (str_action) => {
let nb_index = RenderQueue._nb_context_index;
if (nb_index < 0 || nb_index >= RenderQueue.list_items.length) {
return;
}
let obj_item = RenderQueue.list_items[nb_index];
let str_path = obj_item.str_image_path;
if (!str_path) {
return;
}
if (str_action === "preview") {
PreviewPanel.show_image(str_path);
} else if (str_action === "open-folder") {
window.api.show_item_in_folder(str_path)
.catch((obj_err) => {
ConsoleLog.add("Erreur ouverture dossier : " + (obj_err.message || obj_err));
});
} else if (str_action === "open-default") {
window.api.open_file_default(str_path)
.then((obj_result) => {
if (!obj_result.is_success) {
ConsoleLog.add("Erreur ouverture : " + obj_result.str_error);
}
})
.catch((obj_err) => {
ConsoleLog.add("Erreur ouverture : " + (obj_err.message || obj_err));
});
} else if (str_action === "copy-path") {
navigator.clipboard.writeText(str_path)
.then(() => {
ConsoleLog.add("Chemin copie : " + str_path);
})
.catch(() => {
ConsoleLog.add("Erreur copie chemin.");
});
} else if (str_action === "copy-image") {
window.api.copy_image_to_clipboard(str_path)
.then((obj_result) => {
if (obj_result.is_success) {
ConsoleLog.add("Image copiee dans le presse-papier.");
} else {
ConsoleLog.add("Erreur copie image : " + obj_result.str_error);
}
})
.catch((obj_err) => {
ConsoleLog.add("Erreur copie image : " + (obj_err.message || obj_err));
});
} else if (str_action === "file-info") {
window.api.get_file_info(str_path)
.then((obj_result) => {
if (obj_result.is_success) {
let nb_size_kb = Math.round(obj_result.nb_size / 1024);
let str_size = nb_size_kb > 1024
? (nb_size_kb / 1024).toFixed(1) + " Mo"
: nb_size_kb + " Ko";
let obj_date = new Date(obj_result.str_modified);
let str_date = String(obj_date.getDate()).padStart(2, "0") + "/"
+ String(obj_date.getMonth() + 1).padStart(2, "0") + "/"
+ obj_date.getFullYear() + " "
+ String(obj_date.getHours()).padStart(2, "0") + ":"
+ String(obj_date.getMinutes()).padStart(2, "0") + ":"
+ String(obj_date.getSeconds()).padStart(2, "0");
ConsoleLog.add("Infos : " + str_path);
ConsoleLog.add(" Taille : " + str_size + " — Modifie : " + str_date);
} else {
ConsoleLog.add("Erreur infos : " + obj_result.str_error);
}
})
.catch((obj_err) => {
ConsoleLog.add("Erreur infos : " + (obj_err.message || obj_err));
});
} else if (str_action === "delete") {
RenderQueue._delete_single(nb_index);
} else if (str_action === "delete-from-here") {
RenderQueue._delete_from_here(nb_index);
}
},
_delete_single: (nb_index) => {
let obj_item = RenderQueue.list_items[nb_index];
let str_path = obj_item.str_image_path;
window.api.delete_rendered_file(str_path)
.then((obj_result) => {
if (obj_result.is_success) {
obj_item.str_image_path = null;
obj_item.str_status = "deleted";
obj_item.is_click_bound = false;
RenderQueue._update_deleted_item(obj_item);
ConsoleLog.add("Supprime : " + str_path);
} else {
ConsoleLog.add("Erreur suppression : " + obj_result.str_error);
}
})
.catch((obj_err) => {
ConsoleLog.add("Erreur suppression : " + (obj_err.message || obj_err));
});
},
_delete_from_here: (nb_index) => {
let list_paths = [];
for (let nb_i = nb_index; nb_i < RenderQueue.list_items.length; nb_i++) {
let obj_it = RenderQueue.list_items[nb_i];
if (obj_it.str_image_path) {
list_paths.push(obj_it.str_image_path);
}
}
if (list_paths.length === 0) {
ConsoleLog.add("Aucun fichier a supprimer.");
return;
}
let is_confirm = confirm("Supprimer " + list_paths.length + " fichier(s) depuis cet element ?\n\nCette action est irreversible.");
if (!is_confirm) {
return;
}
window.api.delete_rendered_files(list_paths)
.then((obj_result) => {
if (obj_result.is_success) {
for (let nb_i = nb_index; nb_i < RenderQueue.list_items.length; nb_i++) {
let obj_it = RenderQueue.list_items[nb_i];
if (obj_it.str_image_path) {
obj_it.str_image_path = null;
obj_it.str_status = "deleted";
obj_it.is_click_bound = false;
RenderQueue._update_deleted_item(obj_it);
}
}
ConsoleLog.add(obj_result.nb_deleted + " fichier(s) supprime(s).");
if (obj_result.list_errors.length > 0) {
for (let str_err of obj_result.list_errors) {
ConsoleLog.add("Erreur : " + str_err);
}
}
}
})
.catch((obj_err) => {
ConsoleLog.add("Erreur suppression : " + (obj_err.message || obj_err));
});
},
_update_deleted_item: (obj_item) => {
if (!obj_item.obj_dom_el) {
return;
}
obj_item.obj_dom_icon.className = "mdi mdi-file-remove-outline text-muted";
obj_item.obj_dom_el.classList.remove("queue-item-clickable");
if (obj_item.obj_dom_result) {
obj_item.obj_dom_result.textContent = "supprime";
obj_item.obj_dom_result.className = "text-muted queue-item-result";
obj_item.obj_dom_result.style.minWidth = "140px";
obj_item.obj_dom_result.style.textAlign = "right";
obj_item.obj_dom_result.style.fontSize = "0.75em";
}
obj_item.str_dom_status = "deleted";
},
};

View File

@@ -0,0 +1,152 @@
const RenderSettings = {
obj_settings: null,
init: () => {
RenderSettings.obj_settings = {
str_engine: "CYCLES",
nb_samples: 128,
str_device: "GPU",
is_denoise: true,
is_film_transparent: false,
nb_resolution_percentage: 100,
};
},
set_from_blend: (obj_render) => {
if (!obj_render) {
return;
}
let obj_s = RenderSettings.obj_settings;
obj_s.str_engine = obj_render.str_engine || "CYCLES";
obj_s.nb_resolution_percentage = obj_render.nb_resolution_percentage || 100;
obj_s.is_film_transparent = !!obj_render.is_film_transparent;
if (obj_render.str_cycles_device) {
obj_s.str_device = obj_render.str_cycles_device;
}
if (obj_render.is_cycles_denoise !== undefined) {
obj_s.is_denoise = obj_render.is_cycles_denoise;
}
if (obj_render.nb_cycles_samples !== undefined) {
obj_s.nb_samples = obj_render.nb_cycles_samples;
} else if (obj_render.nb_eevee_samples !== undefined) {
obj_s.nb_samples = obj_render.nb_eevee_samples;
}
RenderSettings.render();
},
set_from_config: (obj_config) => {
if (!obj_config || !obj_config.obj_render_settings) {
return;
}
let obj_src = obj_config.obj_render_settings;
let obj_s = RenderSettings.obj_settings;
if (obj_src.str_engine !== undefined) { obj_s.str_engine = obj_src.str_engine; }
if (obj_src.nb_samples !== undefined) { obj_s.nb_samples = obj_src.nb_samples; }
if (obj_src.str_device !== undefined) { obj_s.str_device = obj_src.str_device; }
if (obj_src.is_denoise !== undefined) { obj_s.is_denoise = obj_src.is_denoise; }
if (obj_src.is_film_transparent !== undefined) { obj_s.is_film_transparent = obj_src.is_film_transparent; }
if (obj_src.nb_resolution_percentage !== undefined) { obj_s.nb_resolution_percentage = obj_src.nb_resolution_percentage; }
RenderSettings.render();
},
get_settings: () => {
return Object.assign({}, RenderSettings.obj_settings);
},
render: () => {
let obj_container = document.getElementById("container_render_settings");
if (!obj_container) {
return;
}
let obj_s = RenderSettings.obj_settings;
let is_cycles = obj_s.str_engine === "CYCLES";
let str_html = '<div class="row g-2">'
+ ' <div class="col-12">'
+ ' <label class="form-label form-label-sm">Moteur de rendu</label>'
+ ' <select class="form-select form-select-sm bg-dark text-light border-secondary" id="select_engine">'
+ ' <option value="CYCLES"' + (obj_s.str_engine === "CYCLES" ? " selected" : "") + ">Cycles</option>"
+ ' <option value="BLENDER_EEVEE_NEXT"' + (obj_s.str_engine === "BLENDER_EEVEE_NEXT" ? " selected" : "") + ">EEVEE</option>"
+ ' <option value="BLENDER_EEVEE"' + (obj_s.str_engine === "BLENDER_EEVEE" ? " selected" : "") + ">EEVEE (Legacy)</option>"
+ ' <option value="BLENDER_WORKBENCH"' + (obj_s.str_engine === "BLENDER_WORKBENCH" ? " selected" : "") + ">Workbench</option>"
+ " </select>"
+ " </div>"
+ ' <div class="col-6">'
+ ' <label class="form-label form-label-sm">Samples</label>'
+ ' <input type="number" class="form-control form-control-sm bg-dark text-light border-secondary" id="input_samples" value="' + obj_s.nb_samples + '" min="1">'
+ " </div>"
+ ' <div class="col-6">'
+ ' <label class="form-label form-label-sm">Resolution %</label>'
+ ' <input type="number" class="form-control form-control-sm bg-dark text-light border-secondary" id="input_res_percent" value="' + obj_s.nb_resolution_percentage + '" min="1" max="1000">'
+ " </div>"
+ ' <div class="col-12' + (is_cycles ? "" : " d-none") + '" id="container_device">'
+ ' <label class="form-label form-label-sm">Device</label>'
+ ' <select class="form-select form-select-sm bg-dark text-light border-secondary" id="select_device">'
+ ' <option value="GPU"' + (obj_s.str_device === "GPU" ? " selected" : "") + ">GPU</option>"
+ ' <option value="CPU"' + (obj_s.str_device === "CPU" ? " selected" : "") + ">CPU</option>"
+ " </select>"
+ " </div>"
+ ' <div class="col-6">'
+ ' <div class="form-check form-check-sm mt-1">'
+ ' <input class="form-check-input" type="checkbox" id="check_denoise"' + (obj_s.is_denoise ? " checked" : "") + ">"
+ ' <label class="form-check-label" for="check_denoise">Denoising</label>'
+ " </div>"
+ " </div>"
+ ' <div class="col-6">'
+ ' <div class="form-check form-check-sm mt-1">'
+ ' <input class="form-check-input" type="checkbox" id="check_film_transparent"' + (obj_s.is_film_transparent ? " checked" : "") + ">"
+ ' <label class="form-check-label" for="check_film_transparent">Transparent</label>'
+ " </div>"
+ " </div>"
+ "</div>";
obj_container.innerHTML = str_html;
RenderSettings._bind_events();
},
_bind_events: () => {
let obj_select_engine = document.getElementById("select_engine");
let obj_input_samples = document.getElementById("input_samples");
let obj_input_res_percent = document.getElementById("input_res_percent");
let obj_select_device = document.getElementById("select_device");
let obj_check_denoise = document.getElementById("check_denoise");
let obj_check_transparent = document.getElementById("check_film_transparent");
obj_select_engine.addEventListener("change", () => {
RenderSettings.obj_settings.str_engine = obj_select_engine.value;
let obj_device_container = document.getElementById("container_device");
if (obj_select_engine.value === "CYCLES") {
obj_device_container.classList.remove("d-none");
} else {
obj_device_container.classList.add("d-none");
}
});
obj_input_samples.addEventListener("change", () => {
RenderSettings.obj_settings.nb_samples = parseInt(obj_input_samples.value, 10) || 128;
});
obj_input_res_percent.addEventListener("change", () => {
RenderSettings.obj_settings.nb_resolution_percentage = parseInt(obj_input_res_percent.value, 10) || 100;
});
obj_select_device.addEventListener("change", () => {
RenderSettings.obj_settings.str_device = obj_select_device.value;
});
obj_check_denoise.addEventListener("change", () => {
RenderSettings.obj_settings.is_denoise = obj_check_denoise.checked;
});
obj_check_transparent.addEventListener("change", () => {
RenderSettings.obj_settings.is_film_transparent = obj_check_transparent.checked;
});
},
};

View File

@@ -39,7 +39,8 @@ body.has-update-banner .container-fluid {
height: 100%;
}
.col-md-4 {
.col-md-3,
.col-md-6 {
max-height: 100%;
overflow-y: auto;
}
@@ -75,6 +76,30 @@ body.has-update-banner .container-fluid {
border-color: #495057 !important;
}
/* ── Collection list ────────────────────────────────────────── */
#container_collection_list .list-group-item {
font-size: 0.8rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.collection-name {
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.collection-original-badge {
font-size: 0.7rem;
flex-shrink: 0;
}
.collection-original-badge .mdi {
font-size: 0.85rem;
}
/* ── Preview ────────────────────────────────────────────────── */
.preview-image {
@@ -221,6 +246,47 @@ body.has-update-banner .container-fluid {
background-color: #6c757d;
}
/* ── Context menu ──────────────────────────────────────────── */
.context-menu {
position: fixed;
z-index: 9999;
background-color: #1e1e2e;
border: 1px solid #444;
border-radius: 6px;
padding: 4px 0;
min-width: 220px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
.context-menu-item {
padding: 5px 12px;
font-size: 0.78rem;
color: #e1e4e8;
cursor: pointer;
display: flex;
align-items: center;
white-space: nowrap;
}
.context-menu-item:hover {
background-color: rgba(13, 110, 253, 0.25);
}
.context-menu-danger {
color: #f87171;
}
.context-menu-danger:hover {
background-color: rgba(248, 113, 113, 0.2);
}
.context-menu-separator {
height: 1px;
background-color: #444;
margin: 3px 8px;
}
/* ── Badge ──────────────────────────────────────────────────── */
.badge {

View File

@@ -1,3 +1,3 @@
{
"str_version": "1.2.0"
"str_version": "1.6.3"
}