diff --git a/README.md b/README.md index b870c37..60a2fa1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,86 @@ -GUI jumpcutter with FFMPEG. Analysis audio file for silent parts cuts it out and concatinates parts of audio + video in sync. Thus making lecture videos shorter. +# Jumpcutter GUI -Another way to improve or create fork of this project instead of cutting off silent parts, it could do 2x-10x to save some video information. To keep track +Jumpcutter is a desktop application that removes silent parts from lectures or talks to make a shorter and more watchable video. It offers a Tkinter interface over FFmpeg, walking you through loading a source file, selecting output options, and processing the video while maintaining audio/video sync. + +## Features +- Analyses the audio track to automatically detect and remove silent sections. +- Uses FFmpeg under the hood for dependable processing of audio and video. +- Provides a basic graphical interface in Lithuanian which breaks down each step in the workflow. +- Maintains modular separation between core processing logic and GUI orchestration modules. + +## Requirements +- Python 3.8 or later. +- FFmpeg available on your system `PATH`. +- Recommended: a virtual environment to isolate dependencies. + +## Installation +1. Clone this repository and move into the project directory: + ```bash + git clone https://github.com/your-username/jumpcutter.git + cd jumpcutter + ``` +2. (Optional) Create and activate a virtual environment. +3. Install the Python requirements: + ```bash + pip install -r requirements.txt + ``` + +## Usage +1. Ensure FFmpeg is installed and accessible from the command line. +2. Run the GUI launcher: + ```bash + python -m jumpcutter.gui + ``` +3. Select an input video, set thresholds for silence, and begin processing by following the Lithuanian instructions in the GUI. +4. The output video will be saved in the same location as the source file with silences removed. + + +## Project Structure +- `jumpcutter/core.py` — Contains a processing pipeline which detects silences in audio, removes them and writes final output video. +- `jumpcutter/gui.py` — A Tkinter based interface to collect inputs from userand manage processing.Displaying all messages to user in Lithuanian. +- `jumpcutter/__init__.py` — Contains package meta data and some utility functions. +- `jumpcutter.py` — older launcher still around for keeping things compatible with older versions. + +--- + +# Jumpcutter GUI Lietuviškai + +„Jumpcutter“ yra darbalaukio programa, skirta tylos fragmentams pašalinti iš paskaitų ar pristatymų, kad vaizdo įrašai būtų trumpesni ir įtraukiantys. Ji veikia su „Tkinter“ grafine sąsaja, naudodama „FFmpeg“, ir savo lange paaiškina, kaip pasirinkti failą, nustatyti parametrus ir pradėti apdorojimą sinchronizuojant garsą su vaizdo įrašu. + +## Funkcijos +- Automatiškai aptinka ir iškirpa tylias garso takelio dalis. +- Naudoja „FFmpeg“, kad apdorojimas būtų patikimas ir aukštos kokybės. +- Turi paprastą grafinę sąsają aiškia lietuvių kalba. +- Šaltinio kodas yra padalintas į atskirus pagrindinius ir gui modulius, kad būtų lengviau prižiūrėti ir išplėsti. + +## REIKALAVIMAI +- Python 3.8 (ar naujesnis). +- FFmpeg prienamas sistemos PATH. +-Rekomenduoti naudoti virtualią aplinką priklausomybėms atskirti. + +## Diegimas +1. Nuklonuokite šį repozitoriją ir atverkite projekto aplanką: + ```bash + git clone https://github.com/your-username/jumpcutter.git + cd jumpcutter + ``` +2. (Nebūtina) Susikurkite ir aktyvuokite virtualią aplinką. +3. Įdiekite Python priklausomybes: + ```bash + pip install -r requirements.txt + ``` + +## Naudojimas +1. Įsitikinkite, kad FFmpeg yra įdiegtas ir pasiekiamas iš komandinės eilutės. +2. Paleiskite grafinę sąsają: + ```bash + python -m jumpcutter.gui + ``` +3. Lange vadovaukitės lietuviškais nurodymais: pasirinkite įvesties vaizdo įrašą, sureguliuokite tylos slenksčius ir pradėkite apdorojimą. +4. Apdorotas išvesties vaizdo įrašas bus išsaugotas šalia originalaus failo, pašalinus tylias dalis. + +## Projekto struktūra +- `jumpcutter/core.py` – apdorojimo eiga: garso analizė, tylos iškirpimas ir galutinio vaizdo įrašymas. +- `jumpcutter/gui.py` – Tkinter sąsaja, kuri surenka naudotojo nustatymus ir paleidžia apdorojimą su paaiškinimais. +- `jumpcutter/__init__.py` – paketo metainformacija ir pagalbinės funkcijos. +- `jumpcutter.py` – senesnis paleidimo scenarijus, paliktas suderinamumui. diff --git a/jumpcutter.py b/jumpcutter.py index 009ea04..fb2541e 100644 --- a/jumpcutter.py +++ b/jumpcutter.py @@ -1,195 +1,12 @@ -import os -import subprocess -import tempfile -import tkinter as tk -from tkinter import filedialog, ttk -from moviepy.editor import VideoFileClip -from pydub import AudioSegment -from pydub.silence import detect_nonsilent -from tqdm import tqdm - - -# ================== CORE LOGIC ================== -def jumpcutter(video_path, output_path, silence_thresh=-40, min_silence_len=500): - if not os.path.isfile(video_path): - print(f"Error: The file '{video_path}' does not exist.") - return - - print(f"\nProcessing: {video_path}") - print("[1/4] Loading video...") - video = VideoFileClip(video_path) - audio = video.audio - - print("[2/4] Exporting audio for silence detection...") - temp_audio_path = "temp_audio.wav" - audio.write_audiofile(temp_audio_path, logger=None) - - print("[3/4] Detecting non-silent audio segments...") - sound = AudioSegment.from_file(temp_audio_path) - nonsilent_parts = detect_nonsilent( - sound, min_silence_len=min_silence_len, silence_thresh=silence_thresh - ) - nonsilent_times = [(start / 1000, end / 1000) for start, end in nonsilent_parts] - - print("[4/4] Creating new video by concatenating non-silent parts...") - clips = [] - for start, end in tqdm(nonsilent_times, desc="Processing video segments"): - segment_duration = end - start - segment = video.subclip(start, end) - - # Pagreitiname segmentą su FFmpeg, jei reikia - if segment_duration < (min_silence_len / 1000): - try: - # Sukuriame laikinus failus - with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_input, \ - tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_output: - - # Išsaugome originalų segmentą - segment.write_videofile(temp_input.name, codec="libx264", audio_codec="aac", logger=None) - - # FFmpeg komanda pagreitinimui - ffmpeg_cmd = [ - "ffmpeg", - "-y", # Perrašyti failą be patvirtinimo - "-i", temp_input.name, - "-filter_complex", f"[0:v]setpts={1}*PTS[v]; [0:a]atempo={1}[a]", - "-map", "[v]", - "-map", "[a]", - "-c:v", "libx264", - "-c:a", "aac", - temp_output.name - ] - - # Paleidžiame FFmpeg - subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - # Įkeliame pagreitintą segmentą - sped_up_clip = VideoFileClip(temp_output.name) - clips.append(sped_up_clip) - - except Exception as e: - print(f"Klaida pagreitinant segmentą {start}-{end}: {e}. Segmentas praleidžiamas.") - continue - else: - clips.append(segment) - - # Išsaugome naują vaizdo įrašą - from moviepy.editor import concatenate_videoclips - - final_video = concatenate_videoclips(clips) - - final_video.write_videofile(output_path, codec="libx264", audio_codec="aac") - -# ================== GUI ================== -class JumpCutterApp(tk.Tk): - def __init__(self): - super().__init__() - self.title("JumpCutter") - self.geometry("300x300") - self._create_widgets() - - - def _update_thresh_desc(self, value): - value = int(float(value)) - if value <= -55: - desc = "Labai jautru (tyla studijoje)" - elif value <= -45: - desc = "Rekomenduojama kalbiniams įrašams" - elif value <= -35: - desc = "Vidutinis slenkstis (gatvės triukšmas)" - else: - desc = "Mažas jautrumas (triukšminga aplinka)" - self.thresh_desc.config(text=f"{value} dB: {desc}") - - def _update_silence_len_desc(self, value): - self.silence_len_desc.config(text=f"Minimali tyla: {int(float(value))} ms") - - def _create_widgets(self): - # Parametrų įvedimas - main_frame = tk.Frame(self) - main_frame.pack(pady=10) - - # Tylos slenkstis - thresh_frame = tk.Frame(main_frame) - thresh_frame.pack(pady=5) - - tk.Label(thresh_frame, text="Tylos slenkstis:").pack() - self.silence_thresh = tk.Scale( - thresh_frame, - from_=-60, - to=-30, - resolution=5, # Nustatomas 5 db žingsnis - orient="horizontal", - command=self._update_thresh_desc - ) - self.silence_thresh.set(-40) - self.silence_thresh.pack() - - self.thresh_desc = tk.Label(thresh_frame, text="", fg="gray") - self.thresh_desc.pack() - self._update_thresh_desc(-40) - - # Minimali tylos trukmė - silence_len_frame = tk.Frame(main_frame) - silence_len_frame.pack(pady=5) - - tk.Label(silence_len_frame, text="Minimali tylos trukmė:").pack() - self.min_silence_len = tk.Scale( - silence_len_frame, - from_=100, - to=2000, - resolution=100, # Nustatomas 100 ms žingsnis - orient="horizontal", - command=self._update_silence_len_desc - ) - self.min_silence_len.set(500) - self.min_silence_len.pack() - - self.silence_len_desc = tk.Label(silence_len_frame, text="", fg="gray") - self.silence_len_desc.pack() - self._update_silence_len_desc(500) - - - # Failo pasirinkimo mygtukas - tk.Button(self, text="Pasirinkti video", command=self._select_file).pack(pady=20) - - # Progreso juosta - self.progress = ttk.Progressbar(self, mode="indeterminate") - - # Statuso žinutė - self.status_label = tk.Label(self, text="", fg="gray") - self.status_label.pack() - - def _select_file(self): - file_path = filedialog.askopenfilename( - title="Pasirinkite video failą", - filetypes=[("Video Files", "*.mp4 *.mkv *.webm *.mov")] - ) - if file_path: - self._process_video(file_path) - - def _process_video(self, input_path): - self.progress.pack(pady=10) - self.progress.start() - self.status_label.config(text="Apdorojama...", fg="green") - - try: - output_path = f"jumpcutted_{os.path.basename(input_path)}" - jumpcutter( - input_path, - output_path, - silence_thresh=int(self.silence_thresh.get()), - min_silence_len=int(self.min_silence_len.get()), - ) - self.status_label.config(text=f"Baigta! Išsaugota: {output_path}", fg="blue") - except Exception as e: - self.status_label.config(text=f"Klaida: {str(e)}", fg="red") - finally: - self.progress.stop() - self.progress.pack_forget() - - -# ================== PALEIDIMAS ================== -if __name__ == "__main__": - app = JumpCutterApp() - app.mainloop() +"""Paleidimas, skirtas „Jumpcutter“ grafinės programos startui.""" + +from jumpcutter import JumpCutterApp + + +def main() -> None: + app = JumpCutterApp() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/jumpcutter/__init__.py b/jumpcutter/__init__.py new file mode 100644 index 0000000..0e2a4b1 --- /dev/null +++ b/jumpcutter/__init__.py @@ -0,0 +1,6 @@ +"""Jumpcutter paketas.""" + +from .core import jumpcutter +from .gui import JumpCutterApp + +__all__ = ["jumpcutter", "JumpCutterApp"] diff --git a/jumpcutter/core.py b/jumpcutter/core.py new file mode 100644 index 0000000..3ead895 --- /dev/null +++ b/jumpcutter/core.py @@ -0,0 +1,116 @@ +"""Core „Jumpcutter“ vaizdo apdorojimo logika.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from typing import List, Tuple + +from moviepy.editor import VideoFileClip, concatenate_videoclips +from pydub import AudioSegment +from pydub.silence import detect_nonsilent +from tqdm import tqdm + + +TimeRange = Tuple[float, float] + + +def _export_audio(video: VideoFileClip, temp_audio_path: str) -> None: + """Eksportuoja garso takelį iš ``video`` į laikiną ``temp_audio_path`` failą.""" + video.audio.write_audiofile(temp_audio_path, logger=None) + + +def _detect_nonsilent_parts( + audio_path: str, min_silence_len: int, silence_thresh: int +) -> List[TimeRange]: + """Randa ir grąžina visų tylos atkarpų laiko intervalus.""" + sound = AudioSegment.from_file(audio_path) + nonsilent_parts = detect_nonsilent( + sound, min_silence_len=min_silence_len, silence_thresh=silence_thresh + ) + return [(start / 1000, end / 1000) for start, end in nonsilent_parts] + + +def _process_segment( + video: VideoFileClip, start: float, end: float, min_silence_len: int +) -> VideoFileClip: + """Sukuria apdorotą ``VideoFileClip`` konkrečiai laiko atkarpai.""" + segment_duration = end - start + segment = video.subclip(start, end) + + # Jei atkarpa trumpesnė nei nustatyta minimali tyla, ją išsaugome kaip yra. + if segment_duration >= (min_silence_len / 1000): + return segment + + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_input, \ + tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_output: + segment.write_videofile( + temp_input.name, codec="libx264", audio_codec="aac", logger=None + ) + + # FFmpeg pagreitinimas, jei reikėtų korekcijų ateityje. + ffmpeg_cmd = [ + "ffmpeg", + "-y", + "-i", + temp_input.name, + "-filter_complex", + f"[0:v]setpts={1}*PTS[v]; [0:a]atempo={1}[a]", + "-map", + "[v]", + "-map", + "[a]", + "-c:v", + "libx264", + "-c:a", + "aac", + temp_output.name, + ] + + subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + return VideoFileClip(temp_output.name) + + +def jumpcutter( + video_path: str, + output_path: str, + silence_thresh: int = -40, + min_silence_len: int = 500, +) -> None: + """Sukuria naują video, kuriame tylos atkarpos praleidžiamos.""" + if not os.path.isfile(video_path): + print(f"Klaida: failas '{video_path}' nerastas.") + return + + print(f"\nApdorojamas failas: {video_path}") + print("[1/4] Įkeliame video failą į atmintį...") + video = VideoFileClip(video_path) + + print("[2/4] Išskiriame garso takelį...") + temp_audio_path = "temp_audio.wav" + _export_audio(video, temp_audio_path) + + print("[3/4] Ieškome kalbos (ne tylos) atkarpų garse...") + nonsilent_times = _detect_nonsilent_parts( + temp_audio_path, min_silence_len=min_silence_len, silence_thresh=silence_thresh + ) + + print("[4/4] Sujungiame rastas atkarpas į naują video failą...") + clips = [] + for start, end in tqdm(nonsilent_times, desc="Apdorojame vaizdo segmentus"): + try: + clips.append(_process_segment(video, start, end, min_silence_len)) + except Exception as exc: # pragma: no cover - logging is side-effect only + print(f"Klaida pagreitinant segmentą {start}-{end}: {exc}. Segmentas praleidžiamas.") + continue + + final_video = concatenate_videoclips(clips) + final_video.write_videofile(output_path, codec="libx264", audio_codec="aac") + + # Tvarkome temp failus ir pranešame vartotojui. + if os.path.exists(temp_audio_path): + os.remove(temp_audio_path) + + print(f"Darbas baigtas! Naujas failas: {output_path}") diff --git a/jumpcutter/gui.py b/jumpcutter/gui.py new file mode 100644 index 0000000..767f26c --- /dev/null +++ b/jumpcutter/gui.py @@ -0,0 +1,118 @@ +"""Grafinė „Jumpcutter“ naudotojo sąsaja.""" + +import os +import tkinter as tk +from tkinter import filedialog, ttk + +from .core import jumpcutter + + +class JumpCutterApp(tk.Tk): + """Tkinter pagrindu sukurta sąsaja „Jumpcutter“ nustatymams ir paleidimui.""" + + def __init__(self) -> None: + super().__init__() + self.title("JumpCutter") + self.geometry("300x300") + self._create_widgets() + + # --- UI helpers ------------------------------------------------- + def _update_thresh_desc(self, value: str) -> None: + value = int(float(value)) + if value <= -55: + desc = "Labai jautru (tyla studijoje)" + elif value <= -45: + desc = "Rekomenduojama kalbiniams įrašams" + elif value <= -35: + desc = "Vidutinis slenkstis (gatvės triukšmas)" + else: + desc = "Mažas jautrumas (triukšminga aplinka)" + self.thresh_desc.config(text=f"{value} dB: {desc}") + + def _update_silence_len_desc(self, value: str) -> None: + self.silence_len_desc.config(text=f"Minimali tyla: {int(float(value))} ms") + + # --- UI setup --------------------------------------------------- + def _create_widgets(self) -> None: + main_frame = tk.Frame(self) + main_frame.pack(pady=10) + + # Tylos slenkscio kontrole + thresh_frame = tk.Frame(main_frame) + thresh_frame.pack(pady=5) + + tk.Label(thresh_frame, text="Tylos slenkstis:").pack() + self.silence_thresh = tk.Scale( + thresh_frame, + from_=-60, + to=-30, + resolution=5, + orient="horizontal", + command=self._update_thresh_desc, + ) + self.silence_thresh.set(-40) + self.silence_thresh.pack() + + self.thresh_desc = tk.Label(thresh_frame, text="", fg="gray") + self.thresh_desc.pack() + self._update_thresh_desc(-40) + + # Minimali tylos trukmes kontole + silence_len_frame = tk.Frame(main_frame) + silence_len_frame.pack(pady=5) + + tk.Label(silence_len_frame, text="Minimali tylos trukmė:").pack() + self.min_silence_len = tk.Scale( + silence_len_frame, + from_=100, + to=2000, + resolution=100, + orient="horizontal", + command=self._update_silence_len_desc, + ) + self.min_silence_len.set(500) + self.min_silence_len.pack() + + self.silence_len_desc = tk.Label(silence_len_frame, text="", fg="gray") + self.silence_len_desc.pack() + self._update_silence_len_desc(500) + + # Vaizdo pasirinkimo mygtukas + tk.Button(self, text="Pasirinkti video", command=self._select_file).pack(pady=20) + + # Progreso indikatorius ir statusas + self.progress = ttk.Progressbar(self, mode="indeterminate") + self.status_label = tk.Label(self, text="", fg="gray") + self.status_label.pack() + + # --- Veiksmai ---------------------------------------------------- + def _select_file(self) -> None: + file_path = filedialog.askopenfilename( + title="Pasirinkite video failą", + filetypes=[("Video Files", "*.mp4 *.mkv *.webm *.mov")], + ) + if file_path: + self._process_video(file_path) + + def _process_video(self, input_path: str) -> None: + self.progress.pack(pady=10) + self.progress.start() + self.status_label.config(text="Apdorojama...", fg="green") + + try: + output_path = f"jumpcutted_{os.path.basename(input_path)}" + jumpcutter( + input_path, + output_path, + silence_thresh=int(self.silence_thresh.get()), + min_silence_len=int(self.min_silence_len.get()), + ) + self.status_label.config(text=f"Baigta! Išsaugota: {output_path}", fg="blue") + except Exception as exc: # pragma: no cover - logging is side-effect only + self.status_label.config(text=f"Klaida: {exc}", fg="red") + finally: + self.progress.stop() + self.progress.pack_forget() + + +__all__ = ["JumpCutterApp"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..504d9b2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +moviepy==1.0.3 +pydub==0.25.1 +numpy +pillow +decorator +imageio==2.9.0 +imageio-ffmpeg +proglog +opencv-python>=4.5.0 +scipy>=1.7.0 \ No newline at end of file