343 lines
13 KiB
JavaScript
343 lines
13 KiB
JavaScript
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;
|