diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 348ec5a..c4adbdd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,7 +31,8 @@ "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" + "mcp__ide__getDiagnostics", + "Bash(ffmpeg:*)" ] } } diff --git a/main.js b/main.js index 0aa438e..fa9cb56 100644 --- a/main.js +++ b/main.js @@ -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); +}); diff --git a/package-lock.json b/package-lock.json index 333454d..84d98ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "multi-render-blender", "version": "1.5.0", "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", diff --git a/package.json b/package.json index 2f76dc7..f1a1a1b 100644 --- a/package.json +++ b/package.json @@ -31,5 +31,8 @@ "target": "dir", "signAndEditExecutable": false } + }, + "dependencies": { + "nodemailer": "^8.0.1" } } diff --git a/preload.js b/preload.js index 24808fa..f44354c 100644 --- a/preload.js +++ b/preload.js @@ -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"); }, diff --git a/src/main/BlenderDaemon.js b/src/main/BlenderDaemon.js new file mode 100644 index 0000000..c6bf7a7 --- /dev/null +++ b/src/main/BlenderDaemon.js @@ -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; diff --git a/src/main/EmailNotifier.js b/src/main/EmailNotifier.js new file mode 100644 index 0000000..ad16db6 --- /dev/null +++ b/src/main/EmailNotifier.js @@ -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; diff --git a/src/main/PathResolver.js b/src/main/PathResolver.js index e2ccf2f..55e5ac5 100644 --- a/src/main/PathResolver.js +++ b/src/main/PathResolver.js @@ -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", diff --git a/src/main/QueueManager.js b/src/main/QueueManager.js index 8ca74b8..614052b 100644 --- a/src/main/QueueManager.js +++ b/src/main/QueueManager.js @@ -1,4 +1,5 @@ const BlenderProcess = require("./BlenderProcess.js"); +const BlenderDaemon = require("./BlenderDaemon.js"); const path = require("path"); const fs = require("fs"); const os = require("os"); @@ -23,12 +24,17 @@ class QueueManager { 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; @@ -49,9 +55,24 @@ class QueueManager { 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._process_next(); + + 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 }); } @@ -66,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); } } @@ -196,6 +233,42 @@ 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; @@ -203,10 +276,19 @@ class QueueManager { this._check_remote_completions(); - // Batch skip : boucle iterative pour eviter un stack overflow recursif + // 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 { @@ -247,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) { @@ -308,22 +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; @@ -383,7 +462,33 @@ class QueueManager { this.nb_current_index++; this._send_progress(); - this._process_next(); + + 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(); + } }); } @@ -440,9 +545,18 @@ class QueueManager { } } + 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, @@ -517,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); + }); + } } } diff --git a/src/main/VideoGenerator.js b/src/main/VideoGenerator.js new file mode 100644 index 0000000..03c8917 --- /dev/null +++ b/src/main/VideoGenerator.js @@ -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; diff --git a/src/python/render_daemon.py b/src/python/render_daemon.py new file mode 100644 index 0000000..f1fbb5a --- /dev/null +++ b/src/python/render_daemon.py @@ -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() diff --git a/src/renderer/index.html b/src/renderer/index.html index 4201522..bbc8003 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,7 +3,7 @@ - + Multi Render Blender @@ -229,8 +229,9 @@ En attente 0 / 0 -
+
+
@@ -264,8 +265,24 @@
-
- Preview +
+ Preview +
+
+
+
+ +
+
+ + +
@@ -296,6 +313,23 @@
+ + + @@ -310,6 +344,7 @@ + diff --git a/src/renderer/scripts/App.js b/src/renderer/scripts/App.js index 99e7478..11b8282 100644 --- a/src/renderer/scripts/App.js +++ b/src/renderer/scripts/App.js @@ -13,6 +13,7 @@ const App = { ProgressBar.init(); UpdateBanner.init(); BlenderPath.init(); + FfmpegPath.init(); NotificationConfig.init(); App._bind_events(); diff --git a/src/renderer/scripts/BlenderPath.js b/src/renderer/scripts/BlenderPath.js index dd362d2..2abaf37 100644 --- a/src/renderer/scripts/BlenderPath.js +++ b/src/renderer/scripts/BlenderPath.js @@ -36,9 +36,14 @@ const BlenderPath = { '' + '
' + '' + + '
'; + + 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 = 'Detection...'; + + window.api.get_ffmpeg_path() + .then((obj_data) => { + obj_btn.disabled = false; + obj_btn.innerHTML = '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 = '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 = 'Trouve'; + obj_btn_validate.disabled = false; + } else { + obj_label.className = "badge bg-danger"; + obj_label.innerHTML = 'Non trouve'; + obj_btn_validate.disabled = true; + } + }, +}; diff --git a/src/renderer/scripts/NotificationConfig.js b/src/renderer/scripts/NotificationConfig.js index 60b8ea2..71470c3 100644 --- a/src/renderer/scripts/NotificationConfig.js +++ b/src/renderer/scripts/NotificationConfig.js @@ -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 = - '