FFmpeg Studio – Moćan alat za preciznu obradu videa

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. 

By Abel

Leave a Reply

Your email address will not be published. Required fields are marked *