mirror of
https://github.com/blw1138/Zordon.git
synced 2025-12-17 16:58:12 +00:00
Ability to set custom start / end frames (#14)
* Accept start / end frames in job submissions. Start / end frame support for Blender * Remove old render_all_frames variables and misc cleanup * Client work - Client determines frame count for FFMPEG and shows frame picker UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<type>[VASFXBD.]{6})\s+(?P<name>\S{2,})\s+(?P<description>.*)'
|
||||
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<type>[DE]{1,2})\s+(?P<name>\S{2,})\s+(?P<description>.*)'
|
||||
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()]
|
||||
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'))
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user