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;