diff --git a/lib/client/new_job_window.py b/lib/client/new_job_window.py index ff50310..9f73b34 100755 --- a/lib/client/new_job_window.py +++ b/lib/client/new_job_window.py @@ -12,6 +12,7 @@ import psutil import requests import threading from lib.workers.blender_worker import Blender +from lib.workers.ffmpeg_worker import FFMPEG from lib.server.server_proxy import RenderServerProxy logger = logging.getLogger() @@ -57,6 +58,7 @@ class NewJobWindow(Frame): self.clients = clients or [] self.server_proxy = RenderServerProxy(hostname=clients[0] if clients else None) self.chosen_file = None + self.project_info = {} self.presets = {} self.renderer_info = {} self.priority = IntVar(value=2) @@ -128,15 +130,27 @@ class NewJobWindow(Frame): self.output_entry.pack(side=LEFT, padx=5, expand=True, fill=X) self.output_format = Combobox(output_frame, state="readonly", values=['JPG', 'MOV', 'PNG'], width=9) - self.output_format.pack(padx=5, pady=5) + self.output_format.pack(side=LEFT, padx=5, pady=5) self.output_format['state'] = DISABLED + # frame_range frame + frame_range_frame = Frame(job_frame) + frame_range_frame.pack(fill=X) + + Label(frame_range_frame, text="Frames", width=label_width).pack(side=LEFT, padx=5, pady=5, expand=False) + + self.start_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5) + self.start_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5) + + Label(frame_range_frame, text="to").pack(side=LEFT, pady=5, expand=False) + self.end_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5) + self.end_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5) + # Blender self.blender_frame = None self.blender_cameras_frame = None self.blender_engine = StringVar(value='CYCLES') self.blender_pack_textures = BooleanVar(value=False) - self.blender_render_all_frames = BooleanVar(value=False) self.blender_multiple_cameras = BooleanVar(value=False) self.blender_cameras_list = None @@ -177,10 +191,11 @@ class NewJobWindow(Frame): self.output_entry.delete(0, END) if self.chosen_file: # Generate a default output name - output_name = os.path.basename(self.chosen_file).split('.')[0] + output_name = os.path.splitext(os.path.basename(self.chosen_file))[-1].strip('.') self.output_entry.insert(0, os.path.basename(output_name)) + # Try to determine file type - extension = self.chosen_file.split('.')[-1] # not the best way to do this + extension = os.path.splitext(self.chosen_file)[-1].strip('.') # not the best way to do this for renderer, renderer_info in self.renderer_info.items(): supported = [x.lower().strip('.') for x in renderer_info.get('supported_extensions', [])] if extension.lower().strip('.') in supported: @@ -206,11 +221,29 @@ class NewJobWindow(Frame): if self.blender_frame: self.blender_frame.pack_forget() + if not self.chosen_file: + return + if renderer == 'blender': + self.project_info = Blender.get_scene_info(self.chosen_file) self.draw_blender_settings() + elif renderer == 'ffmpeg': + f = FFMPEG.get_frame_count(self.chosen_file) + self.project_info['frame_end'] = f + + # set frame start / end numbers fetched from fils + if self.project_info.get('frame_start'): + self.start_frame_spinbox.delete(0, 'end') + self.start_frame_spinbox.insert(0, self.project_info['frame_start']) + if self.project_info.get('frame_end'): + self.end_frame_spinbox.delete(0, 'end') + self.end_frame_spinbox.insert(0, self.project_info['frame_end']) + + # redraw lower ui self.draw_custom_args() self.draw_submit_button() + # check supported export formats if self.renderer_info.get(renderer, {}).get('supported_export_formats', None): formats = self.renderer_info[renderer]['supported_export_formats'] if formats and isinstance(formats[0], dict): @@ -257,12 +290,6 @@ class NewJobWindow(Frame): def draw_blender_settings(self): - scene_data = None - - # get file stats - if self.chosen_file: - scene_data = Blender.get_scene_info(self.chosen_file) - # blender settings self.blender_frame = LabelFrame(self, text="Blender Settings") self.blender_frame.pack(fill=X, padx=5) @@ -285,12 +312,10 @@ class NewJobWindow(Frame): Checkbutton(pack_frame, text="Pack Textures", variable=self.blender_pack_textures, onvalue=True, offvalue=False ).pack(anchor=W, side=LEFT, padx=5) - Checkbutton(pack_frame, text="Render All Frames", variable=self.blender_render_all_frames, onvalue=True, - offvalue=False).pack(anchor=W, side=LEFT, padx=5) # multi cams def draw_scene_cams(event=None): - if scene_data: + if self.project_info: show_cams_checkbutton['state'] = NORMAL if self.blender_multiple_cameras.get(): self.blender_cameras_frame = Frame(self.blender_frame) @@ -298,7 +323,7 @@ class NewJobWindow(Frame): Label(self.blender_cameras_frame, text="Cameras", width=label_width).pack(side=LEFT, padx=5, pady=5) - choices = [f"{x['name']} - {int(float(x['lens']))}mm" for x in scene_data['cameras']] + choices = [f"{x['name']} - {int(float(x['lens']))}mm" for x in self.project_info['cameras']] choices.sort() self.blender_cameras_list = ChecklistBox(self.blender_cameras_frame, choices, relief="sunken") self.blender_cameras_list.pack(padx=5, fill=X) @@ -310,7 +335,7 @@ class NewJobWindow(Frame): self.blender_cameras_frame.pack_forget() # multiple cameras checkbox - camera_count = len(scene_data.get('cameras', [])) if scene_data else 0 + camera_count = len(self.project_info.get('cameras', [])) if self.project_info else 0 show_cams_checkbutton = Checkbutton(pack_frame, text=f'Multiple Cameras ({camera_count})', offvalue=False, onvalue=True, variable=self.blender_multiple_cameras, command=draw_scene_cams) @@ -336,6 +361,8 @@ class NewJobWindow(Frame): 'client': client, 'output_path': os.path.join(os.path.dirname(self.chosen_file), self.output_entry.get()), 'args': {'raw': self.custom_args_entry.get()}, + 'start_frame': self.start_frame_spinbox.get(), + 'end_frame': self.end_frame_spinbox.get(), 'name': None} job_list = [] @@ -356,7 +383,6 @@ class NewJobWindow(Frame): return # add all Blender args job_json['args']['engine'] = self.blender_engine.get() - job_json['args']['render_all_frames'] = self.blender_render_all_frames.get() job_json['args']['export_format'] = self.output_format.get() # multiple camera rendering diff --git a/lib/engines/ffmpeg_engine.py b/lib/engines/ffmpeg_engine.py index a64b4ba..6990a64 100644 --- a/lib/engines/ffmpeg_engine.py +++ b/lib/engines/ffmpeg_engine.py @@ -22,20 +22,34 @@ class FFMPEG(BaseRenderEngine): @classmethod def get_encoders(cls): - encoders_raw = subprocess.check_output([cls.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL, + raw_stdout = subprocess.check_output([cls.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL, timeout=SUBPROCESS_TIMEOUT).decode('utf-8') pattern = '(?P[VASFXBD.]{6})\s+(?P\S{2,})\s+(?P.*)' - encoders = [m.groupdict() for m in re.finditer(pattern, encoders_raw)] + encoders = [m.groupdict() for m in re.finditer(pattern, raw_stdout)] return encoders @classmethod def get_all_formats(cls): - formats_raw = subprocess.check_output([cls.renderer_path(), '-formats'], stderr=subprocess.DEVNULL, + raw_stdout = subprocess.check_output([cls.renderer_path(), '-formats'], stderr=subprocess.DEVNULL, timeout=SUBPROCESS_TIMEOUT).decode('utf-8') pattern = '(?P[DE]{1,2})\s+(?P\S{2,})\s+(?P.*)' - formats = [m.groupdict() for m in re.finditer(pattern, formats_raw)] + formats = [m.groupdict() for m in re.finditer(pattern, raw_stdout)] return formats @classmethod def get_output_formats(cls): - return [x for x in cls.get_all_formats() if 'E' in x['type'].upper()] \ No newline at end of file + return [x for x in cls.get_all_formats() if 'E' in x['type'].upper()] + + @classmethod + def get_frame_count(cls, path_to_file): + raw_stdout = subprocess.check_output([cls.renderer_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy', + '-f', 'null', '-'], stderr=subprocess.STDOUT, + timeout=SUBPROCESS_TIMEOUT).decode('utf-8') + match = re.findall(r'frame=\s*(\d+)', raw_stdout) + if match: + frame_number = int(match[-1]) + return frame_number + + +if __name__ == "__main__": + print(FFMPEG.get_frame_count('/Users/brett/Desktop/Big_Fire_02.mov')) \ No newline at end of file diff --git a/lib/server/api_server.py b/lib/server/api_server.py index b60cb3c..4f408c3 100755 --- a/lib/server/api_server.py +++ b/lib/server/api_server.py @@ -347,8 +347,11 @@ def add_job_handler(): # prepare output paths output_dir = os.path.join(job_dir, job.get('name', None) or 'output') os.makedirs(output_dir, exist_ok=True) - job['output_path'] = os.path.join(output_dir, os.path.basename(job.get('name', None) or - job['output_path'])) + + # get new output path in output_dir + job['output_path'] = os.path.join(output_dir, os.path.basename( + job.get('name', None) or job.get('output_path', None) or loaded_project_local_path + )) # create & configure jobs render_job = RenderWorkerFactory.create_worker(renderer=job['renderer'], @@ -356,9 +359,11 @@ def add_job_handler(): output_path=job["output_path"], args=job.get('args', {})) render_job.client = server.config['HOSTNAME'] - render_job.owner = job.get("owner", None) - render_job.name = job.get("name", None) + render_job.owner = job.get("owner", render_job.owner) + render_job.name = job.get("name", render_job.name) render_job.priority = int(job.get('priority', render_job.priority)) + render_job.start_frame = job.get("start_frame", render_job.start_frame) + render_job.end_frame = job.get("end_frame", render_job.end_frame) RenderQueue.add_to_render_queue(render_job, force_start=job.get('force_start', False)) results.append(render_job.json()) diff --git a/lib/workers/base_worker.py b/lib/workers/base_worker.py index 7949e09..ec9afe7 100644 --- a/lib/workers/base_worker.py +++ b/lib/workers/base_worker.py @@ -48,7 +48,9 @@ class BaseRenderWorker(Base): renderer = Column(String) renderer_version = Column(String) priority = Column(Integer) - total_frames = Column(Integer) + project_length = Column(Integer) + start_frame = Column(Integer) + end_frame = Column(Integer, nullable=True) owner = Column(String) client = Column(String) name = Column(String) @@ -86,8 +88,10 @@ class BaseRenderWorker(Base): self.name = name # Frame Ranges - self.total_frames = 0 - self.current_frame = 0 + self.project_length = -1 + self.current_frame = 0 # should this be a 1 ? + self.start_frame = 0 # should this be a 1 ? + self.end_frame = None # Logging self.start_time = None @@ -107,6 +111,10 @@ class BaseRenderWorker(Base): self.is_finished = False self.last_output = None + @property + def total_frames(self): + return (self.end_frame or self.project_length) - self.start_frame + 1 + @property def status(self): return self._status @@ -119,6 +127,7 @@ class BaseRenderWorker(Base): def status(self): if self._status in [RenderStatus.RUNNING.value, RenderStatus.NOT_STARTED.value]: if not hasattr(self, 'errors'): + self._status = RenderStatus.CANCELLED return RenderStatus.CANCELLED return string_to_status(self._status) @@ -129,7 +138,7 @@ class BaseRenderWorker(Base): def generate_subprocess(self): # Convert raw args from string if available and catch conflicts - generated_args = self.generate_worker_subprocess() + generated_args = [str(x) for x in self.generate_worker_subprocess()] generated_args_flags = [x for x in generated_args if x.startswith('-')] if len(generated_args_flags) != len(set(generated_args_flags)): msg = "Cannot generate subprocess - Multiple arg conflicts detected" @@ -175,7 +184,7 @@ class BaseRenderWorker(Base): return self.status = RenderStatus.RUNNING - logger.info(f'Starting {self.engine.name()} {self.engine.version()} Render for {self.input_path}') + logger.info(f'Starting {self.engine.name()} {self.engine.version()} Render for {self.input_path} | Frame Count: {self.total_frames}') self.__thread.start() def run(self): diff --git a/lib/workers/blender_worker.py b/lib/workers/blender_worker.py index 4f55ebe..d4285c4 100644 --- a/lib/workers/blender_worker.py +++ b/lib/workers/blender_worker.py @@ -22,17 +22,16 @@ class BlenderRenderWorker(BaseRenderWorker): self.blender_engine = self.args.get('engine', 'BLENDER_EEVEE').upper() self.export_format = self.args.get('export_format', None) or 'JPEG' self.camera = self.args.get('camera', None) - self.render_all_frames = self.args.get('render_all_frames', False) or \ - '-a' in (self.args.get('raw', None) or "").split(' ') # Stats self.__frame_percent_complete = 0.0 # Scene Info self.scene_info = Blender.get_scene_info(input_path) - self.total_frames = (int(self.scene_info.get('frame_end', 1)) - int(self.scene_info.get('frame_start', 1)) + 1) \ - if self.render_all_frames else 1 - self.current_frame = int(self.scene_info.get('frame_start', 1)) + self.start_frame = int(self.scene_info.get('start_frame', 1)) + self.end_frame = int(self.scene_info.get('end_frame', self.start_frame)) + self.project_length = (self.end_frame - self.start_frame) + 1 + self.current_frame = -1 def generate_worker_subprocess(self): @@ -44,10 +43,12 @@ class BlenderRenderWorker(BaseRenderWorker): if self.camera: cmd.extend(['--python-expr', f"import bpy;bpy.context.scene.camera = bpy.data.objects['{self.camera}'];"]) - cmd.extend(['-E', self.blender_engine, '-o', self.output_path, '-F', self.export_format]) + # add dash at end of given path to separate frame numbers + path_with_ending_dash = os.path.splitext(self.output_path)[0] + "-" + os.path.splitext(self.output_path)[1] + cmd.extend(['-E', self.blender_engine, '-o', path_with_ending_dash, '-F', self.export_format]) - # all frames or single - cmd.extend(['-a'] if self.render_all_frames else ['-f', str(self.current_frame)]) + # set frame range + cmd.extend(['-s', self.start_frame, '-e', self.end_frame, '-a']) # Convert raw args from string if available raw_args = self.get_raw_args() @@ -96,10 +97,10 @@ class BlenderRenderWorker(BaseRenderWorker): match = re.match(r'Time: (.*) \(Saving', line) if match: time_completed = match.groups()[0] - if self.render_all_frames: - logger.debug(f'Frame {self.current_frame} completed in {time_completed}') - else: - logger.info(f'Render completed in {time_completed}') + frame_count = self.current_frame - self.end_frame + self.total_frames + logger.info(f'Frame #{self.current_frame} - ' + f'{frame_count} of {self.total_frames} completed in {time_completed} | ' + f'Total Elapsed Time: {datetime.now() - self.start_time}') else: logger.debug(line) else: @@ -118,13 +119,14 @@ class BlenderRenderWorker(BaseRenderWorker): def post_processing(self): output_dir = os.listdir(os.path.dirname(self.output_path)) - if self.render_all_frames and len(output_dir) > 1: + if self.total_frames > 1 and len(output_dir) > 1: from ..utilities.ffmpeg_helper import image_sequence_to_video logger.info("Generating preview for image sequence") # get proper file extension - found_output = next(obj for obj in output_dir if os.path.basename(self.output_path) in obj) - glob_pattern = self.output_path + '%04d' + ('.' + found_output.split('.')[-1] if found_output else "") + path_with_ending_dash = os.path.splitext(self.output_path)[0] + "-" + found_output = next(obj for obj in output_dir if os.path.basename(path_with_ending_dash) in obj) + glob_pattern = path_with_ending_dash + '%04d' + ('.' + found_output.split('.')[-1] if found_output else "") try: image_sequence_to_video(source_glob_pattern=glob_pattern, diff --git a/lib/workers/ffmpeg_worker.py b/lib/workers/ffmpeg_worker.py index 9ba2f9e..4f63be4 100644 --- a/lib/workers/ffmpeg_worker.py +++ b/lib/workers/ffmpeg_worker.py @@ -17,10 +17,7 @@ class FFMPEGRenderWorker(BaseRenderWorker): input_path, "-map", "0:v:0", "-c", "copy", "-f", "null", "-y", "/dev/null"], stderr=subprocess.STDOUT).decode('utf-8') found_frames = re.findall('frame=\s*(\d+)', stream_info) - self.total_frames = found_frames[-1] if found_frames else '-1' - self.frame = 0 - - # Stats + self.project_length = found_frames[-1] if found_frames else '-1' self.current_frame = -1 def generate_worker_subprocess(self):