Browse Source

Pivot into an mpv script

Wolf Clement 3 years ago
parent
commit
d4b3074ae3
3 changed files with 102 additions and 96 deletions
  1. 5 7
      README.md
  2. 76 0
      cutitout.lua
  3. 21 89
      cutitout_shared/cutitout.py

+ 5 - 7
README.md

@@ -6,14 +6,12 @@ Unlike the other tools you will find on the internet, `cutitout` will:
 
 - Not fill /tmp with 10+ gigabyte .wav files
 - Not have audio desync when you cut a video longer than a minute
-- Not take an hour to render an hour-long video (unless you have a potato)
+- Not take an hour to render an hour-long video
 
-All of these "features" are required in order to cut the silence off multi-hour videos, which is the main use case of the tool in the first place.
+The reason? It doesn't reencode your videos: it's an addon for [mpv](https://mpv.io/).
 
-### Usage
+### Installation/usage
 
-```
-python3 cutitout.py [video_to_cut.mp4]
-```
+Copy the content of this repository in your mpv scripts directory, usually `~/.config/mpv/scripts` (create it if it doesn't exist).
 
-Enjoy.
+Press F12 to toggle silence skipping.

+ 76 - 0
cutitout.lua

@@ -0,0 +1,76 @@
+-- cutitout: automatically cut silence from videos
+-- Copyright (C) 2020 Wolf Clement
+
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+-- GNU Affero General Public License for more details.
+
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+
+local enabled = false
+local skips = {}
+
+-- Whenever time updates, we check if we should skip
+mp.observe_property("time-pos", "native", function (_, pos)
+    if pos == nil then return end
+    if not enabled then return end
+
+    for _, t in pairs(skips) do
+        -- t[1] == start time of the skip
+        -- t[2] == end time of the skip
+        if t[1] <= pos and t[2] > pos then
+            mp.set_property("time-pos", t[2])
+            return
+        end
+    end
+end)
+
+function reload_skips()
+    if not enabled then return end
+
+    local utils = require("mp.utils")
+    local scripts_dir = mp.find_config_file("scripts")
+    local cutitoutpy = utils.join_path(scripts_dir, "cutitout_shared/cutitout.py")
+
+    -- Reset global skips table
+    skips = {}
+
+    local video_path = mp.get_property("path")
+    mp.command_native_async({
+        name = "subprocess",
+        capture_stdout = true,
+        playback_only = false,
+        args = { "python3", cutitoutpy, video_path }
+    }, function(res, val, err)
+        -- The string sets the "skips" table
+        skips = loadstring(val.stdout)()
+        print(tostring(#skips) .. " skips loaded")
+        mp.osd_message("Silence skipping enabled.")
+    end)
+end
+
+-- F12 toggles silence skipping (off by default)
+mp.add_key_binding("F12", "toggle_silence_skip", function ()
+    enabled = not enabled
+    if enabled then
+        if next(skips) == nil then
+            mp.osd_message("Enabling silence skipping...")
+            reload_skips()
+        else
+            mp.osd_message("Silence skipping enabled.")
+        end
+    else
+        mp.osd_message("Silence skipping disabled.")
+    end
+end)
+
+-- Whenever we load another file, we reload skips
+mp.register_event("file-loaded", reload_skips)

+ 21 - 89
cutitout.py → cutitout_shared/cutitout.py

@@ -24,15 +24,10 @@ min_clip_length = 1.0
 
 
 import audioop
-import datetime
-import os
-import shutil
 import subprocess
 import sys
-import tempfile
-from multiprocessing.pool import ThreadPool
 
-# TODO: kill ffmpeg processes on Ctrl+C
+# TODO: optimize
 
 
 def get_audio_streams(filename):
@@ -56,7 +51,7 @@ def get_audio_streams(filename):
     return list(filter(lambda s: s["codec_type"] == "audio", streams))
 
 
-def get_clips(stream, sample_rate):
+def print_skips(stream, sample_rate):
     clips = []
     clip_index = 0
     loud_start = -1
@@ -80,46 +75,28 @@ def get_clips(stream, sample_rate):
 
     clips = list(filter(lambda v: (v[1] - v[0]) > min_clip_length * 100, clips))
 
-    return clips
-
-
-def index_to_time(index):
-    millis = index * 10 % 1000
-    seconds = index // 100
-    hours = seconds // (60 * 60)
-    seconds %= 60 * 60
-    minutes = seconds // 60
-    seconds %= 60
-    return "%02i:%02i:%02i.%03i" % (hours, minutes, seconds, millis)
-
-
-def encode(tmpdir, i, clip):
-    path = os.path.join(tmpdir, f"part{i}.mkv")
-    subprocess.call(
-        [
-            "ffmpeg",
-            "-i",
-            filename,
-            "-ss",
-            index_to_time(clip[0]),
-            "-to",
-            index_to_time(clip[1]),
-            "-c:v",
-            "copy",
-            path,
-        ],
-        stdout=subprocess.DEVNULL,
-        stderr=subprocess.DEVNULL,
-    )
+    # Turn clips into skips
+    skips = []
+    last_skip = 0
+    for clip in clips:
+        if last_skip == clip[0]:
+            last_skip = clip[1]
+        else:
+            skips.append((last_skip, clip[0]))
+            last_skip = clip[1]
+
+    # Fix time format (1 index = 10ms)
+    index_to_time = lambda index: index / 100
+    skips = [(index_to_time(skip[0]), index_to_time(skip[1])) for skip in skips]
+
+    skips = ["{" + f"{v[0]},{v[1]}" + "}" for v in skips]
+    print("return {" + ",".join(skips) + "}")
 
 
 for filename in sys.argv[1:]:
     for stream in get_audio_streams(filename):
         index = int(stream["index"])
         sample_rate = int(stream["sample_rate"])
-        nb_samples = int(stream["duration_ts"])
-        duration_seconds = nb_samples / sample_rate
-        print(f"Analyzing {duration_seconds:.2f} seconds of audio...")
 
         orig_audio = subprocess.Popen(
             [
@@ -142,52 +119,7 @@ for filename in sys.argv[1:]:
             stderr=subprocess.DEVNULL,
         )
 
-        clips = get_clips(orig_audio.stdout, sample_rate)
-        tmpdir = tempfile.mkdtemp()
-        clipfiles = [os.path.join(tmpdir, f"part{i}.mkv") for i, _ in enumerate(clips)]
-
-        max = len(clips)
-        progress = -1
-
-        def update_progress(*args):
-            global progress
-            progress += 1
-            bar_size = 60
-            n = int(bar_size * progress / max)
-            pounds = "#" * n
-            dots = "." * (bar_size - n)
-            sys.stdout.write(f"Cutting: [{pounds}{dots}] {progress}/{max}\r")
-            sys.stdout.flush()
-
-        update_progress()
-        pool = ThreadPool()
-        for i, clip in enumerate(clips):
-            res = pool.apply_async(encode, (tmpdir, i, clip), callback=update_progress)
-        pool.close()
-
-        pool.join()
-        sys.stdout.write("\n")
-        sys.stdout.flush()
-
-        cliplist = os.path.join(tmpdir, "list.txt")
-        with open(cliplist, "w") as filelist:
-            filelist.write("ffconcat version 1.0\n\n")
-            for fn in clipfiles:
-                filelist.write(f"file '{fn}'\n")
-
-        subprocess.call(
-            [
-                "ffmpeg",
-                "-f",
-                "concat",
-                "-safe",
-                "0",
-                "-i",
-                cliplist,
-                "-c",
-                "copy",
-                filename[:-4] + "_cutout.mkv",
-            ]
-        )
+        print_skips(orig_audio.stdout, sample_rate)
 
-        shutil.rmtree(tmpdir)
+        # We're only using the first audio stream
+        break