U svetu digitalne video produkcije često se suočavamo sa problemom neujednačenih video isečaka.
Različite kamere, telefoni i softveri snimaju sa različitim rezolucijama, frekvencijama sličica (FPS) i nivoima jačine zvuka.
Rezultat je kolekcija fajlova koji izgledaju i zvuče neujednačeno — što otežava kasniju montažu, spajanje ili objavljivanje.
Upravo zato je razvijen ovaj jednostavan, ali moćan Python + PyQt6 program za automatsku normalizaciju videa i zvuka.
Šta program radi
Program automatski prolazi kroz sve video fajlove u izabranom folderu i:
- Normalizuje rezoluciju na zadatu vrednost (npr. 1280×720).
- Normalizuje frame rate (FPS) tako da svi fajlovi imaju isti broj sličica u sekundi — ili automatski izabere najveći FPS iz foldera.
- Normalizuje jačinu zvuka pomoću EBU R128 standarda, što znači da svi klipovi imaju ujednačen i profesionalno balansiran nivo glasnoće.
Rezultat je set video fajlova koji su potpuno spremni za dalju obradu, spajanje ili objavljivanje na platformama poput YouTube-a, TikToka ili društvenih mreža.
Zašto je normalizacija važna
1. Ujednačena rezolucija
Kada video isečci imaju različite dimenzije (npr. 1080p, 720p, 4K), spajanje u jedan projekat izaziva razvučene ili „letterbox“ slike.
Normalizacija rezolucije sprečava ove probleme i osigurava konzistentan izgled.
2. Ujednačen FPS (frame rate)
Različiti FPS (24, 25, 30, 60…) dovode do trzanja, različitog trajanja ili desinhronizacije pri spajanju.
Program automatski detektuje sve frame rate-ove i može da izabere najviši (max), čime se izbegavaju gubici u glatkoći slike.
3. Ujednačena jačina zvuka
Klipovi snimani različitim uređajima često imaju neujednačen volumen — neki preglasni, neki tihi.
Korišćenjem EBU R128 algoritma, program balansira nivoe tako da svi zvuče jednako prijatno i profesionalno.
Kako se koristi
- Pokrenite program
normalizacija.py. - Kliknite na „Izaberi folder“ i odaberite direktorijum sa video fajlovima.
- U padajućim menijima podesite:
- Ciljnu rezoluciju (npr. 1280×720)
- Ciljni frame rate (npr. „Automatski – maksimalni FPS u folderu“)
- Normalizaciju zvuka (npr. „Napredna (EBU R128)“)
- Kliknite na „Pokreni normalizaciju“.
- Program će prikazivati napredak u terminal prozoru i automatski čuvati konvertovane fajlove u podfolderu
normalized/.
Prednosti
- Potpuno automatski proces, bez ručnog rada u editorima.
- Stabilni izlazni fajlovi, spremni za spajanje u bilo kom video editoru.
- Profesionalna audio normalizacija po standardima evropskih medija.
- Jednostavan GUI, prilagođen i početnicima i profesionalcima.
Tehnički detalji
Program koristi:
- Python 3 + PyQt6 za grafički interfejs
- FFmpeg za sve video/audio operacije
-vf scale=...,-r ..., i-af loudnorm=...filtere za normalizaciju
Podržani su svi popularni formati:mp4, mov, mkv, avi, m4v, webm.
Ako radite sa većim brojem video isečaka iz različitih izvora, ovaj alat vam može uštedeti sate posla.
Automatska normalizacija rezolucije, frame rate-a i zvuka čini svaki fajl uniformnim i profesionalno pripremljenim za objavljivanje.
Napomena
Program je potpuno besplatan i open-source, zasnovan na FFmpeg tehnologiji, i radi na Linux, Windows i macOS sistemima.
Sve što vam treba jeste instaliran ffmpeg i python3 -m pip install pyqt6.
Evo jedne linije koja instalira sve što je potrebno za tvoj program 👇
sudo apt update && sudo apt install -y ffmpeg python3-pip && python3 -m pip install --upgrade pip pyqt6
✅ Ova komanda:
- Osvežava listu paketa
- Instalira ffmpeg i pip
- Ažurira pip na najnoviju verziju
- Instalira PyQt6 (potreban za GUI)
Radi na svim Debian/Ubuntu/elementary OS/Linux Mint sistemima.
Programski kod za xnorm.py
#xnorm.py
# potrebne instalacije programa i zavisnosti:
# sudo apt update && sudo apt install -y ffmpeg python3-pip && python3 -m pip install --upgrade pip pyqt6
import os
import subprocess
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFileDialog, QTextEdit, QComboBox, QMessageBox
)
from PyQt6.QtGui import QTextCursor
from PyQt6.QtCore import Qt, QThread, pyqtSignal
def get_video_fps(video_path):
"""Vrati FPS vrednost video fajla pomoću ffprobe."""
try:
cmd = [
"ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1",
video_path
]
output = subprocess.check_output(cmd, text=True).strip()
if "/" in output:
num, den = output.split("/")
fps = float(num) / float(den)
else:
fps = float(output)
return round(fps)
except Exception:
return None
class NormalizationThread(QThread):
progress = pyqtSignal(str)
finished = pyqtSignal()
def __init__(self, folder, resolution, fps_mode, audio_mode):
super().__init__()
self.folder = folder
self.resolution = resolution
self.fps_mode = fps_mode
self.audio_mode = audio_mode
def run(self):
videos = [f for f in os.listdir(self.folder) if f.lower().endswith(('.mp4', '.mkv', '.mov', '.avi'))]
if not videos:
self.progress.emit("[error] Nema video fajlova u izabranom folderu.\n")
self.finished.emit()
return
# Automatsko određivanje max FPS-a ako je izabrano
target_fps = None
if self.fps_mode == "Automatski (max u folderu)":
fps_values = []
self.progress.emit("[info] Analiziram FPS vrednosti...\n")
for video in videos:
fps = get_video_fps(os.path.join(self.folder, video))
if fps:
fps_values.append(fps)
self.progress.emit(f" {video}: {fps} fps\n")
if fps_values:
target_fps = max(fps_values)
self.progress.emit(f"[info] Maksimalni detektovani FPS: {target_fps}\n")
else:
self.progress.emit("[warning] Nije moguće odrediti FPS, koristi se originalni.\n")
output_folder = os.path.join(self.folder, "normalized")
os.makedirs(output_folder, exist_ok=True)
for video in videos:
input_path = os.path.join(self.folder, video)
base_name, ext = os.path.splitext(video)
output_path = os.path.join(output_folder, f"{base_name}_normalized.mp4")
# Video filter (rezolucija + fps)
vf_filters = []
if self.resolution != "Zadrži originalnu":
w, h = self.resolution.split("x")
vf_filters.append(f"scale={w}:{h}")
fps_value = None
if self.fps_mode not in ["Zadrži originalni", "Automatski (max u folderu)"]:
fps_value = self.fps_mode
elif self.fps_mode == "Automatski (max u folderu)" and target_fps:
fps_value = str(target_fps)
if fps_value:
vf_filters.append(f"fps={fps_value}")
vf_part = f'-vf "{",".join(vf_filters)}"' if vf_filters else ""
# Audio filter
if self.audio_mode == "Jednostavna":
audio_filter = '-filter:a "volume=normalize"'
elif self.audio_mode == "Napredna (EBU R128)":
audio_filter = '-af "loudnorm=I=-16:TP=-1.5:LRA=11"'
else:
audio_filter = ""
cmd = f'ffmpeg -i "{input_path}" {vf_part} {audio_filter} -c:v libx264 -preset medium -crf 18 -c:a aac -b:a 192k "{output_path}" -y'
self.progress.emit(f"\n[info] Obrada fajla: {video}\n")
self.progress.emit(f"[cmd] {cmd}\n")
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in process.stdout:
self.progress.emit(line)
process.wait()
if process.returncode == 0:
self.progress.emit(f"[ok] Završeno: {output_path}\n")
else:
self.progress.emit(f"[error] Greška pri obradi: {video}\n")
self.finished.emit()
class NormalizerApp(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("FFmpeg Normalizacija Videa")
self.setGeometry(300, 200, 800, 600)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
# Folder izbor
folder_layout = QHBoxLayout()
self.folder_label = QLabel("Izabrani folder: (nijedan)")
self.folder_btn = QPushButton("Odaberi folder")
self.folder_btn.clicked.connect(self.select_folder)
folder_layout.addWidget(self.folder_label)
folder_layout.addWidget(self.folder_btn)
layout.addLayout(folder_layout)
# Rezolucija
res_layout = QHBoxLayout()
res_layout.addWidget(QLabel("Ciljna rezolucija:"))
self.res_combo = QComboBox()
self.res_combo.addItems([
"Zadrži originalnu", "1920x1080", "1280x720", "854x480", "640x360", "320x240"
])
self.res_combo.setCurrentText("1280x720")
res_layout.addWidget(self.res_combo)
layout.addLayout(res_layout)
# FPS
fps_layout = QHBoxLayout()
fps_layout.addWidget(QLabel("Ciljni frame rate (fps):"))
self.fps_combo = QComboBox()
self.fps_combo.addItems([
"Zadrži originalni", "Automatski (max u folderu)", "24", "25", "30", "60"
])
self.fps_combo.setCurrentText("Automatski (max u folderu)")
fps_layout.addWidget(self.fps_combo)
layout.addLayout(fps_layout)
# Audio normalizacija
audio_layout = QHBoxLayout()
audio_layout.addWidget(QLabel("Normalizacija zvuka:"))
self.audio_combo = QComboBox()
self.audio_combo.addItems([
"Bez normalizacije", "Jednostavna", "Napredna (EBU R128)"
])
self.audio_combo.setCurrentText("Napredna (EBU R128)")
audio_layout.addWidget(self.audio_combo)
layout.addLayout(audio_layout)
# Pokretanje
self.start_btn = QPushButton("Pokreni normalizaciju")
self.start_btn.clicked.connect(self.start_normalization)
layout.addWidget(self.start_btn)
# Terminal
self.terminal = QTextEdit()
self.terminal.setReadOnly(True)
self.terminal.setStyleSheet("background-color: #101010; color: #00FF00; font-family: monospace;")
layout.addWidget(self.terminal)
self.setLayout(layout)
def select_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Odaberi folder sa videima")
if folder:
self.folder_label.setText(f"Izabrani folder: {folder}")
self.folder = folder
self.append_terminal(f"[info] Izabrani folder: {folder}\n")
def append_terminal(self, text):
self.terminal.insertPlainText(text)
self.terminal.moveCursor(QTextCursor.MoveOperation.End)
QApplication.processEvents()
def start_normalization(self):
if not hasattr(self, "folder") or not os.path.isdir(self.folder):
QMessageBox.warning(self, "Greška", "Molim te izaberi folder sa video fajlovima.")
return
resolution = self.res_combo.currentText()
fps_mode = self.fps_combo.currentText()
audio_mode = self.audio_combo.currentText()
self.append_terminal(f"[info] Pokrećem normalizaciju ({resolution}, {fps_mode}, audio: {audio_mode})\n\n")
self.thread = NormalizationThread(self.folder, resolution, fps_mode, audio_mode)
self.thread.progress.connect(self.append_terminal)
self.thread.finished.connect(self.on_finished)
self.thread.start()
def on_finished(self):
self.append_terminal("[done] Normalizacija svih videa završena.\n")
if __name__ == "__main__":
app = QApplication([])
window = NormalizerApp()
window.show()
app.exec()
#MIT_License.txt MIT License Copyright (c) [2025] [Aleksandar Maričić] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
