diff --git a/app.py b/app.py index 9f9959e..59f4b1b 100644 --- a/app.py +++ b/app.py @@ -74,7 +74,7 @@ def render_and_submit_version(self, template, fields, first_frame, last_frame, s # Make sure we don't overwrite the caller's fields fields = copy.copy(fields) - # Tweak fields so that we'll be getting nuke formated sequence markers (%03d, %04d etc): + # Tweak fields so that we'll be getting nuke formatted sequence markers (%03d, %04d etc): for key_name in [key.name for key in template.keys.values() if isinstance(key, sgtk.templatekey.SequenceKey)]: fields[key_name] = "FORMAT: %d" diff --git a/hooks/codec_settings.py b/hooks/codec_settings.py index d397557..2475464 100644 --- a/hooks/codec_settings.py +++ b/hooks/codec_settings.py @@ -15,8 +15,6 @@ import os import sys -import nuke - HookBaseClass = sgtk.get_hook_baseclass() class CodecSettings(HookBaseClass): @@ -27,6 +25,11 @@ def get_quicktime_settings(self, **kwargs): Returns a dictionary of settings to be used for the Write Node that generates the Quicktime in Nuke. """ + # This hook file gets loaded even if overriden in sgtk_config. + # import causes error if not launched from nuke. + # Therefore, moving the import into the function. + import nuke + settings = {} if sys.platform in ["darwin", "win32"]: settings["file_type"] = "mov" diff --git a/hooks/nuke_batch_render_movie.py b/hooks/nuke_batch_render_movie.py new file mode 100644 index 0000000..1a2d308 --- /dev/null +++ b/hooks/nuke_batch_render_movie.py @@ -0,0 +1,258 @@ +import os +import pickle +import sys +import traceback +import getopt + +from sgtk.context import Context + +import nuke + + +def __create_scale_node(width, height): + """ + Create the Nuke scale node to resize the content. + """ + scale = nuke.nodes.Reformat() + scale["type"].setValue("to box") + scale["box_width"].setValue(width) + scale["box_height"].setValue(height) + scale["resize"].setValue("fit") + scale["box_fixed"].setValue(True) + scale["center"].setValue(True) + scale["black_outside"].setValue(True) + return scale + + +def __create_output_node(path, codec_settings, logger=None): + """ + Create the Nuke output node for the movie. + """ + # get the Write node settings we'll use for generating the Quicktime + + wn_settings = codec_settings.get('quicktime', {}) + + node = nuke.nodes.Write(file_type=wn_settings.get("file_type", '')) + + # apply any additional knob settings provided by the hook. Now that the knob has been + # created, we can be sure specific file_type settings will be valid. + for knob_name, knob_value in wn_settings.iteritems(): + if knob_name != "file_type": + node.knob(knob_name).setValue(knob_value) + + # Don't fail if we're in proxy mode. The default Nuke publish will fail if + # you try and publish while in proxy mode. But in earlier versions of + # tk-multi-publish (< v0.6.9) if there is no proxy template set, it falls + # back on the full-res version and will succeed. This handles that case + # and any custom cases where you may want to send your proxy render to + # screening room. + root_node = nuke.root() + is_proxy = root_node['proxy'].value() + if is_proxy: + if logger: + logger.info("Proxy mode is ON. Rendering proxy.") + node["proxy"].setValue(path.replace(os.sep, "/")) + else: + node["file"].setValue(path.replace(os.sep, "/")) + + return node + + +def render_movie_in_nuke(path, output_path, + width, height, + first_frame, last_frame, + version, name, + color_space, + app_settings, + ctx, render_info, + is_subprocess=False): + """ + Use Nuke to render a movie. This assumes we're running _inside_ Nuke. + + :param path: Path to the input frames for the movie + :param output_path: Path to the output movie that will be rendered + :param width: Width of the output movie + :param height: Height of the output movie + :param first_frame: Start frame for the output movie + :param last_frame: End frame for the output movie + :param version: Version number to use for the output movie slate and burn-in + :param name: Name to use in the slate for the output movie + :param color_space: Colorspace of the input frames + """ + output_node = None + root_node = nuke.root() + + if is_subprocess: + # set Nuke root settings (since this is a subprocess with a fresh session) + root_node["first_frame"].setValue(first_frame) + root_node["last_frame"].setValue(last_frame) + + # create group where everything happens + group = nuke.nodes.Group() + + # now operate inside this group + group.begin() + + try: + # create read node + read = nuke.nodes.Read(name="source", file=path.replace(os.sep, "/")) + read["on_error"].setValue("black") + read["first"].setValue(first_frame) + read["last"].setValue(last_frame) + if color_space: + read["colorspace"].setValue(color_space) + + if is_subprocess: + # set root_format = res of read node + read_format = read.format() + read_format.add('READ_FORMAT') + root_node.knob('format').setValue('READ_FORMAT') + + # now create the slate/burnin node + burn = nuke.nodePaste(render_info.get('burnin_nk')) + burn.setInput(0, read) + + font = render_info.get('slate_font') + + # set the fonts for all text fields + # TODO: find by class instead of using node names + burn.node("top_left_text")["font"].setValue(font) + burn.node("top_right_text")["font"].setValue(font) + burn.node("bottom_left_text")["font"].setValue(font) + burn.node("framecounter")["font"].setValue(font) + burn.node("slate_info")["font"].setValue(font) + + # add the logo + logo = app_settings.get('slate_logo', '') + if not os.path.isfile(logo): + logo = '' + + burn.node("logo")["file"].setValue(logo) + + # format the burnins + ver_num_pad = app_settings.get('version_number_padding', 4) + version_padding_format = "%%0%dd" % ver_num_pad + version_str = version_padding_format % version + + if ctx.task: + version_label = "%s, v%s" % (ctx.task["name"], version_str) + elif ctx.step: + version_label = "%s, v%s" % (ctx.step["name"], version_str) + else: + version_label = "v%s" % version_str + + # TODO: use context names instead positional so that the nodes can be moved around + burn.node("top_left_text")["message"].setValue(ctx.project["name"]) + burn.node("top_right_text")["message"].setValue(ctx.entity["name"]) + burn.node("bottom_left_text")["message"].setValue(version_label) + + # and the slate + slate_str = "Project: %s\n" % ctx.project["name"] + slate_str += "%s: %s\n" % (ctx.entity["type"], ctx.entity["name"]) + slate_str += "Name: %s\n" % name.capitalize() + slate_str += "Version: %s\n" % version_str + + if ctx.task: + slate_str += "Task: %s\n" % ctx.task["name"] + elif ctx.step: + slate_str += "Step: %s\n" % ctx.step["name"] + + slate_str += "Frames: %s - %s\n" % (first_frame, last_frame) + + burn.node("slate_info")["message"].setValue(slate_str) + + # Create a scale node + scale = __create_scale_node(width, height) + scale.setInput(0, burn) + + # Create the output node + output_node = __create_output_node(output_path, render_info.get('codec_settings', {})) + output_node.setInput(0, scale) + finally: + group.end() + + if output_node: + # Make sure the output folder exists + output_folder = os.path.dirname(output_path) + # TODO: jsmk stuff? + if not os.path.isdir(output_folder): + os.makedirs(output_folder) + + # Render the outputs, first view only + nuke.executeMultiple([output_node], ([first_frame-1, last_frame, 1],), + [nuke.views()[0]]) + + # Cleanup after ourselves + nuke.delete(group) + + +def get_usage(): + return ''' + Usage: python {0} [ OPTIONS ] + -h | --help ... print this usage message and exit. + --path ... specify full path to frames, with frame spec ... e.g. ".%04d.exr" + --output_path ... specify full path to output movie + --width ... specify width for output movie + --height ... specify height for output movie + --first_frame ... specify first frame number of the input frames + --last_frame ... specify last frame number of the input frames + --version ... specify version number for the slate + --name ... specify name for the slate + --color_space ... specify color space to use for the movie generation + --app_settings ... specify app settings from the Toolkit app calling this + --shotgun_context ... specify shotgun context from the Toolkit app calling this + --render_info ... specify render info from the Toolkit app calling this +'''.format(os.path.basename(sys.argv[0])) + + +if __name__ == '__main__': + # TODO: copied maquino's code. Refactor? + data_keys = [ + 'path', 'output_path', 'width', 'height', 'first_frame', 'last_frame', + 'version', 'name', 'color_space', 'app_settings', 'shotgun_context', 'render_info', + ] + + non_str_data_list = { + 'width', 'height', 'first_frame', 'last_frame', 'version', + 'app_settings', 'shotgun_context', 'render_info', + } + + short_opt_str = "h" + long_opt_list = ['help'] + ['{0}='.format(k) for k in data_keys] + + input_data = {} + try: + opt_list, arg_list = getopt.getopt(sys.argv[1:], short_opt_str, long_opt_list) + except getopt.GetoptError as err: + sys.stderr.write(str(err)) + sys.stderr.write(get_usage()) + sys.exit(1) + + for opt, opt_value in opt_list: + if opt in ('-h', '--help'): + print get_usage() + sys.exit(0) + elif opt.replace('--', '') in data_keys: + d_key = opt.replace('--', '') + if d_key == 'shotgun_context': + input_data[d_key] = Context.deserialize(opt_value) + elif d_key in non_str_data_list: + input_data[d_key] = pickle.loads(opt_value) + else: + input_data[d_key] = opt_value + + for d_key in data_keys: + if d_key not in input_data: + sys.stderr.write('ERROR - missing input argument for "--{0}". Aborting'.format(d_key)) + sys.stderr.write(get_usage()) + sys.exit(2) + + render_movie_in_nuke(input_data['path'], input_data['output_path'], + input_data['width'], input_data['height'], + input_data['first_frame'], input_data['last_frame'], + input_data['version'], input_data['name'], + input_data['color_space'], + input_data['app_settings'], + input_data['shotgun_context'], + input_data['render_info'], + is_subprocess=True) diff --git a/info.yml b/info.yml index daea222..0b9dfb4 100644 --- a/info.yml +++ b/info.yml @@ -73,8 +73,9 @@ configuration: if they are defined." slate_logo: - type: config_path - description: This is the path to an image to use on the slate such as a + type: template + fields: context + description: This is the template for a path to an image to use on the slate such as a company logo. The supplied image will be reformated into a 400 pixel box and the lower left corner of the logo will be aligned 100 pixels to the right and 100 pixels above the @@ -82,7 +83,6 @@ configuration: an alpha channel if you want to add transparency. Currently any image format supported by Nuke is adequate. If this setting is an empty string, no logo will be applied. - default_value: "" codec_settings_hook: type: hook @@ -90,6 +90,26 @@ configuration: for review. default_value: '{self}/codec_settings.py' + nuke_linux_path: + type: str + description: The path to the application executable on Linux. + default_value: "" + + nuke_windows_path: + type: str + description: The path to the application executable on Windows. + default_value: "" + + nuke_mac_path: + type: str + description: The path to the application executable on Mac OS X. + default_value: "" + + burnin_path: + type: template + fields: context + description: Template for burnin nuke file path + # the Shotgun fields that this app needs in order to operate correctly requires_shotgun_fields: @@ -103,7 +123,7 @@ requires_core_version: "v0.14.0" requires_engine_version: # For now this app is only compatible with Nuke. -supported_engines: ["tk-nuke"] +supported_engines: # the frameworks required to run this app frameworks: diff --git a/python/tk_multi_reviewsubmission/renderer.py b/python/tk_multi_reviewsubmission/renderer.py index 0af42bc..b00460b 100644 --- a/python/tk_multi_reviewsubmission/renderer.py +++ b/python/tk_multi_reviewsubmission/renderer.py @@ -1,35 +1,46 @@ # Copyright (c) 2013 Shotgun Software Inc. -# +# # CONFIDENTIAL AND PROPRIETARY -# -# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit # Source Code License included in this distribution package. See LICENSE. -# By accessing, using, copying or modifying this work you indicate your -# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. import sgtk import os +import pickle import sys -import nuke +import subprocess +from sgtk.platform.qt import QtCore + +try: + import nuke +except ImportError: + nuke = None + class Renderer(object): - def __init__(self): """ Construction """ - self.__app = sgtk.platform.current_bundle() - - self._burnin_nk = os.path.join(self.__app.disk_location, "resources", "burnin.nk") + self.__app = sgtk.platform.current_bundle() self._font = os.path.join(self.__app.disk_location, "resources", "liberationsans_regular.ttf") - - # If the slate_logo supplied was an empty string, the result of getting - # the setting will be the config folder which is invalid so catch that - # and make our logo path an empty string which Nuke won't have issues with. + context_fields = self.__app.context.as_template_fields() + + burnin_template = self.__app.get_template("burnin_path") + self._burnin_nk = burnin_template.apply_fields(context_fields) + # If a show specific burnin file has not been defined, take it from the default location + if not os.path.isfile(self._burnin_nk): + self._burnin_nk = os.path.join(self.__app.disk_location, "resources", "burnin.nk") + self._logo = None - if os.path.isfile( self.__app.get_setting("slate_logo", "") ): - self._logo = self.__app.get_setting("slate_logo", "") + logo_template = self.__app.get_template("slate_logo") + logo_file_path = logo_template.apply_fields(context_fields) + if os.path.isfile(logo_file_path): + self._logo = logo_file_path else: self._logo = "" @@ -37,151 +48,164 @@ def __init__(self): if sys.platform == "win32": self._font = self._font.replace(os.sep, "/") self._logo = self._logo.replace(os.sep, "/") - self._burnin_nk = self._burnin_nk.replace(os.sep, "/") + self._burnin_nk = self._burnin_nk.replace(os.sep, "/") + + def gather_nuke_render_info(self, path, output_path, + width, height, + first_frame, last_frame, + version, name, + color_space): + # First get Nuke executable path from project configuration environment + setting_key_by_os = {'win32': 'nuke_windows_path', + 'linux2': 'nuke_linux_path', + 'darwin': 'nuke_mac_path'} + nuke_exe_path = self.__app.get_setting(setting_key_by_os[sys.platform]) - def render_movie_in_nuke(self, path, output_path, - width, height, - first_frame, last_frame, - version, name, - color_space): - """ - Use Nuke to render a movie. This assumes we're running _inside_ Nuke. - - :param path: Path to the input frames for the movie - :param output_path: Path to the output movie that will be rendered - :param width: Width of the output movie - :param height: Height of the output movie - :param first_frame: Start frame for the output movie - :param last_frame: End frame for the output movie - :param version: Version number to use for the output movie slate and burn-in - :param name: Name to use in the slate for the output movie - :param color_space: Colorspace of the input frames - """ - output_node = None - ctx = self.__app.context - - # create group where everything happens - group = nuke.nodes.Group() - - # now operate inside this group - group.begin() - try: - # create read node - read = nuke.nodes.Read(name="source", file=path.replace(os.sep, "/")) - read["on_error"].setValue("black") - read["first"].setValue(first_frame) - read["last"].setValue(last_frame) - if color_space: - read["colorspace"].setValue(color_space) - - # now create the slate/burnin node - burn = nuke.nodePaste(self._burnin_nk) - burn.setInput(0, read) - - # set the fonts for all text fields - burn.node("top_left_text")["font"].setValue(self._font) - burn.node("top_right_text")["font"].setValue(self._font) - burn.node("bottom_left_text")["font"].setValue(self._font) - burn.node("framecounter")["font"].setValue(self._font) - burn.node("slate_info")["font"].setValue(self._font) - - # add the logo - burn.node("logo")["file"].setValue(self._logo) - - # format the burnins - version_padding_format = "%%0%dd" % self.__app.get_setting("version_number_padding") - version_str = version_padding_format % version - - if ctx.task: - version_label = "%s, v%s" % (ctx.task["name"], version_str) - elif ctx.step: - version_label = "%s, v%s" % (ctx.step["name"], version_str) - else: - version_label = "v%s" % version_str - - burn.node("top_left_text")["message"].setValue(ctx.project["name"]) - burn.node("top_right_text")["message"].setValue(ctx.entity["name"]) - burn.node("bottom_left_text")["message"].setValue(version_label) - - # and the slate - slate_str = "Project: %s\n" % ctx.project["name"] - slate_str += "%s: %s\n" % (ctx.entity["type"], ctx.entity["name"]) - slate_str += "Name: %s\n" % name.capitalize() - slate_str += "Version: %s\n" % version_str - - if ctx.task: - slate_str += "Task: %s\n" % ctx.task["name"] - elif ctx.step: - slate_str += "Step: %s\n" % ctx.step["name"] - - slate_str += "Frames: %s - %s\n" % (first_frame, last_frame) - - burn.node("slate_info")["message"].setValue(slate_str) - - # create a scale node - scale = self.__create_scale_node(width, height) - scale.setInput(0, burn) - - # Create the output node - output_node = self.__create_output_node(output_path) - output_node.setInput(0, scale) - finally: - group.end() - - if output_node: - # Make sure the output folder exists - output_folder = os.path.dirname(output_path) - self.__app.ensure_folder_exists(output_folder) - - # Render the outputs, first view only - nuke.executeMultiple([output_node], ([first_frame-1, last_frame, 1],), [nuke.views()[0]]) - - # Cleanup after ourselves - nuke.delete(group) - - - def __create_scale_node(self, width, height): - """ - Create the Nuke scale node to resize the content. - """ - scale = nuke.nodes.Reformat() - scale["type"].setValue("to box") - scale["box_width"].setValue(width) - scale["box_height"].setValue(height) - scale["resize"].setValue("fit") - scale["box_fixed"].setValue(True) - scale["center"].setValue(True) - scale["black_outside"].setValue(True) - return scale - - def __create_output_node(self, path): - """ - Create the Nuke output node for the movie. - """ # get the Write node settings we'll use for generating the Quicktime - wn_settings = self.__app.execute_hook_method("codec_settings_hook", - "get_quicktime_settings") - - node = nuke.nodes.Write(file_type=wn_settings.get("file_type")) - - # apply any additional knob settings provided by the hook. Now that the knob has been - # created, we can be sure specific file_type settings will be valid. - for knob_name, knob_value in wn_settings.iteritems(): - if knob_name != "file_type": - node.knob(knob_name).setValue(knob_value) - - # Don't fail if we're in proxy mode. The default Nuke publish will fail if - # you try and publish while in proxy mode. But in earlier versions of - # tk-multi-publish (< v0.6.9) if there is no proxy template set, it falls - # back on the full-res version and will succeed. This handles that case - # and any custom cases where you may want to send your proxy render to - # screening room. - root_node = nuke.root() - is_proxy = root_node['proxy'].value() - if is_proxy: - self.__app.log_info("Proxy mode is ON. Rendering proxy.") - node["proxy"].setValue(path.replace(os.sep, "/")) + writenode_quicktime_settings = self.__app.execute_hook_method("codec_settings_hook", + "get_quicktime_settings") + + render_script_path = os.path.join(self.__app.disk_location, "hooks", + "nuke_batch_render_movie.py") + + serialized_context = self.__app.context.serialize() + + app_settings = { + 'version_number_padding': self.__app.get_setting('version_number_padding'), + 'slate_logo': self._logo, + } + + render_info = { + 'burnin_nk': self._burnin_nk, + 'slate_font': self._font, + 'codec_settings': {'quicktime': writenode_quicktime_settings}, + } + + # set needed paths and force them to use forward slashes for use in Nuke (for Windows) + src_frames_path = path.replace('\\', '/') + movie_output_path = output_path.replace('\\', '/') + + nuke_render_info = { + 'width': width, + 'height': height, + 'first_frame': first_frame, + 'last_frame': last_frame, + 'version': version, + 'name': name, + 'color_space': color_space, + 'nuke_exe_path': nuke_exe_path, + 'render_script_path': render_script_path, + 'serialized_context': serialized_context, + 'app_settings': app_settings, + 'render_info': render_info, + 'src_frames_path': src_frames_path, + 'movie_output_path': movie_output_path, + } + return nuke_render_info + + def render_movie_in_nuke(self, path, output_path, + width, height, + first_frame, last_frame, + version, name, + color_space, + active_progress_info=None): + + render_info = self.gather_nuke_render_info(path, output_path, width, height, first_frame, + last_frame, version, name, color_space) + run_in_batch_mode = True if nuke is None else False + + event_loop = QtCore.QEventLoop() + thread = ShooterThread(render_info, run_in_batch_mode) + thread.finished.connect(event_loop.quit) + thread.start() + event_loop.exec_() + + # log any errors generated in the thread + thread_error_msg = thread.get_errors() + if thread_error_msg: + self.__app.log_error("OUTPUT:\n" + thread.get_output()) + self.__app.log_error("ERROR:\n" + thread_error_msg) + + # Do not clutter user message with any warnings etc from Nuke. Print only traceback. + # TODO: is there a better way? + try: + subproc_traceback = 'Traceback' + thread_error_msg.split('Traceback')[1] + except IndexError: + subproc_traceback = thread_error_msg + # Make sure we don't display a success message. TODO: Custom exception? + raise Exception("Error in tk-multi-reviewsubmission: " + subproc_traceback) + +class ShooterThread(QtCore.QThread): + def __init__(self, render_info, batch_mode=True, active_progress_info=None): + QtCore.QThread.__init__(self) + self.render_info = render_info + self.batch_mode = batch_mode + self.active_progress_info = active_progress_info + self.subproc_error_msg = '' + self.subproc_output = '' + + def get_errors(self): + return self.subproc_error_msg + + def get_output(self): + return self.subproc_output + + def run(self): + if self.batch_mode: + nuke_flag = '-t' else: - node["file"].setValue(path.replace(os.sep, "/")) + nuke_flag = '-it' + + cmd_and_args = [ + self.render_info['nuke_exe_path'], nuke_flag, self.render_info['render_script_path'], + '--path', self.render_info['src_frames_path'], + '--output_path', self.render_info['movie_output_path'], + '--width', pickle.dumps(self.render_info['width']), + '--height', pickle.dumps(self.render_info['height']), + '--version', pickle.dumps(self.render_info['version']), + '--name', self.render_info['name'], + '--color_space', self.render_info['color_space'], + '--first_frame', pickle.dumps(self.render_info['first_frame']), + '--last_frame', pickle.dumps(self.render_info['last_frame']), + '--app_settings', pickle.dumps(self.render_info['app_settings']), + '--shotgun_context', self.render_info['serialized_context'], + '--render_info', pickle.dumps(self.render_info['render_info']), + ] + + p = subprocess.Popen(cmd_and_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + output_name = os.path.basename(self.render_info['movie_output_path']) + # TODO: How is this supposed to work? + progress_fn = progress_label = None + if self.active_progress_info: + progress_fn = self.active_progress_info.get('show_progress_fn') + progress_label = self.active_progress_info.get('label') + + output_lines = [] + error_lines = [] + + num_frames = self.render_info['last_frame'] - self.render_info['first_frame'] + 1 + write_count = 0 + + while p.poll() is None: + stdout_line = p.stdout.readline() + stderr_line = p.stderr.readline() + + if stdout_line != '': + output_lines.append(stdout_line.rstrip()) + if stderr_line != '': + error_lines.append(stderr_line.rstrip()) + + percent_complete = float(write_count) / float(num_frames) * 100.0 + if progress_fn: + progress_fn(progress_label, + 'Nuke: {0:03.1f}%, {1}'.format(percent_complete, output_name)) + # TODO: emit a signal to be captured by calling thread to update UI? + + if stdout_line.startswith('Writing '): + # The number of these lines will be number of frames + 1 + write_count += 1 - return node \ No newline at end of file + self.subproc_output = '\n'.join(output_lines) + if p.returncode != 0: + self.subproc_error_msg = '\n'.join(error_lines) diff --git a/python/tk_multi_reviewsubmission/submitter.py b/python/tk_multi_reviewsubmission/submitter.py index 444ec07..c58c279 100644 --- a/python/tk_multi_reviewsubmission/submitter.py +++ b/python/tk_multi_reviewsubmission/submitter.py @@ -93,8 +93,12 @@ def _upload_files(self, sg_version, output_path, thumbnail_path, upload_to_shotg event_loop.exec_() # log any errors generated in the thread - for e in thread.get_errors(): - self.__app.log_error(e) + thread_errors = thread.get_errors() + if thread_errors: + for e in thread_errors: + self.__app.log_error(e) + # make sure we don't display a success message. TODO: Custom exception? + raise Exception('\n'.join(thread_errors))