temps restant + video + download link + left click + show next frame done
This commit is contained in:
244
src/main/BlenderDaemon.js
Normal file
244
src/main/BlenderDaemon.js
Normal 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;
|
||||
107
src/main/EmailNotifier.js
Normal file
107
src/main/EmailNotifier.js
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
342
src/main/VideoGenerator.js
Normal file
342
src/main/VideoGenerator.js
Normal 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;
|
||||
Reference in New Issue
Block a user