diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 49f0eac41df..a2cbc1b7047 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -58,6 +58,7 @@ def process(self, instance): pixel_aspect = inst_data.get("pixelAspect", 1) fps = inst_data.get("fps") + self.log.debug("fps {} ".format(fps)) for idx, repre in enumerate(inst_data["representations"]): self.log.debug("repre ({}): `{}`".format(idx + 1, repre)) @@ -73,20 +74,16 @@ def process(self, instance): os.path.normpath(stagingdir), repre["files"]) self.log.debug("__ input_path: {}".format(input_path)) - video_streams = get_ffprobe_streams( + streams = get_ffprobe_streams( input_path, self.log ) - - # Try to find first stream with defined 'width' and 'height' - # - this is to avoid order of streams where audio can be as first - # - there may be a better way (checking `codec_type`?) - input_width = None - input_height = None - for stream in video_streams: - if "width" in stream and "height" in stream: - input_width = int(stream["width"]) - input_height = int(stream["height"]) - break + # Get video metadata + ( + input_width, + input_height, + input_timecode, + input_frame_rate + ) = self._get_video_metadata(streams) # Raise exception of any stream didn't define input resolution if input_width is None: @@ -94,6 +91,14 @@ def process(self, instance): "FFprobe couldn't read resolution from input file: \"{}\"" ).format(input_path)) + ( + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, + input_audio + ) = self._get_audio_metadata(streams) + # values are set in ExtractReview if use_legacy_code: to_width = inst_data["reviewToWidth"] @@ -149,14 +154,27 @@ def process(self, instance): input_args.extend(repre["_profile"].get('input', [])) else: input_args.extend(repre["outputDef"].get('input', [])) - input_args.append("-loop 1 -i {}".format( - path_to_subprocess_arg(slate_path) - )) + input_args.extend([ - "-r {}".format(fps), - "-t 0.04" + "-loop", "1", + "-i", openpype.lib.path_to_subprocess_arg(slate_path), + "-r", str(input_frame_rate), + "-frames:v", "1", ]) + # add timecode from source to the slate, substract one frame + offset_timecode = "" + if input_timecode: + offset_timecode = self._tc_offset( + str(input_timecode), + framerate=fps, + frame_offset=-1 + ) + self.log.debug("Slate Timecode: `{}`".format( + offset_timecode + )) + input_args.extend(["-timecode", str(offset_timecode)]) + if use_legacy_code: format_args = [] codec_args = repre["_profile"].get('codec', []) @@ -171,10 +189,10 @@ def process(self, instance): # make sure colors are correct output_args.extend([ - "-vf scale=out_color_matrix=bt709", - "-color_primaries bt709", - "-color_trc bt709", - "-colorspace bt709" + "-vf", "scale=out_color_matrix=bt709", + "-color_primaries", "bt709", + "-color_trc", "bt709", + "-colorspace", "bt709", ]) # scaling none square pixels and 1920 width @@ -210,15 +228,24 @@ def process(self, instance): "__ height_half_pad: `{}`".format(height_half_pad) ) - scaling_arg = ("scale={0}x{1}:flags=lanczos," - "pad={2}:{3}:{4}:{5}:black,setsar=1").format( - width_scale, height_scale, to_width, to_height, - width_half_pad, height_half_pad + scaling_arg = ( + "scale={0}x{1}:flags=lanczos" + ",pad={2}:{3}:{4}:{5}:black" + ",setsar=1" + ",fps={6}" + ).format( + width_scale, + height_scale, + to_width, + to_height, + width_half_pad, + height_half_pad, + input_frame_rate ) - vf_back = self.add_video_filter_args(output_args, scaling_arg) - # add it to output_args - output_args.insert(0, vf_back) + vf_back = self.add_video_filter_args(output_args, scaling_arg) + # add it to output_args + output_args.insert(0, vf_back) # overrides output file output_args.append("-y") @@ -244,6 +271,25 @@ def process(self, instance): slate_subprocess_cmd, shell=True, logger=self.log ) + # Create slate with silent audio track + if input_audio: + # silent slate output path + slate_silent_path = "_silent".join( + os.path.splitext(slate_v_path)) + _remove_at_end.append(slate_silent_path) + self._create_silent_slate( + ffmpeg_path, + slate_v_path, + slate_silent_path, + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, + ) + + # replace slate with silent slate for concat + slate_v_path = slate_silent_path + # create ffmpeg concat text file path conc_text_file = input_file.replace(ext, "") + "_concat" + ".txt" conc_text_path = os.path.join( @@ -269,12 +315,27 @@ def process(self, instance): "-i", conc_text_path, "-c", "copy", ] + if offset_timecode: + concat_args.extend(["-timecode", offset_timecode]) # NOTE: Added because of OP Atom demuxers # Add format arguments if there are any # - keep format of output if format_args: concat_args.extend(format_args) - # Add final output path + # Use arguments from ffmpeg preset + source_ffmpeg_cmd = repre.get("ffmpeg_cmd") + if source_ffmpeg_cmd: + copy_args = ( + "-metadata", + "-metadata:s:v:0", + ) + args = source_ffmpeg_cmd.split(" ") + for indx, arg in enumerate(args): + if arg in copy_args: + concat_args.append(arg) + # assumes arg has one parameter + concat_args.append(args[indx + 1]) + # add final output path concat_args.append(output_path) # ffmpeg concat subprocess @@ -309,6 +370,129 @@ def process(self, instance): self.log.debug(inst_data["representations"]) + def _get_video_metadata(self, streams): + input_timecode = "" + input_width = None + input_height = None + input_frame_rate = None + for stream in streams: + if stream.get("codec_type") != "video": + continue + self.log.debug("FFprobe Video: {}".format(stream)) + + if "width" not in stream or "height" not in stream: + continue + width = int(stream["width"]) + height = int(stream["height"]) + if not width or not height: + continue + + # Make sure that width and height are captured even if frame rate + # is not available + input_width = width + input_height = height + + tags = stream.get("tags") or {} + input_timecode = tags.get("timecode") or "" + + input_frame_rate = stream.get("r_frame_rate") + if input_frame_rate is not None: + break + return ( + input_width, + input_height, + input_timecode, + input_frame_rate + ) + + def _get_audio_metadata(self, streams): + # Get audio metadata + audio_codec = None + audio_channels = None + audio_sample_rate = None + audio_channel_layout = None + input_audio = False + + for stream in streams: + if stream.get("codec_type") != "audio": + continue + self.log.debug("__Ffprobe Audio: {}".format(stream)) + + if all( + stream.get(key) + for key in ( + "codec_name", + "channels", + "sample_rate", + "channel_layout", + ) + ): + audio_codec = stream["codec_name"] + audio_channels = stream["channels"] + audio_sample_rate = stream["sample_rate"] + audio_channel_layout = stream["channel_layout"] + input_audio = True + break + + return ( + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, + input_audio, + ) + + def _create_silent_slate( + self, + ffmpeg_path, + src_path, + dst_path, + audio_codec, + audio_channels, + audio_sample_rate, + audio_channel_layout, + ): + # Get duration of one frame in micro seconds + items = audio_sample_rate.split("/") + if len(items) == 1: + one_frame_duration = 1.0 / float(items[0]) + elif len(items) == 2: + one_frame_duration = float(items[1]) / float(items[0]) + else: + one_frame_duration = None + + if one_frame_duration is None: + one_frame_duration = "40000us" + else: + one_frame_duration *= 1000000 + one_frame_duration = str(int(one_frame_duration)) + "us" + self.log.debug("One frame duration is {}".format(one_frame_duration)) + + slate_silent_args = [ + ffmpeg_path, + "-i", src_path, + "-f", "lavfi", "-i", + "anullsrc=r={}:cl={}:d={}".format( + audio_sample_rate, + audio_channel_layout, + one_frame_duration + ), + "-c:v", "copy", + "-c:a", audio_codec, + "-map", "0:v", + "-map", "1:a", + "-shortest", + "-y", + dst_path + ] + # run slate generation subprocess + self.log.debug("Silent Slate Executing: {}".format( + " ".join(slate_silent_args) + )) + openpype.api.run_subprocess( + slate_silent_args, logger=self.log + ) + def add_video_filter_args(self, args, inserting_arg): """ Fixing video filter argumets to be one long string @@ -375,3 +559,41 @@ def _get_format_codec_args(self, repre): ) return format_args, codec_args + + def _tc_offset(self, timecode, framerate=24.0, frame_offset=-1): + """Offsets timecode by frame""" + def _seconds(value, framerate): + if isinstance(value, str): + _zip_ft = zip((3600, 60, 1, 1 / framerate), value.split(':')) + _s = sum(f * float(t) for f, t in _zip_ft) + elif isinstance(value, (int, float)): + _s = value / framerate + else: + _s = 0 + return _s + + def _frames(seconds, framerate, frame_offset): + _f = seconds * framerate + frame_offset + if _f < 0: + _f = framerate * 60 * 60 * 24 + _f + return _f + + def _timecode(seconds, framerate): + return '{h:02d}:{m:02d}:{s:02d}:{f:02d}'.format( + h=int(seconds / 3600), + m=int(seconds / 60 % 60), + s=int(seconds % 60), + f=int(round((seconds - int(seconds)) * framerate))) + drop = False + if ';' in timecode: + timecode = timecode.replace(';', ':') + drop = True + frames = _frames( + _seconds(timecode, framerate), + framerate, + frame_offset + ) + tc = _timecode(_seconds(frames, framerate), framerate) + if drop: + tc = ';'.join(tc.rsplit(':', 1)) + return tc diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 1f57891b847..4c3a5de2ece 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -568,6 +568,7 @@ def burnins_from_data( if source_ffmpeg_cmd: copy_args = ( "-metadata", + "-metadata:s:v:0", ) args = source_ffmpeg_cmd.split(" ") for idx, arg in enumerate(args):