WyattBlue / auto-editor

Auto-Editor: Efficient media analysis and rendering
https://auto-editor.com
The Unlicense
2.88k stars 420 forks source link

Lossless Cutting (with Speedchange!) #400

Closed AhmedThahir closed 10 months ago

AhmedThahir commented 11 months ago

Referencing #399 I believe I have solved it! Speeding up and slowing down of videos is also possible. With help from @Fir121

After testing, I hope you can add this into auto-editor main cli, with --lossless flag and warning.

Code

import numpy as np
import time
import re
import sys
import json
import os
import concurrent.futures as multi

def cleanup():
    files_to_remove = []
    for file in os.listdir():
        for unwanted in ["_segment"]:
            if unwanted in file.lower():
                files_to_remove.append(file)
    with multi.ThreadPoolExecutor() as executor:
        executor.map(os.remove, files_to_remove)

def get_fps(input_file):
    info = os.popen(f'ffprobe -i "{input_file}" 2>&1').read()
    match = re.search(r'\s([\d\.]*)\sfps', info)
    if match:
        return float(match.group(1))
    return 0.0

def get_keyframe_interval(input_file):
    """
    Get average keyframe interval
    """

    start_time_to_read = 1
    max_seconds_to_read = 5
    info = os.popen(f"""
    ffprobe -read_intervals {start_time_to_read}%+{max_seconds_to_read} -select_streams v -show_entries frame=pts_time -of csv=p=0 -skip_frame nokey -v 0 -hide_banner -i {input_file}
    """).read()

    keyframe_time_points = np.array(info.split("\n"))
    keyframe_time_points = keyframe_time_points[
        (keyframe_time_points != "")
    ]
    keyframe_time_points = keyframe_time_points.astype(np.float32)
    keyframe_interval = np.round(np.mean(np.diff(keyframe_time_points))).astype(int)
    return keyframe_interval

def process_json(input_file, fps, json):
    extension = os.path.splitext(input_file)[1]
    cmd = []
    with open('_segments.txt', 'w') as f:
        sounded_chunks = json["v"][0]
        for segment_number, sounded_chunk in enumerate(sounded_chunks):
            segment_file_name = f"_segment{segment_number}{extension}"

            f.write(f'file {segment_file_name}\n')

            offset_time = sounded_chunk["offset"] / fps
            start_time = sounded_chunk["start"] / fps
            speed = sounded_chunk["speed"]
            duration = (sounded_chunk["dur"]/speed) / fps

            cmd.append(f"""
            ffmpeg -hide_banner -loglevel error \
            -ss {offset_time + start_time} \
            {(
                f"-itsscale {1/speed}"
                if speed==1
                else ""
            )} \
            -i "{input_file}" \
            -t {duration} \
            -avoid_negative_ts make_zero \
            {(
                "-c:a copy"
                if speed==1
                else f"-af volume=0 -af atempo={speed}"
            )} \
            -c:v copy \
            -map_metadata 0 -movflags use_metadata_tags -movflags '+faststart' -default_mode infer_no_subs -ignore_unknown -y \
            {segment_file_name}
            """)

        with multi.ProcessPoolExecutor() as executor:
            executor.map(os.system, cmd)

def combine_segments(input_file):
    os.system(f"""
    ffmpeg  -hide_banner -loglevel error \
    -f concat -safe 0 -protocol_whitelist 'file,pipe,fd' \
    -i _segments.txt \
    -c copy \
    '-disposition' default -movflags use_metadata_tags -movflags '+faststart' -default_mode infer_no_subs -ignore_unknown -y \
    "{os.path.splitext(input_file)[0]}_STRIPPED{os.path.splitext(input_file)[1]}"
    """)

def process_file(input_file):
    fps_val = get_fps(input_file)
    keyframe_interval = get_keyframe_interval(input_file)

    if fps_val <= 0.0:
        print(f'Unable to determine FPS of {input_file}')
        return

    os.system(f'auto-editor "{input_file}" --edit "audio:threshold=-40dB,mincut={max([1, 1 + keyframe_interval])}s" --export json')
    json_file = os.path.splitext(input_file)[0] + '_ALTERED.json'
    with open(json_file) as f:
        json_data = json.load(f)
    process_json(input_file, fps_val, json_data)
    os.remove(json_file)
    combine_segments(input_file)

def iterate_files():
    for input_file in sys.argv[1:]:
        process_file(input_file)

def main():
    cleanup()

    start_time = time.time()
    iterate_files()
    print("--- %s seconds ---" % (time.time() - start_time))

    cleanup()

if __name__ == "__main__":
    main()

Execution

python main.py input.mp4
AhmedThahir commented 11 months ago

Any comments/suggestions?

If not, I'll close this issue to keep your repo clean.

WyattBlue commented 11 months ago

I'm intrigued by the feature and I'm interested in turning this into a PR request.

AhmedThahir commented 10 months ago

Great!

AhmedThahir commented 10 months ago

I've used ffprobe to automatically get the keyframe interval. I've added a cushion for cutting using this as a fail-safe for inaccurate keyframe trimming.

AhmedThahir commented 10 months ago

I'm not able to get the exact cuts like what I achieve with 'detect silent parts' with LosslessCut and cutting out those parts.

https://github.com/mifi/lossless-cut/issues/1334

I think it's because of the "Combine Overlapping Segments feature": https://github.com/mifi/lossless-cut/commit/95e6a5d198760397689ad2f9485eb2830c203ca9

WyattBlue commented 10 months ago

I'll still accept a PR but I don't care enough about this feature to implement this myself.