├── requirements.txt ├── LICENSE ├── .gitignore ├── README.md ├── default.nix └── jumpcutter.py /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow 2 | audiotsm 3 | scipy 4 | numpy 5 | pytube 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 carykh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # IntelliJ project files 92 | .idea 93 | *.iml 94 | out 95 | gen -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jumpcutter 2 | Automatically edits videos. Explanation here: https://www.youtube.com/watch?v=DQ8orIurGxw 3 | 4 | Go here for a more polished version of this software that my friends and I have been working on fr the last year or so: https://jumpcutter.com/ 5 | 6 | Since my GitHub is more like a dumping ground or personal journal, I'm not going to be actively updating this GitHub repo. But if you do want a version of jumpcutter that is actively being worked on, please do check on the version at https://jumpcutter.com/! There's way more developers fixing bugs and adding new features to that tool, and there's a developer's Discord server to discuss anything JC-related, so go check it out! 7 | 8 | ## Some heads-up: 9 | 10 | It uses Python 3. 11 | 12 | It works on Ubuntu 16.04 and Windows 10. (It might work on other OSs too, we just haven't tested it yet.) 13 | 14 | This program relies heavily on ffmpeg. It will start subprocesses that call ffmpeg, so be aware of that! 15 | 16 | As the program runs, it saves every frame of the video as an image file in a 17 | temporary folder. If your video is long, this could take a LOT of space. 18 | I have processed 17-minute videos completely fine, but be wary if you're gonna go longer. 19 | 20 | I want to use pyinstaller to turn this into an executable, so non-techy people 21 | can use it EVEN IF they don't have Python and all those libraries. Jabrils 22 | recommended this to me. However, my pyinstaller build did not work. :( HELP 23 | 24 | ## Building with nix 25 | `nix-build` to get a script with all the libraries and ffmpeg, `nix-build -A bundle` to get a single binary. 26 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | let 4 | python = python2; 5 | audiotsm = python.pkgs.buildPythonPackage { 6 | name = "audiotsm-0.1.2"; 7 | src = pkgs.fetchurl { url = "https://files.pythonhosted.org/packages/f8/b8/721a9c613641c938a6fb9c7c3efb173b7f77b519de066e9cd2eeb27c3289/audiotsm-0.1.2.tar.gz"; sha256 = "8870af28fad0a76cac1d2bb2b55e7eac6ad5d1ad5416293eb16120dece6c0281"; }; 8 | doCheck = false; 9 | buildInputs = []; 10 | propagatedBuildInputs = [ 11 | python.pkgs.numpy 12 | ]; 13 | meta = with pkgs.stdenv.lib; { 14 | homepage = "https://github.com/Muges/audiotsm"; 15 | license = licenses.mit; 16 | description = "A real-time audio time-scale modification library"; 17 | }; 18 | }; 19 | 20 | pythonForThis = python.withPackages (ps: with ps;[ 21 | scipy 22 | numpy 23 | pillow 24 | audiotsm 25 | ]); 26 | jumpcutter = stdenv.mkDerivation { 27 | pname = "jumpcutter"; 28 | version = "0.0.1"; 29 | src = ./.; 30 | buildInputs = [ 31 | pythonForThis 32 | ffmpeg 33 | ]; 34 | installPhase = '' 35 | mkdir -p $out/bin 36 | echo "#!${pythonForThis}/bin/python" > $out/bin/jumpcutter 37 | cat $src/jumpcutter.py >> $out/bin/jumpcutter 38 | substituteInPlace $out/bin/jumpcutter --replace ffmpeg ${ffmpeg} 39 | chmod +x $out/bin/jumpcutter 40 | ''; 41 | }; 42 | 43 | nix-bundle-src = builtins.fetchGit { 44 | url = "https://github.com/matthewbauer/nix-bundle"; 45 | rev = "113d8c6b426b0932a64c58c21cd065baad4c2314"; 46 | }; 47 | nix-bundle = (import ("${nix-bundle-src}/appimage-top.nix") {}) // (import "${nix-bundle-src}/default.nix" {}); 48 | in 49 | jumpcutter // { 50 | bundle = nix-bundle.nix-bootstrap { 51 | extraTargets = []; 52 | target = jumpcutter; 53 | run = "/bin/jumpcutter"; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /jumpcutter.py: -------------------------------------------------------------------------------- 1 | from contextlib import closing 2 | from PIL import Image 3 | import subprocess 4 | from audiotsm import phasevocoder 5 | from audiotsm.io.wav import WavReader, WavWriter 6 | from scipy.io import wavfile 7 | import numpy as np 8 | import re 9 | import math 10 | from shutil import copyfile, rmtree 11 | import os 12 | import argparse 13 | from pytube import YouTube 14 | 15 | def downloadFile(url): 16 | name = YouTube(url).streams.first().download() 17 | newname = name.replace(' ','_') 18 | os.rename(name,newname) 19 | return newname 20 | 21 | def getMaxVolume(s): 22 | maxv = float(np.max(s)) 23 | minv = float(np.min(s)) 24 | return max(maxv,-minv) 25 | 26 | def copyFrame(inputFrame,outputFrame): 27 | src = TEMP_FOLDER+"/frame{:06d}".format(inputFrame+1)+".jpg" 28 | dst = TEMP_FOLDER+"/newFrame{:06d}".format(outputFrame+1)+".jpg" 29 | if not os.path.isfile(src): 30 | return False 31 | copyfile(src, dst) 32 | if outputFrame%20 == 19: 33 | print(str(outputFrame+1)+" time-altered frames saved.") 34 | return True 35 | 36 | def inputToOutputFilename(filename): 37 | dotIndex = filename.rfind(".") 38 | return filename[:dotIndex]+"_ALTERED"+filename[dotIndex:] 39 | 40 | def createPath(s): 41 | #assert (not os.path.exists(s)), "The filepath "+s+" already exists. Don't want to overwrite it. Aborting." 42 | 43 | try: 44 | os.mkdir(s) 45 | except OSError: 46 | assert False, "Creation of the directory %s failed. (The TEMP folder may already exist. Delete or rename it, and try again.)" 47 | 48 | def deletePath(s): # Dangerous! Watch out! 49 | try: 50 | rmtree(s,ignore_errors=False) 51 | except OSError: 52 | print ("Deletion of the directory %s failed" % s) 53 | print(OSError) 54 | 55 | parser = argparse.ArgumentParser(description='Modifies a video file to play at different speeds when there is sound vs. silence.') 56 | parser.add_argument('--input_file', type=str, help='the video file you want modified') 57 | parser.add_argument('--url', type=str, help='A youtube url to download and process') 58 | parser.add_argument('--output_file', type=str, default="", help="the output file. (optional. if not included, it'll just modify the input file name)") 59 | parser.add_argument('--silent_threshold', type=float, default=0.03, help="the volume amount that frames' audio needs to surpass to be consider \"sounded\". It ranges from 0 (silence) to 1 (max volume)") 60 | parser.add_argument('--sounded_speed', type=float, default=1.00, help="the speed that sounded (spoken) frames should be played at. Typically 1.") 61 | parser.add_argument('--silent_speed', type=float, default=5.00, help="the speed that silent frames should be played at. 999999 for jumpcutting.") 62 | parser.add_argument('--frame_margin', type=float, default=1, help="some silent frames adjacent to sounded frames are included to provide context. How many frames on either the side of speech should be included? That's this variable.") 63 | parser.add_argument('--sample_rate', type=float, default=44100, help="sample rate of the input and output videos") 64 | parser.add_argument('--frame_rate', type=float, default=30, help="frame rate of the input and output videos. optional... I try to find it out myself, but it doesn't always work.") 65 | parser.add_argument('--frame_quality', type=int, default=3, help="quality of frames to be extracted from input video. 1 is highest, 31 is lowest, 3 is the default.") 66 | 67 | args = parser.parse_args() 68 | 69 | 70 | 71 | frameRate = args.frame_rate 72 | SAMPLE_RATE = args.sample_rate 73 | SILENT_THRESHOLD = args.silent_threshold 74 | FRAME_SPREADAGE = args.frame_margin 75 | NEW_SPEED = [args.silent_speed, args.sounded_speed] 76 | if args.url != None: 77 | INPUT_FILE = downloadFile(args.url) 78 | else: 79 | INPUT_FILE = args.input_file 80 | URL = args.url 81 | FRAME_QUALITY = args.frame_quality 82 | 83 | assert INPUT_FILE != None , "why u put no input file, that dum" 84 | 85 | if len(args.output_file) >= 1: 86 | OUTPUT_FILE = args.output_file 87 | else: 88 | OUTPUT_FILE = inputToOutputFilename(INPUT_FILE) 89 | 90 | TEMP_FOLDER = "TEMP" 91 | AUDIO_FADE_ENVELOPE_SIZE = 400 # smooth out transitiion's audio by quickly fading in/out (arbitrary magic number whatever) 92 | 93 | createPath(TEMP_FOLDER) 94 | 95 | command = "ffmpeg -i "+INPUT_FILE+" -qscale:v "+str(FRAME_QUALITY)+" "+TEMP_FOLDER+"/frame%06d.jpg -hide_banner" 96 | subprocess.call(command, shell=True) 97 | 98 | command = "ffmpeg -i "+INPUT_FILE+" -ab 160k -ac 2 -ar "+str(SAMPLE_RATE)+" -vn "+TEMP_FOLDER+"/audio.wav" 99 | 100 | subprocess.call(command, shell=True) 101 | 102 | command = "ffmpeg -i "+TEMP_FOLDER+"/input.mp4 2>&1" 103 | f = open(TEMP_FOLDER+"/params.txt", "w") 104 | subprocess.call(command, shell=True, stdout=f) 105 | 106 | 107 | 108 | sampleRate, audioData = wavfile.read(TEMP_FOLDER+"/audio.wav") 109 | audioSampleCount = audioData.shape[0] 110 | maxAudioVolume = getMaxVolume(audioData) 111 | 112 | f = open(TEMP_FOLDER+"/params.txt", 'r+') 113 | pre_params = f.read() 114 | f.close() 115 | params = pre_params.split('\n') 116 | for line in params: 117 | m = re.search('Stream #.*Video.* ([0-9]*) fps',line) 118 | if m is not None: 119 | frameRate = float(m.group(1)) 120 | 121 | samplesPerFrame = sampleRate/frameRate 122 | 123 | audioFrameCount = int(math.ceil(audioSampleCount/samplesPerFrame)) 124 | 125 | hasLoudAudio = np.zeros((audioFrameCount)) 126 | 127 | 128 | 129 | for i in range(audioFrameCount): 130 | start = int(i*samplesPerFrame) 131 | end = min(int((i+1)*samplesPerFrame),audioSampleCount) 132 | audiochunks = audioData[start:end] 133 | maxchunksVolume = float(getMaxVolume(audiochunks))/maxAudioVolume 134 | if maxchunksVolume >= SILENT_THRESHOLD: 135 | hasLoudAudio[i] = 1 136 | 137 | chunks = [[0,0,0]] 138 | shouldIncludeFrame = np.zeros((audioFrameCount)) 139 | for i in range(audioFrameCount): 140 | start = int(max(0,i-FRAME_SPREADAGE)) 141 | end = int(min(audioFrameCount,i+1+FRAME_SPREADAGE)) 142 | shouldIncludeFrame[i] = np.max(hasLoudAudio[start:end]) 143 | if (i >= 1 and shouldIncludeFrame[i] != shouldIncludeFrame[i-1]): # Did we flip? 144 | chunks.append([chunks[-1][1],i,shouldIncludeFrame[i-1]]) 145 | 146 | chunks.append([chunks[-1][1],audioFrameCount,shouldIncludeFrame[i-1]]) 147 | chunks = chunks[1:] 148 | 149 | outputAudioData = np.zeros((0,audioData.shape[1])) 150 | outputPointer = 0 151 | 152 | lastExistingFrame = None 153 | for chunk in chunks: 154 | audioChunk = audioData[int(chunk[0]*samplesPerFrame):int(chunk[1]*samplesPerFrame)] 155 | 156 | sFile = TEMP_FOLDER+"/tempStart.wav" 157 | eFile = TEMP_FOLDER+"/tempEnd.wav" 158 | wavfile.write(sFile,SAMPLE_RATE,audioChunk) 159 | with WavReader(sFile) as reader: 160 | with WavWriter(eFile, reader.channels, reader.samplerate) as writer: 161 | tsm = phasevocoder(reader.channels, speed=NEW_SPEED[int(chunk[2])]) 162 | tsm.run(reader, writer) 163 | _, alteredAudioData = wavfile.read(eFile) 164 | leng = alteredAudioData.shape[0] 165 | endPointer = outputPointer+leng 166 | outputAudioData = np.concatenate((outputAudioData,alteredAudioData/maxAudioVolume)) 167 | 168 | #outputAudioData[outputPointer:endPointer] = alteredAudioData/maxAudioVolume 169 | 170 | # smooth out transitiion's audio by quickly fading in/out 171 | 172 | if leng < AUDIO_FADE_ENVELOPE_SIZE: 173 | outputAudioData[outputPointer:endPointer] = 0 # audio is less than 0.01 sec, let's just remove it. 174 | else: 175 | premask = np.arange(AUDIO_FADE_ENVELOPE_SIZE)/AUDIO_FADE_ENVELOPE_SIZE 176 | mask = np.repeat(premask[:, np.newaxis],2,axis=1) # make the fade-envelope mask stereo 177 | outputAudioData[outputPointer:outputPointer+AUDIO_FADE_ENVELOPE_SIZE] *= mask 178 | outputAudioData[endPointer-AUDIO_FADE_ENVELOPE_SIZE:endPointer] *= 1-mask 179 | 180 | startOutputFrame = int(math.ceil(outputPointer/samplesPerFrame)) 181 | endOutputFrame = int(math.ceil(endPointer/samplesPerFrame)) 182 | for outputFrame in range(startOutputFrame, endOutputFrame): 183 | inputFrame = int(chunk[0]+NEW_SPEED[int(chunk[2])]*(outputFrame-startOutputFrame)) 184 | didItWork = copyFrame(inputFrame,outputFrame) 185 | if didItWork: 186 | lastExistingFrame = inputFrame 187 | else: 188 | copyFrame(lastExistingFrame,outputFrame) 189 | 190 | outputPointer = endPointer 191 | 192 | wavfile.write(TEMP_FOLDER+"/audioNew.wav",SAMPLE_RATE,outputAudioData) 193 | 194 | ''' 195 | outputFrame = math.ceil(outputPointer/samplesPerFrame) 196 | for endGap in range(outputFrame,audioFrameCount): 197 | copyFrame(int(audioSampleCount/samplesPerFrame)-1,endGap) 198 | ''' 199 | 200 | command = "ffmpeg -framerate "+str(frameRate)+" -i "+TEMP_FOLDER+"/newFrame%06d.jpg -i "+TEMP_FOLDER+"/audioNew.wav -strict -2 "+OUTPUT_FILE 201 | subprocess.call(command, shell=True) 202 | 203 | deletePath(TEMP_FOLDER) 204 | 205 | --------------------------------------------------------------------------------