U digitalnom svetu gde video sadržaj zauzima sve više prostora, potreba za jednostavnim i efikasnim alatima za obradu videa postaje ključna. FFmpeg Studio je moderan PyQt GUI program koji omogućava naprednu obradu video fajlova direktno na vašem računaru, bez potrebe za komplikovanim komandnim linijama.
Glavne funkcionalnosti
1. Precizno pomeranje frejmova
Program koristi OpenCV za prikaz videa frejm po frejm, što omogućava maksimalnu preciznost pri izboru sekvenci i capture pojedinačnih frejmova. Pomeranje se može vršiti pomoću dugmića „Frame <-“ i „Frame ->“ ili točkićem miša, uz mogućnost zaustavljanja videa u bilo kojem trenutku.
2. Capture frejma
Bilo koji trenutni frejm može biti sačuvan kao JPG slika jednim klikom. Ovo je idealno za snimanje važnih scena, pravljenje thumbnailova ili referentnih slika za montažu.
3. Izdvajanje segmenta videa
Pomoću dugmića „Od“ i „Do“ korisnik može označiti početak i kraj segmenta, a program automatski konvertuje vreme u HH:MM:SS format za preciznu obradu. Segment se zatim može izdvojiti i sačuvati kao poseban video fajl.
4. Spajanje više video fajlova
FFmpeg Studio omogućava selektovanje više video fajlova i njihovo spajanje u jedan fajl, uz mogućnost promene redosleda i uklanjanja nepotrebnih stavki sa liste. Ova funkcija olakšava kreiranje montiranih video zapisa ili pripremu materijala za prezentacije i društvene mreže.
5. Interaktivna lista fajlova
Lista video fajlova omogućava jednostavno dodavanje, uklanjanje, kao i pomeranje pojedinačnih stavki gore ili dole, što je posebno korisno prilikom spajanja videa. Sve operacije se rade u GUI, bez potrebe za ručnim editovanjem fajlova.
6. Jednostavan i moderan PyQt GUI
Korisnički interfejs je intuitivan, sa jasno raspoređenim dugmićima za kontrolu reprodukcije, markiranje segmenata i upravljanje listom fajlova. Video prikaz je centralizovan, a slider pokazuje trenutni frejm u realnom vremenu.
Napomena o zvuku
Trenutno program koristi OpenCV za prikaz videa, što omogućava precizno pomeranje frejmova i capture. Reprodukcija zvuka nije podržana u ovom modu, ali je moguće proširiti program kombinacijom OpenCV i audio biblioteka kao što su pydub ili pygame, čime bi video i zvuk bili potpuno sinhronizovani.
FFmpeg Studio je moćan i praktičan alat za sve koji žele preciznu kontrolu nad video materijalom. Bilo da pripremate kratke isječke, montirate duže snimke ili snimate pojedinačne frejmove, ovaj program vam omogućava da to uradite brzo, intuitivno i sa profesionalnom preciznošću.
Instalacija ffmpeg:
sudo apt update sudo apt install ffmpeg
instalacija svih potrebnih Python zavisnosti:
python3 -m pip install pyqt6 opencv-python numpy
Programski kod za studio.py
import sys
import cv2
import os
import subprocess
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QPushButton, QLabel, QFileDialog, QSlider, QHBoxLayout, QListWidget, QMessageBox, QInputDialog
)
from PyQt6.QtGui import QPixmap, QImage
from PyQt6.QtCore import Qt, QTimer
class VideoLabel(QLabel):
def __init__(self, parent=None):
super().__init__(parent)
self.frame_step_func = None
self.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
def wheelEvent(self, event):
if self.frame_step_func:
if hasattr(self.frame_step_func.__self__, 'timer'):
self.frame_step_func.__self__.timer.stop()
step = 1 if event.angleDelta().y() > 0 else -1
self.frame_step_func(step)
class FFmpegOpencvEditor(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle('FFmpeg Studio - Frame Editor')
self.resize(1000, 750)
self.video_files = []
self.current_video = None
self.cap = None
self.total_frames = 0
self.fps = 25
self.current_frame_idx = 0
self.timer = QTimer()
self.timer.timeout.connect(self.timer_next_frame)
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout()
# File controls
file_layout = QHBoxLayout()
self.btn_add = QPushButton('➕ Dodaj fajlove')
self.btn_clear = QPushButton('🗑️ Očisti listu')
self.btn_remove = QPushButton('❌ Ukloni selektovano')
self.btn_up = QPushButton('⬆️ Pomeri gore')
self.btn_down = QPushButton('⬇️ Pomeri dole')
file_layout.addWidget(self.btn_add)
file_layout.addWidget(self.btn_clear)
file_layout.addWidget(self.btn_remove)
file_layout.addWidget(self.btn_up)
file_layout.addWidget(self.btn_down)
layout.addLayout(file_layout)
self.list_files = QListWidget()
self.list_files.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
layout.addWidget(self.list_files)
# Video display
self.video_label = VideoLabel()
self.video_label.frame_step_func = self.frame_step
self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.video_label.setMinimumHeight(400)
layout.addWidget(self.video_label)
# Slider and time label
slider_layout = QHBoxLayout()
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setRange(0, 1000)
self.lbl_time = QLabel('00:00 / 00:00')
slider_layout.addWidget(self.slider)
slider_layout.addWidget(self.lbl_time)
layout.addLayout(slider_layout)
# Playback controls
playback_layout = QHBoxLayout()
self.btn_play = QPushButton('▶️ Play')
self.btn_pause = QPushButton('⏸️ Pause')
self.btn_stop = QPushButton('⏹️ Stop')
playback_layout.addWidget(self.btn_play)
playback_layout.addWidget(self.btn_pause)
playback_layout.addWidget(self.btn_stop)
layout.addLayout(playback_layout)
# Frame controls
frame_layout = QHBoxLayout()
self.btn_frame_back = QPushButton('⏪ Frame <-')
self.btn_frame_forward = QPushButton('Frame -> ⏩')
self.btn_capture = QPushButton('📸 Capture Frame')
frame_layout.addWidget(self.btn_frame_back)
frame_layout.addWidget(self.btn_frame_forward)
frame_layout.addWidget(self.btn_capture)
layout.addLayout(frame_layout)
# Segment controls
segment_layout = QHBoxLayout()
self.btn_mark_start = QPushButton('Od')
self.btn_mark_end = QPushButton('Do')
self.param1 = QLabel('')
self.param2 = QLabel('')
segment_layout.addWidget(self.btn_mark_start)
segment_layout.addWidget(self.btn_mark_end)
segment_layout.addWidget(self.param1)
segment_layout.addWidget(self.param2)
layout.addLayout(segment_layout)
# Extract/Merge
run_layout = QHBoxLayout()
self.btn_extract = QPushButton('✂️ Izdvoji segment')
self.btn_merge = QPushButton('🔗 Merge Videos')
run_layout.addWidget(self.btn_extract)
run_layout.addWidget(self.btn_merge)
layout.addLayout(run_layout)
self.setLayout(layout)
# Connections
self.btn_add.clicked.connect(self.add_files)
self.btn_clear.clicked.connect(self.list_files.clear)
self.btn_remove.clicked.connect(self.remove_selected)
self.btn_up.clicked.connect(lambda: self.move_selected(-1))
self.btn_down.clicked.connect(lambda: self.move_selected(1))
self.list_files.itemDoubleClicked.connect(self.load_video)
self.btn_play.clicked.connect(self.play_video)
self.btn_pause.clicked.connect(self.pause_video)
self.btn_stop.clicked.connect(self.stop_video)
self.btn_frame_back.clicked.connect(lambda: self.frame_step(-1))
self.btn_frame_forward.clicked.connect(lambda: self.frame_step(1))
self.slider.sliderMoved.connect(self.slider_seek)
self.btn_capture.clicked.connect(self.capture_frame)
self.btn_mark_start.clicked.connect(self.mark_start)
self.btn_mark_end.clicked.connect(self.mark_end)
self.btn_extract.clicked.connect(self.extract_segment)
self.btn_merge.clicked.connect(self.merge_videos)
# --- Video operations ---
def add_files(self):
files, _ = QFileDialog.getOpenFileNames(self, 'Odaberi video fajlove', '', 'Video fajlovi (*.mp4 *.mkv *.avi *.mov)')
for f in files:
self.list_files.addItem(f)
def remove_selected(self):
for item in self.list_files.selectedItems():
self.list_files.takeItem(self.list_files.row(item))
def move_selected(self, direction):
selected = self.list_files.selectedItems()
if not selected:
return
for item in selected:
row = self.list_files.row(item)
new_row = row + direction
if 0 <= new_row < self.list_files.count():
self.list_files.takeItem(row)
self.list_files.insertItem(new_row, item)
item.setSelected(True)
def load_video(self, item):
path = item.text()
if self.cap:
self.cap.release()
self.cap = cv2.VideoCapture(path)
if not self.cap.isOpened():
QMessageBox.warning(self, 'Greška', 'Ne može da se otvori video!')
return
self.current_video = path
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.fps = self.cap.get(cv2.CAP_PROP_FPS) or 25
self.current_frame_idx = 0
self.slider.setRange(0, self.total_frames-1)
self.show_frame(self.current_frame_idx)
# --- Frame / Video display ---
def show_frame(self, idx):
if not self.cap:
return
self.cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ret, frame = self.cap.read()
if not ret:
return
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w, ch = frame_rgb.shape
bytes_per_line = ch * w
qimg = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
pixmap = QPixmap.fromImage(qimg)
self.video_label.setPixmap(pixmap.scaled(self.video_label.size(), Qt.AspectRatioMode.KeepAspectRatio))
self.current_frame_idx = idx
self.slider.setValue(idx)
self.lbl_time.setText(f'{self.format_time(idx / self.fps)} / {self.format_time(self.total_frames / self.fps)}')
def frame_step(self, step):
if not self.cap:
return
new_idx = max(0, min(self.total_frames - 1, self.current_frame_idx + step))
self.show_frame(new_idx)
def slider_seek(self, value):
self.show_frame(value)
# --- Playback ---
def play_video(self):
self.timer.start(int(1000 / self.fps))
def pause_video(self):
self.timer.stop()
def stop_video(self):
self.timer.stop()
self.show_frame(0)
def timer_next_frame(self):
if self.current_frame_idx < self.total_frames - 1:
self.show_frame(self.current_frame_idx + 1)
else:
self.timer.stop()
# --- Capture / Segment ---
def capture_frame(self):
if not self.cap or not self.current_video:
return
save_path, _ = QFileDialog.getSaveFileName(self, 'Save Frame', '', 'JPG Files (*.jpg)')
if save_path:
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame_idx)
ret, frame = self.cap.read()
if ret:
cv2.imwrite(save_path, frame)
QMessageBox.information(self, 'Snimljeno', f'Frejm sačuvan: {save_path}')
def mark_start(self):
self.param1.setText(self.seconds_to_hhmmss(self.current_frame_idx / self.fps))
def mark_end(self):
self.param2.setText(self.seconds_to_hhmmss(self.current_frame_idx / self.fps))
def extract_segment(self):
if not self.current_video:
return
start_time = self.param1.text()
end_time = self.param2.text()
if not start_time or not end_time:
QMessageBox.warning(self, 'Greška', 'Odredi start i end vreme!')
return
# Dijalog za unos imena segmenta
text, ok = QInputDialog.getText(self, 'Naziv segmenta', 'Unesi naziv segmenta:')
if not ok or not text.strip():
return
name = text.strip().replace(' ', '_')
# Generisanje putanje za izlazni fajl
out_dir = os.path.dirname(self.current_video)
out_path = os.path.join(out_dir, f"{name}.mp4")
cmd = [
'ffmpeg', '-y', '-i', self.current_video,
'-ss', start_time, '-to', end_time,
'-c:v', 'libx264', '-c:a', 'aac',
out_path
]
subprocess.Popen(cmd)
QMessageBox.information(self, 'Segment', f'Segment sačuvan u {out_path}')
def merge_videos(self):
items = self.list_files.selectedItems()
if len(items) < 2:
QMessageBox.warning(self, 'Greška', 'Selektuj bar dva fajla!')
return
files = [item.text() for item in items]
list_txt = 'merge_list.txt'
with open(list_txt, 'w') as f:
for file in files:
f.write(f"file '{os.path.abspath(file)}'\n")
out_path, _ = QFileDialog.getSaveFileName(self, 'Save Merged Video', '', 'MP4 Files (*.mp4)')
if not out_path:
return
cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', list_txt, '-c', 'copy', out_path]
subprocess.Popen(cmd)
QMessageBox.information(self, 'Spajanje', f'Video fajlovi spojeni u {out_path}')
def seconds_to_hhmmss(self, seconds):
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f'{h:02d}:{m:02d}:{s:02d}'
def format_time(self, seconds):
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f'{h:02d}:{m:02d}:{s:02d}'
if __name__ == '__main__':
app = QApplication(sys.argv)
win = FFmpegOpencvEditor()
win.show()
sys.exit(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.
