Add transition support to fast cuts

pull/400/head
Mike Lang 6 months ago committed by Mike Lang
parent c8724a1e63
commit 6b0a025812

@ -442,39 +442,106 @@ def streams_info(segment):
return json.loads(output)['streams'] return json.loads(output)['streams']
def ffmpeg_cut_segment(segment, cut_start=None, cut_end=None): def get_segment_encode_args(segment, video_stream="0:v", audio_stream="0:a"):
""" """For a given segment, return ffmpeg args needed to encode something in the same way."""
Wrapper for ffmpeg_cut_many() which cuts a single segment file to stdout,
taking care to preserve stream order and other metadata.
Used when doing a fast cut.
"""
logging.debug(f"Will cut segment for range ({cut_start}, {cut_end}): {segment.path}")
args = []
# output from ffprobe is generally already sorted but let's be paranoid, # output from ffprobe is generally already sorted but let's be paranoid,
# because the order of map args matters. # because the order of map args matters.
args = []
for stream in sorted(streams_info(segment), key=lambda stream: stream['index']): for stream in sorted(streams_info(segment), key=lambda stream: stream['index']):
# map the same stream in the same position from input to output
args += ['-map', '0:{}'.format(stream['index'])]
if stream['codec_type'] in ('video', 'audio'): if stream['codec_type'] in ('video', 'audio'):
# for non-metadata streams, make sure we use the same codec (metadata streams # for non-metadata streams, make sure we use the same codec (metadata streams
# are a bit weirder, and ffmpeg will do the right thing anyway) # are a bit weirder, and ffmpeg will do the right thing anyway)
args += ['-codec:{}'.format(stream['index']), stream['codec_name']] args += ['-codec:{}'.format(stream['index']), stream['codec_name']]
# now add trim args # map the stream of the correct type into the same position
if cut_start: args += ['-map', video_stream if stream['codec_type'] == "video" else audio_stream]
args += ['-ss', str(cut_start)] else:
if cut_end: # map the metadata stream into the same position
args += ['-to', str(cut_end)] args += ['-map', '0:{}'.format(stream['index'])]
# disable B-frames (frames which contain data needed by earlier frames) as a codec option, # disable B-frames (frames which contain data needed by earlier frames) as a codec option,
# as it changes the order that frames go in the file, which messes with our "concatenate the # as it changes the order that frames go in the file, which messes with our "concatenate the
# packets" method of concatenating the video. # packets" method of concatenating the video.
args += ['-bf', '0'] args += ['-bf', '0']
# output as MPEG-TS # output as MPEG-TS
args += ['-f', 'mpegts'] args += ['-f', 'mpegts']
return args
def ffmpeg_cut_segment(segment, cut_start=None, cut_end=None):
"""
Wrapper for ffmpeg_cut_many() which cuts a single segment file to stdout,
taking care to preserve stream order and other metadata.
Used when doing a fast cut.
"""
logging.debug(f"Will cut segment for range ({cut_start}, {cut_end}): {segment.path}")
args = get_segment_encode_args(segment)
# now add trim args
if cut_start:
args += ['-ss', str(cut_start)]
if cut_end:
args += ['-to', str(cut_end)]
return ffmpeg_cut_one([segment], args) return ffmpeg_cut_one([segment], args)
def ffmpeg_cut_transition(prev_segments, next_segments, video_type, duration, offset, prev_cut_end, cut_start, cut_end):
"""
Wrapper for ffmpeg_cut_many which cuts a transition between two lists of segments.
prev_segments is cut at the end according to prev_cut_end, and the transition begins
at offset. If offset + duration is not equal to the length of prev_segments after the cut,
audio may get out of sync.
next_segments is cut at the start and end by cut_start and cut_end, which may be None.
Note both end cut values must be relative to the full list of input segments, not just the last segment.
"""
logging.debug(f"Will cut {duration}s {video_type} from {len(prev_segments)} segments (offset {offset}, end {prev_cut_end}) to {len(next_segments)} segments (cut {cut_start}, {cut_end})")
# Remove holes
prev_segments = [segment for segment in prev_segments if segment is not None]
next_segments = [segment for segment in next_segments if segment is not None]
xfade_kwargs = {
"duration": duration,
"offset": offset,
}
if video_type in CUSTOM_XFADE_TRANSITIONS:
xfade_kwargs["transition"] = "custom"
xfade_kwargs["expr"] = f"'{CUSTOM_XFADE_TRANSITIONS[video_type]}'" # wrap in '' for quoting
elif video_type in KNOWN_XFADE_TRANSITIONS:
xfade_kwargs["transition"] = video_type
else:
raise ValueError(f"Unknown video transition type: {video_type}")
xfade_kwargs = ":".join(f"{k}={v}" for k, v in xfade_kwargs.items())
filters = [
f"[0:v][1:v]xfade={xfade_kwargs}[outv]",
f"[0:a][1:a]acrossfade=duration={duration}[outa]",
]
# Assume desired encode args are the same for all segments, so pick one arbitarily
args = get_segment_encode_args(prev_segments[0], "[outv]", "[outa]")
args += [
"-filter_complex",
"; ".join(filters),
]
prev_args = []
if prev_cut_end:
prev_args += ["-to", prev_cut_end]
next_args = []
if cut_start:
next_args += ["-ss", cut_start]
if cut_end:
next_args += ["-to", cut_end]
inputs = [
(prev_segments, prev_args),
(next_segments, next_args),
]
return ffmpeg_cut_many(inputs, args)
def ffmpeg_cut_one(segments, encode_args, output_file=subprocess.PIPE, input_args=[]): def ffmpeg_cut_one(segments, encode_args, output_file=subprocess.PIPE, input_args=[]):
"""Wrapper for ffmpeg_cut_many() with a simpler API for the single-input case.""" """Wrapper for ffmpeg_cut_many() with a simpler API for the single-input case."""
return ffmpeg_cut_many([(segments, input_args)], encode_args, output_file=output_file) return ffmpeg_cut_many([(segments, input_args)], encode_args, output_file=output_file)
@ -524,6 +591,7 @@ def ffmpeg_cut_many(inputs, encode_args, output_file=subprocess.PIPE):
for segments, input_args in inputs: for segments, input_args in inputs:
# prepare the input pipe # prepare the input pipe
read_fd, write_fd = os.pipe() read_fd, write_fd = os.pipe()
logging.debug("Sending as fd {}: {}".format(read_fd, ", ".join(s.path for s in segments)))
input_pipes.append(read_fd) input_pipes.append(read_fd)
# set up the writer to fill the pipe # set up the writer to fill the pipe
write_file = FileObject(write_fd, 'wb') write_file = FileObject(write_fd, 'wb')
@ -628,7 +696,7 @@ def fast_cut_segments(segment_ranges, ranges, transitions, smart=False):
# - This range's middle # - This range's middle
# - This range's end, unless it's part of the next transition. # - This range's end, unless it's part of the next transition.
# We pass the previous range's end segments and offset to the next iteration via prev_end # We pass the previous range's end segments and offset to the next iteration via prev_end
prev_end = None # None | (segments, offset) prev_end = None # None | (segments, offset, cut_end)
for segments, (start, end), in_transition, out_transition in zip( for segments, (start, end), in_transition, out_transition in zip(
segment_ranges, segment_ranges,
ranges, ranges,
@ -639,11 +707,15 @@ def fast_cut_segments(segment_ranges, ranges, transitions, smart=False):
# prev_end should be set if and only if there is an in transition # prev_end should be set if and only if there is an in transition
assert (in_transition is None) == (prev_end is None) assert (in_transition is None) == (prev_end is None)
# Determine start and end cut points, unless we start/end with a discontinuity # Determine start and end cut points, unless we start/end with a discontinuity.
# For end cut, determine value both relative to last segment (for cutting end only)
# and first segment (for cutting with a start transition).
cut_start = None cut_start = None
start_cut_end = None
cut_end = None cut_end = None
if segments[0] is not None: if segments[0] is not None:
cut_start = (start - segments[0].start).total_seconds() cut_start = (start - segments[0].start).total_seconds()
start_cut_end = (end - segments[0].start).total_seconds()
if cut_start < 0: if cut_start < 0:
raise ValueError("First segment doesn't begin until after cut start, but no leading hole indicated") raise ValueError("First segment doesn't begin until after cut start, but no leading hole indicated")
if segments[-1] is not None: if segments[-1] is not None:
@ -652,7 +724,6 @@ def fast_cut_segments(segment_ranges, ranges, transitions, smart=False):
raise ValueError("Last segment ends before cut end, but no trailing hole indicated") raise ValueError("Last segment ends before cut end, but no trailing hole indicated")
# Handle start # Handle start
start_cut_end = None
if in_transition is not None: if in_transition is not None:
video_type, duration = in_transition video_type, duration = in_transition
# Get start segments, and split them from the full range of segments # Get start segments, and split them from the full range of segments
@ -662,26 +733,27 @@ def fast_cut_segments(segment_ranges, ranges, transitions, smart=False):
if segment is not None and segment.start > transition_end: if segment is not None and segment.start > transition_end:
start_segments = segments[:i] start_segments = segments[:i]
segments = segments[i:] segments = segments[i:]
# there are still segments remaining, don't include the end cut
start_cut_end = None
break break
else: else:
# Unlikely,but in this case the start transition is the entire range # Unlikely,but in this case the start transition is the entire range
# and we should include the end cut. # and we should include the end cut.
start_segments = segments start_segments = segments
segments = [] segments = []
start_cut_end = cut_end
cut_end = None
if len(start_segments) == 0: if len(start_segments) == 0:
raise ValueError(f"Could not find any video data for {duration}s transition into range ({start}, {end})") raise ValueError(f"Could not find any video data for {duration}s transition into range ({start}, {end})")
raise NotImplementedError("no transitions yet")
# TODO append ffmpeg with transition, start_segments, prev_end, cut_start, start_cut_end prev_segments, offset, prev_cut_end = prev_end
ffmpeg = ffmpeg_cut_transition(prev_segments, start_segments, video_type, duration, offset, prev_cut_end, cut_start, start_cut_end)
parts.append(read_from_stdout(ffmpeg))
elif cut_start is not None and cut_start > 0: elif cut_start is not None and cut_start > 0:
# Cut start segment, unless it is a discontinuity or doesn't need cutting # Cut start segment, unless it is a discontinuity or doesn't need cutting
segment = segments[0] segment = segments[0]
segments = segments[1:] segments = segments[1:]
if not segments: if segments:
# The whole thing is only one segment, so also apply end cut # There are still segments remaining, so don't apply end cut
start_cut_end = cut_end start_cut_end = None
cut_end = None
ffmpeg = ffmpeg_cut_segment(segment, cut_start, start_cut_end) ffmpeg = ffmpeg_cut_segment(segment, cut_start, start_cut_end)
parts.append(read_from_stdout(ffmpeg)) parts.append(read_from_stdout(ffmpeg))
else: else:
@ -700,21 +772,25 @@ def fast_cut_segments(segment_ranges, ranges, transitions, smart=False):
if segment is not None and segment.end >= transition_start: if segment is not None and segment.end >= transition_start:
end_segments = segments[i:] end_segments = segments[i:]
segments = segments[:i] segments = segments[:i]
break
else: else:
raise ValueError(f"Could not find any video data for {duration}s transition out of range ({start}, {end}) that is not already part of another transition") raise ValueError(f"Could not find any video data for {duration}s transition out of range ({start}, {end}) that is not already part of another transition, try a full cut?")
# offset is how many seconds into end_segments the transition should start. # offset is how many seconds into end_segments the transition should start.
# We know that end_segments is not empty as otherwise we would have hit the for/else condition. # We know that end_segments is not empty as otherwise we would have hit the for/else condition.
# We also know that end_segments[0] is not None as it is one of our conditions for picking it. # We also know that end_segments[0] is not None as it is one of our conditions for picking it.
# It is however possible for end_segments[0] to start AFTER the transition should have started # It is however possible for end_segments[0] to start AFTER the transition should have started
# due to a hole immediately before. Technically a negative offset is still correct in this case, # due to a hole immediately before. In this case the offset will be negative, which would be fine
# and ideally would cause ffmpeg to treat the transition as "already in progress" when we start. # except it causes the audio to go out of sync because audio is joined with a crossfade that doesn't
# I haven't tested this behaviour but the alternative is to just fail so it's worth a shot. # take an offset. We would rather error early in this case instead of proceeding with bad data.
offset = (transition_start - end_segments[0].start).total_seconds() offset = (transition_start - end_segments[0].start).total_seconds()
prev_end = end_segments, offset if offset < 0:
elif cut_end is not None: raise ValueError(f"Video data not available at the start of a {duration}s transition out of range ({start}, {end}), it was probably already part of an earlier cut or transition. Try a full cut?")
# Re-define cut_end to be relative to the start of end_segments
cut_end = (end - end_segments[0].start).total_seconds()
prev_end = end_segments, offset, cut_end
elif segments and cut_end:
# Cut end segment, unless it is a discontinuity, doesn't need cutting, or was already # Cut end segment, unless it is a discontinuity, doesn't need cutting, or was already
# cut as part of start (which sets cut_end to None). # cut as part of start (in which case we have no segments)
assert segments # Either we should still have segments left, or the start cut should have included the end
segment = segments[-1] segment = segments[-1]
segments = segments[:-1] segments = segments[:-1]
ffmpeg = ffmpeg_cut_segment(segment, cut_end=cut_end) ffmpeg = ffmpeg_cut_segment(segment, cut_end=cut_end)

Loading…
Cancel
Save