diff --git a/pipeline/video_producers.py b/pipeline/video_producers.py new file mode 100644 index 0000000..338816d --- /dev/null +++ b/pipeline/video_producers.py @@ -0,0 +1,84 @@ +"""Classes for producing videos""" + +from abc import ABC +import logging +import subprocess +import tempfile + +class VideoProducer(ABC): + """Video producer interface.""" + def __init__(self, features): + pass + def produce(self): + pass + + +class FfmpegVideoProducer(VideoProducer): + """Produce videos using ffmpeg""" + # TODO: consider output filename options + + def __init__(self, features): + if not features: + raise ValueError("No features provided") + # TODO: consider if we want to permit empty features (producing no video) + self.features = features + + def _ffmpeg_feature_to_clip(self, feature=None, output_filepath=None): + """use ffmpeg to produve a video clip from a feature""" + OVERWRITE = True # TODO: consider making this a config option + if not feature or not feature.interval: + raise ValueError("No feature provided") + + if not output_filepath: + raise ValueError("No output filepath provided") + + ffmpeg_prefix = ["ffmpeg", "-y"] if OVERWRITE else ["ffmpeg"] + ffmpeg_suffix = ["-r", "60", "-c:v", "libx264", "-crf", "26", "-c:a", "aac", "-preset", "ultrafast"] + # TODO: match framerate of input video + # TODO: adjustable encoding options + seek = ["-ss", str(feature.interval.start)] + duration = ["-t", str(feature.interval.duration)] + ffmpeg_args = ffmpeg_prefix + seek + ["-i"] + [feature.path] + duration + ffmpeg_suffix + [output_filepath] + logging.info(f"ffmpeg_args: {ffmpeg_args}") + subprocess.run(ffmpeg_args, stdout=None, stderr=None) + + def _ffmpeg_concat_clips(self, clips=None, output_filepath=None): + """use ffmpeg to concatenate clips into a single video""" + OVERWRITE = True + ffmpeg_prefix = ["ffmpeg"] + ffmpeg_prefix += ["-y"] if OVERWRITE else [] + ffmpeg_prefix += ["-f", "concat", "-safe", "0", "-i"] + + # there is a method to do this via process substitution, but it's not portable + # so we'll use the input file list method + + if not clips: + raise ValueError("No clips provided") + + if not output_filepath: + raise ValueError("No output filepath provided") + + # generate a temporary file with the list of clips + join_file = tempfile.NamedTemporaryFile(mode="w") + for clip in clips: + join_file.write(f"file '{clip}'\n") + join_file.flush() + + ffmpeg_args = ffmpeg_prefix + [join_file.name] + ["-c", "copy", output_filepath] + logging.info(f"ffmpeg_args: {ffmpeg_args}") + subprocess.run(ffmpeg_args, stdout=None, stderr=None) + join_file.close() + + def produce(self): + OUTPUT_DIR = "/tmp/" # TODO: make this a config option + + clips = [] + for num, feature in enumerate(self.features): + output_filepath = f"{OUTPUT_DIR}/highlight_{num}.mp4" + self._ffmpeg_feature_to_clip(feature, output_filepath) + clips.append(output_filepath) + + # concatenate the clips + output_filepath = f"{OUTPUT_DIR}/highlights.mp4" + self._ffmpeg_concat_clips(clips, output_filepath) + logging.info(f"Produced video: {output_filepath}")