import os import subprocess import sys import mpv from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal from PyQt6.QtGui import QColor, QPainter, QPen from PyQt6.QtWidgets import QApplication, QFrame, QMainWindow, QWidget def build_export_path(folder: str, basename: str, counter: int) -> str: filename = f"{basename}_{counter:03d}.mp4" return os.path.join(folder, filename) def format_time(seconds: float) -> str: m = int(seconds // 60) s = int(seconds % 60 * 10) / 10 # floor-truncate to 1dp, prevents "X:60.0" rollover return f"{m}:{s:04.1f}" def build_ffmpeg_command(input_path: str, start: float, output_path: str) -> list[str]: # -ss before -i: fast input-seeking. Safe here because we always re-encode # (libx264/aac), so there is no keyframe-alignment issue from pre-input seek. return [ "ffmpeg", "-y", "-ss", str(start), "-i", input_path, "-t", "8", "-c:v", "libx264", "-c:a", "aac", output_path, ] class ExportWorker(QThread): finished = pyqtSignal(str) # output path error = pyqtSignal(str) # error message def __init__(self, input_path: str, start: float, output_path: str): super().__init__() self._input = input_path self._start = start self._output = output_path def run(self): cmd = build_ffmpeg_command(self._input, self._start, self._output) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode == 0: self.finished.emit(self._output) else: self.error.emit(result.stderr[-500:]) except FileNotFoundError: self.error.emit("ffmpeg not found — is it installed and on PATH?") except Exception as e: self.error.emit(str(e)) class TimelineWidget(QWidget): cursor_changed = pyqtSignal(float) # emits position in seconds def __init__(self): super().__init__() self.setMinimumHeight(40) self.setMouseTracking(True) self._duration = 0.0 self._cursor = 0.0 def set_duration(self, duration: float): self._duration = duration self._cursor = 0.0 self.update() def set_cursor(self, seconds: float): self._cursor = max(0.0, min(seconds, max(0.0, self._duration - 8.0))) self.update() def _pos_to_time(self, x: int) -> float: if self._duration <= 0 or self.width() <= 0: return 0.0 ratio = max(0.0, min(1.0, x / self.width())) return ratio * self._duration def paintEvent(self, event): p = QPainter(self) try: w, h = self.width(), self.height() # Background p.fillRect(0, 0, w, h, QColor(30, 30, 30)) if self._duration <= 0: return # 8s selection highlight x_start = int(self._cursor / self._duration * w) x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w) p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120)) # Cursor line pen = QPen(QColor(255, 200, 0)) pen.setWidth(2) p.setPen(pen) p.drawLine(x_start, 0, x_start, h) finally: p.end() def mousePressEvent(self, event): self._seek(event.position().x()) def mouseMoveEvent(self, event): if event.buttons(): self._seek(event.position().x()) def _seek(self, x: float): t = self._pos_to_time(int(x)) self.set_cursor(t) self.cursor_changed.emit(self._cursor) class MpvWidget(QFrame): file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready def __init__(self): super().__init__() self.setMinimumSize(640, 360) self.setStyleSheet("background: black;") # Required so Qt creates a real native window handle for mpv to embed into. # Without these, mpv opens a separate window instead of embedding. self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen, True) self._player = None def _init_player(self): if self._player is not None: return self._player = mpv.MPV( wid=str(int(self.winId())), keep_open=True, pause=True, ) # mpv fires events on its own thread; bounce to Qt thread via QTimer. @self._player.event_callback("file-loaded") def _on_file_loaded(event): QTimer.singleShot(0, self.file_loaded.emit) def load(self, path: str): self._init_player() self._player.play(path) def seek(self, t: float): if self._player: self._player.pause = True self._player.seek(t, "absolute") def play_loop(self, a: float, b: float): if self._player: self._player["ab-loop-a"] = a self._player["ab-loop-b"] = b self._player.pause = False def stop_loop(self): if self._player: # ab-loop-a/b are numeric properties — setting to "no" via dict # accessor throws TypeError. Disable loop via ab_loop_count instead. self._player.ab_loop_count = 0 self._player.pause = True def get_duration(self) -> float: if self._player: d = self._player.duration return d if d else 0.0 return 0.0 def closeEvent(self, event): if self._player: self._player.terminate() super().closeEvent(event) def main(): app = QApplication(sys.argv) win = MainWindow() win.show() sys.exit(app.exec()) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("8-cut") self.resize(900, 650) if __name__ == "__main__": main()