From 5fbdaf8422cc6a8a5d862ab073981fa53de03d4f Mon Sep 17 00:00:00 2001 From: Mike Lang Date: Sun, 26 May 2024 11:46:41 +1000 Subject: [PATCH] full cuts: Support multiple ranges This allows full cuts to support multiple ranges in the same way fast cuts do, by using multiple inputs to ffmpeg and concat filters joining them. This will be easy to add transitions to later as this is "just" replacing a concat filter with an xfade + afade filter. --- common/common/segments.py | 71 +++++++++++++++++++++++++---------- cutter/cutter/main.py | 5 +-- restreamer/restreamer/main.py | 5 +-- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/common/common/segments.py b/common/common/segments.py index a3be982..fb02962 100644 --- a/common/common/segments.py +++ b/common/common/segments.py @@ -651,40 +651,71 @@ def feed_input(segments, pipe): @timed('cut_range', - cut_type=lambda _, segments, start, end, encode_args, stream=False: ("full-streamed" if stream else "full-buffered"), - normalize=lambda _, segments, start, end, *a, **k: (end - start).total_seconds(), + cut_type=lambda _, segment_ranges, ranges, encode_args, stream=False: ("full-streamed" if stream else "full-buffered"), + normalize=lambda _, segment_ranges, ranges, *a, **k: range_total(ranges), ) -def full_cut_segments(segments, start, end, encode_args, stream=False): +def full_cut_segments(segment_ranges, ranges, encode_args, stream=False): """If stream=true, assume encode_args gives a streamable format, and begin returning output immediately instead of waiting for ffmpeg to finish and buffering to disk.""" - # Remove holes - segments = [segment for segment in segments if segment is not None] + # for now, hard-code no transitions + transitions = [None] * (len(ranges) - 1) - # how far into the first segment to begin - cut_start = max(0, (start - segments[0].start).total_seconds()) - # duration - duration = (end - start).total_seconds() + inputs = [] + for segments, (start, end) in zip(segment_ranges, ranges): + # Remove holes + segments = [segment for segment in segments if segment is not None] + # how far into the first segment to begin + cut_start = max(0, (start - segments[0].start).total_seconds()) + # how long the whole section should be (sets the end cut) + duration = (end - start).total_seconds() + args = [ + "-ss", cut_start, + "-t", duration, + ] + inputs.append((segments, args)) + + filters = [] + # with no transitions, the output stream is just the first input stream + output_video_stream = "0:v" + output_audio_stream = "0:a" + for i, transition in enumerate(transitions): + # combine the current output stream with the next input stream + input_streams = [ + output_video_stream, output_audio_stream, + f"{i+1}:v", f"{i+1}:a" + ] + input_streams = "".join(f"[{stream}]" for stream in input_streams) + # set new output streams + output_video_stream = f"v{i}" + output_audio_stream = f"a{i}" + outputs = f"[{output_video_stream}][{output_audio_stream}]" + if transition is None: + filters.append(f"{input_streams}concat=n=2:v=1:a=1{outputs}") + else: + raise NotImplementedError if stream: # When streaming, we can just use a pipe - tempfile = subprocess.PIPE + output_file = subprocess.PIPE else: # Some ffmpeg output formats require a seekable file. # For the same reason, it's not safe to begin uploading until ffmpeg # has finished. We create a temporary file for this. - tempfile = TemporaryFile() + output_file = TemporaryFile() args = [] - if cut_start is not None: - args += ['-ss', cut_start] - if duration is not None: - args += ['-t', duration] - args += list(encode_args) - - with ffmpeg_cut_many([segments, ()], args, output_file=tempfile) as ffmpeg: - # When streaming, we can return data as it is available + if filters: + args += [ + "-filter_complex", "; ".join(filters), + "-map", f"[{output_video_stream}]", + "-map", f"[{output_audio_stream}]", + ] + args += encode_args + + with ffmpeg_cut_many(inputs, args, output_file) as ffmpeg: + # When streaming, we can return data as it is available. # Otherwise, just exit the context manager so tempfile is fully written. if stream: for chunk in read_chunks(ffmpeg.stdout): @@ -692,7 +723,7 @@ def full_cut_segments(segments, start, end, encode_args, stream=False): # When not streaming, we can only return the data once ffmpeg has exited if not stream: - for chunk in read_chunks(tempfile): + for chunk in read_chunks(output_file): yield chunk diff --git a/cutter/cutter/main.py b/cutter/cutter/main.py index 1c567d1..6b908bf 100644 --- a/cutter/cutter/main.py +++ b/cutter/cutter/main.py @@ -418,11 +418,8 @@ class Cutter(object): "streamable" if upload_backend.encoding_streamable else "non-streamable", upload_backend.encoding_settings, )) - if len(job.video_ranges) > 1: - raise ValueError("Full cuts do not support multiple ranges") - range = job.video_ranges[0] cut = full_cut_segments( - job.segment_ranges[0], range.start, range.end, + job.segment_ranges, job.video_ranges, upload_backend.encoding_settings, stream=upload_backend.encoding_streamable, ) diff --git a/restreamer/restreamer/main.py b/restreamer/restreamer/main.py index 34d9c1f..c982824 100644 --- a/restreamer/restreamer/main.py +++ b/restreamer/restreamer/main.py @@ -400,10 +400,7 @@ def cut(channel, quality): # encode as high-quality, without wasting too much cpu on encoding stream, muxer, mimetype = (True, 'mpegts', 'video/MP2T') if type == 'mpegts' else (False, 'mp4', 'video/mp4') encoding_args = ['-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '0', '-f', muxer] - if len(ranges) > 1: - return "full cut does not support multiple ranges at this time", 400 - start, end = ranges[0] - return Response(full_cut_segments(segment_ranges[0], start, end, encoding_args, stream=stream), mimetype=mimetype) + return Response(full_cut_segments(segment_ranges, ranges, encoding_args, stream=stream), mimetype=mimetype) else: return "Unknown type {!r}".format(type), 400