temps restant + video + download link + left click + show next frame done

This commit is contained in:
sorlinv
2026-03-05 15:43:28 +01:00
parent 9ab59373df
commit b169e69b24
20 changed files with 2128 additions and 30 deletions

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;

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