From 0b69c7146a6c1a0c0e6b1df2d31b1e05c5930a38 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 04:09:46 +1100 Subject: [PATCH 01/67] Initial commit --- README.md | 38 + requirements.txt | 3 + videoeditor/main.py | 1249 +++++++++++++++++++ videoeditor/plugins.py | 266 ++++ videoeditor/plugins/ai_frame_joiner/main.py | 308 +++++ 5 files changed, 1864 insertions(+) create mode 100644 videoeditor/main.py create mode 100644 videoeditor/plugins.py create mode 100644 videoeditor/plugins/ai_frame_joiner/main.py diff --git a/README.md b/README.md index fdaacf94f..2b409148a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ +# Inline AI Video Editor + +A simple, non-linear video editor built with Python, PyQt6, and FFmpeg. It provides a multi-track timeline, a media preview window, and basic clip manipulation capabilities, all wrapped in a dockable user interface. The editor is designed to be extensible through a plugin system. + +## Features + +- **Inline AI Video Generation With WAN2GP**: Select a region to join and it will bring up a desktop port of WAN2GP for you to generate a video inline using the start and end frames in the selected region. +- **Multi-Track Timeline**: Arrange video and audio clips on separate tracks. +- **Project Management**: Create, save, and load projects in a `.json` format. +- **Clip Operations**: + - Drag and drop clips to reposition them in the timeline. + - Split clips at the playhead. + - Create selection regions for advanced operations. + - Join/remove content within selected regions across all tracks. +- **Real-time Preview**: A video preview window with playback controls (Play, Pause, Stop, Frame-by-frame stepping). +- **Dynamic Track Management**: Add or remove video and audio tracks as needed. +- **FFmpeg Integration**: + - Handles video processing for frame extraction, playback, and exporting. + - **Automatic FFmpeg Downloader (Windows)**: Automatically downloads the necessary FFmpeg executables on first run if they are not found. +- **Extensible Plugin System**: Load custom plugins to add new features and dockable widgets. +- **Customizable UI**: Features a dockable interface with resizable panels for the video preview and timeline. +- **More coming soon.. + +## Installation + +**Follow the standard installation steps for WAN2GP below** + +**Run the server:** +```bash +python main.py +``` + +**Run the video editor:** +```bash +python videoeditor\main.py +``` + + # WanGP ----- diff --git a/requirements.txt b/requirements.txt index cfe61c57f..f68b37b56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ + # Core AI stack diffusers==0.34.0 transformers==4.53.1 @@ -25,6 +26,7 @@ audio-separator==0.36.1 # UI & interaction gradio==5.29.0 +PyQt6 dashscope loguru @@ -57,6 +59,7 @@ ftfy piexif nvidia-ml-py misaki +GitPython # Optional / commented out # transformers==4.46.3 # for llamallava pre-patch diff --git a/videoeditor/main.py b/videoeditor/main.py new file mode 100644 index 000000000..717628c42 --- /dev/null +++ b/videoeditor/main.py @@ -0,0 +1,1249 @@ +import sys +import os +import uuid +import subprocess +import re +import json +import ffmpeg +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QPushButton, QFileDialog, QLabel, + QScrollArea, QFrame, QProgressBar, QDialog, + QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget) +from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, + QPixmap, QImage) +from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, + pyqtSignal, QTimer, QByteArray) + +from plugins import PluginManager, ManagePluginsDialog +import requests +import zipfile +from tqdm import tqdm + +def download_ffmpeg(): + if os.name != 'nt': return + exes = ['ffmpeg.exe', 'ffprobe.exe', 'ffplay.exe'] + if all(os.path.exists(e) for e in exes): return + api_url = 'https://api.github.com/repos/GyanD/codexffmpeg/releases/latest' + r = requests.get(api_url, headers={'Accept': 'application/vnd.github+json'}) + assets = r.json().get('assets', []) + zip_asset = next((a for a in assets if 'essentials_build.zip' in a['name']), None) + if not zip_asset: return + zip_url = zip_asset['browser_download_url'] + zip_name = zip_asset['name'] + with requests.get(zip_url, stream=True) as resp: + total = int(resp.headers.get('Content-Length', 0)) + with open(zip_name, 'wb') as f, tqdm(total=total, unit='B', unit_scale=True) as pbar: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + pbar.update(len(chunk)) + with zipfile.ZipFile(zip_name) as z: + for f in z.namelist(): + if f.endswith(tuple(exes)) and '/bin/' in f: + z.extract(f) + os.rename(f, os.path.basename(f)) + os.remove(zip_name) + +download_ffmpeg() + +class TimelineClip: + def __init__(self, source_path, timeline_start_sec, clip_start_sec, duration_sec, track_index, track_type, group_id): + self.id = str(uuid.uuid4()) + self.source_path = source_path + self.timeline_start_sec = timeline_start_sec + self.clip_start_sec = clip_start_sec + self.duration_sec = duration_sec + self.track_index = track_index + self.track_type = track_type + self.group_id = group_id + + @property + def timeline_end_sec(self): + return self.timeline_start_sec + self.duration_sec + +class Timeline: + def __init__(self): + self.clips = [] + self.num_video_tracks = 1 + self.num_audio_tracks = 1 + + def add_clip(self, clip): + self.clips.append(clip) + self.clips.sort(key=lambda c: c.timeline_start_sec) + + def get_total_duration(self): + if not self.clips: return 0 + return max(c.timeline_end_sec for c in self.clips) + +class ExportWorker(QObject): + progress = pyqtSignal(int) + finished = pyqtSignal(str) + def __init__(self, ffmpeg_cmd, total_duration): + super().__init__() + self.ffmpeg_cmd = ffmpeg_cmd + self.total_duration_secs = total_duration + def run_export(self): + try: + process = subprocess.Popen(self.ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, encoding="utf-8") + time_pattern = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})") + for line in iter(process.stdout.readline, ""): + match = time_pattern.search(line) + if match: + hours, minutes, seconds = [int(g) for g in match.groups()[:3]] + processed_time = hours * 3600 + minutes * 60 + seconds + if self.total_duration_secs > 0: + percentage = int((processed_time / self.total_duration_secs) * 100) + self.progress.emit(percentage) + process.stdout.close() + return_code = process.wait() + if return_code == 0: + self.progress.emit(100) + self.finished.emit("Export completed successfully!") + else: + self.finished.emit(f"Export failed! Check console for FFmpeg errors.") + except Exception as e: + self.finished.emit(f"An exception occurred during export: {e}") + +class TimelineWidget(QWidget): + PIXELS_PER_SECOND = 50 + TIMESCALE_HEIGHT = 30 + HEADER_WIDTH = 120 + TRACK_HEIGHT = 50 + AUDIO_TRACKS_SEPARATOR_Y = 15 + + split_requested = pyqtSignal(object) + playhead_moved = pyqtSignal(float) + split_region_requested = pyqtSignal(list) + split_all_regions_requested = pyqtSignal(list) + join_region_requested = pyqtSignal(list) + join_all_regions_requested = pyqtSignal(list) + add_track = pyqtSignal(str) + remove_track = pyqtSignal(str) + operation_finished = pyqtSignal() + context_menu_requested = pyqtSignal(QMenu, 'QContextMenuEvent') + + def __init__(self, timeline_model, settings, parent=None): + super().__init__(parent) + self.timeline = timeline_model + self.settings = settings + self.playhead_pos_sec = 0.0 + self.setMinimumHeight(300) + self.setMouseTracking(True) + self.selection_regions = [] + self.dragging_clip = None + self.dragging_linked_clip = None + self.dragging_playhead = False + self.creating_selection_region = False + self.dragging_selection_region = None + self.drag_start_pos = QPoint() + self.drag_original_timeline_start = 0 + self.selection_drag_start_sec = 0.0 + self.drag_selection_start_values = None + + self.highlighted_track_info = None + self.add_video_track_btn_rect = QRect() + self.remove_video_track_btn_rect = QRect() + self.add_audio_track_btn_rect = QRect() + self.remove_audio_track_btn_rect = QRect() + + self.video_tracks_y_start = 0 + self.audio_tracks_y_start = 0 + + def sec_to_x(self, sec): return self.HEADER_WIDTH + int(sec * self.PIXELS_PER_SECOND) + def x_to_sec(self, x): return float(x - self.HEADER_WIDTH) / self.PIXELS_PER_SECOND if x > self.HEADER_WIDTH else 0.0 + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QColor("#333")) + + self.draw_headers(painter) + self.draw_timescale(painter) + self.draw_tracks_and_clips(painter) + self.draw_selections(painter) + self.draw_playhead(painter) + + total_width = self.sec_to_x(self.timeline.get_total_duration()) + 100 + total_height = self.calculate_total_height() + self.setMinimumSize(max(self.parent().width(), total_width), total_height) + + def calculate_total_height(self): + video_tracks_height = (self.timeline.num_video_tracks + 1) * self.TRACK_HEIGHT + audio_tracks_height = (self.timeline.num_audio_tracks + 1) * self.TRACK_HEIGHT + return self.TIMESCALE_HEIGHT + video_tracks_height + self.AUDIO_TRACKS_SEPARATOR_Y + audio_tracks_height + 20 + + def draw_headers(self, painter): + painter.save() + painter.setPen(QColor("#AAA")) + header_font = QFont("Arial", 9, QFont.Weight.Bold) + button_font = QFont("Arial", 8) + + y_cursor = self.TIMESCALE_HEIGHT + + rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(rect, QColor("#3a3a3a")) + painter.drawRect(rect) + self.add_video_track_btn_rect = QRect(rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) + painter.setFont(button_font) + painter.fillRect(self.add_video_track_btn_rect, QColor("#454")) + painter.drawText(self.add_video_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "Add Track (+)") + y_cursor += self.TRACK_HEIGHT + self.video_tracks_y_start = y_cursor + + for i in range(self.timeline.num_video_tracks): + track_number = self.timeline.num_video_tracks - i + rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(rect, QColor("#444")) + painter.drawRect(rect) + painter.setFont(header_font) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, f"Video {track_number}") + + if track_number == self.timeline.num_video_tracks and self.timeline.num_video_tracks > 1: + self.remove_video_track_btn_rect = QRect(rect.right() - 25, rect.top() + 5, 20, 20) + painter.setFont(button_font) + painter.fillRect(self.remove_video_track_btn_rect, QColor("#833")) + painter.drawText(self.remove_video_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "-") + y_cursor += self.TRACK_HEIGHT + + y_cursor += self.AUDIO_TRACKS_SEPARATOR_Y + + self.audio_tracks_y_start = y_cursor + for i in range(self.timeline.num_audio_tracks): + track_number = i + 1 + rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(rect, QColor("#444")) + painter.drawRect(rect) + painter.setFont(header_font) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, f"Audio {track_number}") + + if track_number == self.timeline.num_audio_tracks and self.timeline.num_audio_tracks > 1: + self.remove_audio_track_btn_rect = QRect(rect.right() - 25, rect.top() + 5, 20, 20) + painter.setFont(button_font) + painter.fillRect(self.remove_audio_track_btn_rect, QColor("#833")) + painter.drawText(self.remove_audio_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "-") + y_cursor += self.TRACK_HEIGHT + + rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(rect, QColor("#3a3a3a")) + painter.drawRect(rect) + self.add_audio_track_btn_rect = QRect(rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) + painter.setFont(button_font) + painter.fillRect(self.add_audio_track_btn_rect, QColor("#454")) + painter.drawText(self.add_audio_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "Add Track (+)") + + painter.restore() + + def draw_timescale(self, painter): + painter.save() + painter.setPen(QColor("#AAA")) + painter.setFont(QFont("Arial", 8)) + font_metrics = QFontMetrics(painter.font()) + + painter.fillRect(QRect(self.HEADER_WIDTH, 0, self.width() - self.HEADER_WIDTH, self.TIMESCALE_HEIGHT), QColor("#222")) + painter.drawLine(self.HEADER_WIDTH, self.TIMESCALE_HEIGHT - 1, self.width(), self.TIMESCALE_HEIGHT - 1) + + max_time_to_draw = self.x_to_sec(self.width()) + major_interval_sec = 5 + minor_interval_sec = 1 + + for t_sec in range(int(max_time_to_draw) + 2): + x = self.sec_to_x(t_sec) + + if t_sec % major_interval_sec == 0: + painter.drawLine(x, self.TIMESCALE_HEIGHT - 10, x, self.TIMESCALE_HEIGHT - 1) + label = f"{t_sec}s" + label_width = font_metrics.horizontalAdvance(label) + painter.drawText(x - label_width // 2, self.TIMESCALE_HEIGHT - 12, label) + elif t_sec % minor_interval_sec == 0: + painter.drawLine(x, self.TIMESCALE_HEIGHT - 5, x, self.TIMESCALE_HEIGHT - 1) + painter.restore() + + def get_clip_rect(self, clip): + if clip.track_type == 'video': + visual_index = self.timeline.num_video_tracks - clip.track_index + y = self.video_tracks_y_start + visual_index * self.TRACK_HEIGHT + else: + visual_index = clip.track_index - 1 + y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT + + x = self.sec_to_x(clip.timeline_start_sec) + w = int(clip.duration_sec * self.PIXELS_PER_SECOND) + clip_height = self.TRACK_HEIGHT - 10 + y += (self.TRACK_HEIGHT - clip_height) / 2 + return QRectF(x, y, w, clip_height) + + def draw_tracks_and_clips(self, painter): + painter.save() + y_cursor = self.video_tracks_y_start + for i in range(self.timeline.num_video_tracks): + rect = QRect(self.HEADER_WIDTH, y_cursor, self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(rect, QColor("#444") if i % 2 == 0 else QColor("#404040")) + y_cursor += self.TRACK_HEIGHT + + y_cursor = self.audio_tracks_y_start + for i in range(self.timeline.num_audio_tracks): + rect = QRect(self.HEADER_WIDTH, y_cursor, self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(rect, QColor("#444") if i % 2 == 0 else QColor("#404040")) + y_cursor += self.TRACK_HEIGHT + + if self.highlighted_track_info: + track_type, track_index = self.highlighted_track_info + y = -1 + if track_type == 'video' and track_index <= self.timeline.num_video_tracks: + visual_index = self.timeline.num_video_tracks - track_index + y = self.video_tracks_y_start + visual_index * self.TRACK_HEIGHT + elif track_type == 'audio' and track_index <= self.timeline.num_audio_tracks: + visual_index = track_index - 1 + y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT + + if y != -1: + highlight_rect = QRect(self.HEADER_WIDTH, y, self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(highlight_rect, QColor(255, 255, 0, 40)) + + # Draw clips + for clip in self.timeline.clips: + clip_rect = self.get_clip_rect(clip) + base_color = QColor("#46A") if clip.track_type == 'video' else QColor("#48C") + color = QColor("#5A9") if self.dragging_clip and self.dragging_clip.id == clip.id else base_color + painter.fillRect(clip_rect, color) + painter.setPen(QPen(QColor("#FFF"), 1)) + font = QFont("Arial", 10) + painter.setFont(font) + text = os.path.basename(clip.source_path) + font_metrics = QFontMetrics(font) + text_width = font_metrics.horizontalAdvance(text) + if text_width > clip_rect.width() - 10: text = font_metrics.elidedText(text, Qt.TextElideMode.ElideRight, int(clip_rect.width() - 10)) + painter.drawText(QPoint(int(clip_rect.left() + 5), int(clip_rect.center().y() + 5)), text) + painter.restore() + + def draw_selections(self, painter): + for start_sec, end_sec in self.selection_regions: + x = self.sec_to_x(start_sec) + w = int((end_sec - start_sec) * self.PIXELS_PER_SECOND) + selection_rect = QRectF(x, self.TIMESCALE_HEIGHT, w, self.height() - self.TIMESCALE_HEIGHT) + painter.fillRect(selection_rect, QColor(100, 100, 255, 80)) + painter.setPen(QColor(150, 150, 255, 150)) + painter.drawRect(selection_rect) + + def draw_playhead(self, painter): + playhead_x = self.sec_to_x(self.playhead_pos_sec) + painter.setPen(QPen(QColor("red"), 2)) + painter.drawLine(playhead_x, 0, playhead_x, self.height()) + + def y_to_track_info(self, y): + video_tracks_end_y = self.video_tracks_y_start + self.timeline.num_video_tracks * self.TRACK_HEIGHT + if self.video_tracks_y_start <= y < video_tracks_end_y: + visual_index = (y - self.video_tracks_y_start) // self.TRACK_HEIGHT + track_index = self.timeline.num_video_tracks - visual_index + return ('video', track_index) + + audio_tracks_end_y = self.audio_tracks_y_start + self.timeline.num_audio_tracks * self.TRACK_HEIGHT + if self.audio_tracks_y_start <= y < audio_tracks_end_y: + visual_index = (y - self.audio_tracks_y_start) // self.TRACK_HEIGHT + track_index = visual_index + 1 + return ('audio', track_index) + return None + + def get_region_at_pos(self, pos: QPoint): + if pos.y() <= self.TIMESCALE_HEIGHT or pos.x() <= self.HEADER_WIDTH: + return None + + clicked_sec = self.x_to_sec(pos.x()) + for region in reversed(self.selection_regions): + if region[0] <= clicked_sec <= region[1]: + return region + return None + + def mousePressEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.LeftButton: + if event.pos().x() < self.HEADER_WIDTH: + # Click in headers area + if self.add_video_track_btn_rect.contains(event.pos()): self.add_track.emit('video') + elif self.remove_video_track_btn_rect.contains(event.pos()): self.remove_track.emit('video') + elif self.add_audio_track_btn_rect.contains(event.pos()): self.add_track.emit('audio') + elif self.remove_audio_track_btn_rect.contains(event.pos()): self.remove_track.emit('audio') + return + + self.dragging_clip = None + self.dragging_linked_clip = None + self.dragging_playhead = False + self.dragging_selection_region = None + self.creating_selection_region = False + + for clip in reversed(self.timeline.clips): + clip_rect = self.get_clip_rect(clip) + if clip_rect.contains(QPointF(event.pos())): + self.dragging_clip = clip + self.dragging_linked_clip = next((c for c in self.timeline.clips if c.group_id == clip.group_id and c.id != clip.id), None) + self.drag_start_pos = event.pos() + self.drag_original_timeline_start = clip.timeline_start_sec + break + + if not self.dragging_clip: + region_to_drag = self.get_region_at_pos(event.pos()) + if region_to_drag: + self.dragging_selection_region = region_to_drag + self.drag_start_pos = event.pos() + self.drag_selection_start_values = tuple(region_to_drag) + else: + is_on_timescale = event.pos().y() <= self.TIMESCALE_HEIGHT + is_in_track_area = event.pos().y() > self.TIMESCALE_HEIGHT and event.pos().x() > self.HEADER_WIDTH + + if is_in_track_area: + self.creating_selection_region = True + self.selection_drag_start_sec = self.x_to_sec(event.pos().x()) + self.selection_regions.append([self.selection_drag_start_sec, self.selection_drag_start_sec]) + elif is_on_timescale: + self.playhead_pos_sec = max(0, self.x_to_sec(event.pos().x())) + self.playhead_moved.emit(self.playhead_pos_sec) + self.dragging_playhead = True + + self.update() + + def mouseMoveEvent(self, event: QMouseEvent): + if self.creating_selection_region: + current_sec = self.x_to_sec(event.pos().x()) + start = min(self.selection_drag_start_sec, current_sec) + end = max(self.selection_drag_start_sec, current_sec) + self.selection_regions[-1] = [start, end] + self.update() + return + + if self.dragging_selection_region: + delta_x = event.pos().x() - self.drag_start_pos.x() + time_delta = delta_x / self.PIXELS_PER_SECOND + + original_start, original_end = self.drag_selection_start_values + duration = original_end - original_start + new_start = max(0, original_start + time_delta) + + self.dragging_selection_region[0] = new_start + self.dragging_selection_region[1] = new_start + duration + + self.update() + return + + if self.dragging_playhead: + self.playhead_pos_sec = max(0, self.x_to_sec(event.pos().x())) + self.playhead_moved.emit(self.playhead_pos_sec) + self.update() + elif self.dragging_clip: + self.highlighted_track_info = None + new_track_info = self.y_to_track_info(event.pos().y()) + if new_track_info: + new_track_type, new_track_index = new_track_info + if new_track_type == self.dragging_clip.track_type: + self.dragging_clip.track_index = new_track_index + if self.dragging_linked_clip: + self.dragging_linked_clip.track_index = new_track_index + if self.dragging_clip.track_type == 'video': + if new_track_index > self.timeline.num_audio_tracks: + self.timeline.num_audio_tracks = new_track_index + self.highlighted_track_info = ('audio', new_track_index) + else: + if new_track_index > self.timeline.num_video_tracks: + self.timeline.num_video_tracks = new_track_index + self.highlighted_track_info = ('video', new_track_index) + + delta_x = event.pos().x() - self.drag_start_pos.x() + time_delta = delta_x / self.PIXELS_PER_SECOND + new_start_time = self.drag_original_timeline_start + time_delta + + for other_clip in self.timeline.clips: + if other_clip.id == self.dragging_clip.id: continue + if self.dragging_linked_clip and other_clip.id == self.dragging_linked_clip.id: continue + if (other_clip.track_type != self.dragging_clip.track_type or + other_clip.track_index != self.dragging_clip.track_index): + continue + + is_overlapping = (new_start_time < other_clip.timeline_end_sec and + new_start_time + self.dragging_clip.duration_sec > other_clip.timeline_start_sec) + + if is_overlapping: + if time_delta > 0: new_start_time = other_clip.timeline_start_sec - self.dragging_clip.duration_sec + else: new_start_time = other_clip.timeline_end_sec + break + + final_start_time = max(0, new_start_time) + self.dragging_clip.timeline_start_sec = final_start_time + if self.dragging_linked_clip: + self.dragging_linked_clip.timeline_start_sec = final_start_time + + self.update() + + def mouseReleaseEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.LeftButton: + if self.creating_selection_region: + self.creating_selection_region = False + if self.selection_regions: + start, end = self.selection_regions[-1] + if (end - start) * self.PIXELS_PER_SECOND < 2: + self.selection_regions.pop() + + if self.dragging_selection_region: + self.dragging_selection_region = None + self.drag_selection_start_values = None + + self.dragging_playhead = False + if self.dragging_clip: + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.highlighted_track_info = None + self.operation_finished.emit() + self.dragging_clip = None + self.dragging_linked_clip = None + + self.update() + + def contextMenuEvent(self, event: 'QContextMenuEvent'): + menu = QMenu(self) + + region_at_pos = self.get_region_at_pos(event.pos()) + if region_at_pos: + split_this_action = menu.addAction("Split This Region") + split_all_action = menu.addAction("Split All Regions") + join_this_action = menu.addAction("Join This Region") + join_all_action = menu.addAction("Join All Regions") + menu.addSeparator() + clear_this_action = menu.addAction("Clear This Region") + clear_all_action = menu.addAction("Clear All Regions") + split_this_action.triggered.connect(lambda: self.split_region_requested.emit(region_at_pos)) + split_all_action.triggered.connect(lambda: self.split_all_regions_requested.emit(self.selection_regions)) + join_this_action.triggered.connect(lambda: self.join_region_requested.emit(region_at_pos)) + join_all_action.triggered.connect(lambda: self.join_all_regions_requested.emit(self.selection_regions)) + clear_this_action.triggered.connect(lambda: self.clear_region(region_at_pos)) + clear_all_action.triggered.connect(self.clear_all_regions) + + clip_at_pos = None + for clip in self.timeline.clips: + if self.get_clip_rect(clip).contains(QPointF(event.pos())): + clip_at_pos = clip + break + + if clip_at_pos: + if not menu.isEmpty(): menu.addSeparator() + split_action = menu.addAction("Split Clip") + playhead_time = self.playhead_pos_sec + is_playhead_over_clip = (clip_at_pos.timeline_start_sec < playhead_time < clip_at_pos.timeline_end_sec) + split_action.setEnabled(is_playhead_over_clip) + split_action.triggered.connect(lambda: self.split_requested.emit(clip_at_pos)) + + self.context_menu_requested.emit(menu, event) + + if not menu.isEmpty(): + menu.exec(self.mapToGlobal(event.pos())) + + def clear_region(self, region_to_clear): + if region_to_clear in self.selection_regions: + self.selection_regions.remove(region_to_clear) + self.update() + + def clear_all_regions(self): + self.selection_regions.clear() + self.update() + +class SettingsDialog(QDialog): + def __init__(self, parent_settings, parent=None): + super().__init__(parent) + self.setWindowTitle("Settings") + self.setMinimumWidth(350) + layout = QVBoxLayout(self) + self.seek_anywhere_checkbox = QCheckBox("Allow seeking by clicking anywhere on the timeline") + self.seek_anywhere_checkbox.setChecked(parent_settings.get("allow_seek_anywhere", False)) + layout.addWidget(self.seek_anywhere_checkbox) + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def get_settings(self): + return {"allow_seek_anywhere": self.seek_anywhere_checkbox.isChecked()} + +class MainWindow(QMainWindow): + def __init__(self, project_to_load=None): + super().__init__() + self.setWindowTitle("Inline AI Video Editor") + self.setGeometry(100, 100, 1200, 800) + self.setDockOptions(QMainWindow.DockOption.AnimatedDocks | QMainWindow.DockOption.AllowNestedDocks) + + self.timeline = Timeline() + self.export_thread = None + self.export_worker = None + self.current_project_path = None + self.settings = {} + self.settings_file = "settings.json" + self.is_shutting_down = False + self._load_settings() + + self.plugin_manager = PluginManager(self) + self.plugin_manager.discover_and_load_plugins() + + self.project_fps = 25.0 + self.project_width = 1280 + self.project_height = 720 + self.playback_timer = QTimer(self) + self.playback_process = None + self.playback_clip = None + + self.splitter = QSplitter(Qt.Orientation.Vertical) + + self.preview_widget = QLabel() + self.preview_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_widget.setMinimumSize(640, 360) + self.preview_widget.setFrameShape(QFrame.Shape.Box) + self.preview_widget.setStyleSheet("background-color: black; color: white;") + self.splitter.addWidget(self.preview_widget) + + self.timeline_widget = TimelineWidget(self.timeline, self.settings) + self.timeline_scroll_area = QScrollArea() + self.timeline_scroll_area.setWidgetResizable(True) + self.timeline_scroll_area.setWidget(self.timeline_widget) + self.timeline_scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.timeline_scroll_area.setMinimumHeight(250) + self.splitter.addWidget(self.timeline_scroll_area) + + container_widget = QWidget() + main_layout = QVBoxLayout(container_widget) + main_layout.setContentsMargins(0,0,0,0) + main_layout.addWidget(self.splitter, 1) + + controls_widget = QWidget() + controls_layout = QHBoxLayout(controls_widget) + controls_layout.setContentsMargins(0, 5, 0, 5) + self.play_pause_button = QPushButton("Play") + self.stop_button = QPushButton("Stop") + self.frame_back_button = QPushButton("<") + self.frame_forward_button = QPushButton(">") + controls_layout.addStretch() + controls_layout.addWidget(self.frame_back_button) + controls_layout.addWidget(self.play_pause_button) + controls_layout.addWidget(self.stop_button) + controls_layout.addWidget(self.frame_forward_button) + controls_layout.addStretch() + main_layout.addWidget(controls_widget) + + status_layout = QHBoxLayout() + self.status_label = QLabel("Ready. Create or open a project from the File menu.") + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + self.progress_bar.setTextVisible(True) + self.progress_bar.setRange(0, 100) + status_layout.addWidget(self.status_label, 1) + status_layout.addWidget(self.progress_bar, 1) + main_layout.addLayout(status_layout) + + self.setCentralWidget(container_widget) + + self.managed_widgets = { + 'preview': {'widget': self.preview_widget, 'name': 'Video Preview', 'action': None}, + 'timeline': {'widget': self.timeline_scroll_area, 'name': 'Timeline', 'action': None} + } + self.plugin_menu_actions = {} + self.windows_menu = None + self._create_menu_bar() + + self.splitter_save_timer = QTimer(self) + self.splitter_save_timer.setSingleShot(True) + self.splitter_save_timer.timeout.connect(self._save_settings) + self.splitter.splitterMoved.connect(self.on_splitter_moved) + + self.timeline_widget.split_requested.connect(self.split_clip_at_playhead) + self.timeline_widget.playhead_moved.connect(self.seek_preview) + self.timeline_widget.split_region_requested.connect(self.on_split_region) + self.timeline_widget.split_all_regions_requested.connect(self.on_split_all_regions) + self.timeline_widget.join_region_requested.connect(self.on_join_region) + self.timeline_widget.join_all_regions_requested.connect(self.on_join_all_regions) + self.timeline_widget.add_track.connect(self.add_track) + self.timeline_widget.remove_track.connect(self.remove_track) + self.timeline_widget.operation_finished.connect(self.prune_empty_tracks) + + self.play_pause_button.clicked.connect(self.toggle_playback) + self.stop_button.clicked.connect(self.stop_playback) + self.frame_back_button.clicked.connect(lambda: self.step_frame(-1)) + self.frame_forward_button.clicked.connect(lambda: self.step_frame(1)) + self.playback_timer.timeout.connect(self.advance_playback_frame) + + self.plugin_manager.load_enabled_plugins_from_settings(self.settings.get("enabled_plugins", [])) + self._apply_loaded_settings() + self.seek_preview(0) + + if not self.settings_file_was_loaded: self._save_settings() + if project_to_load: QTimer.singleShot(100, lambda: self._load_project_from_path(project_to_load)) + + def prune_empty_tracks(self): + pruned_something = False + + while self.timeline.num_video_tracks > 1: + highest_track_index = self.timeline.num_video_tracks + is_track_occupied = any(c for c in self.timeline.clips + if c.track_type == 'video' and c.track_index == highest_track_index) + if is_track_occupied: + break + else: + self.timeline.num_video_tracks -= 1 + pruned_something = True + + while self.timeline.num_audio_tracks > 1: + highest_track_index = self.timeline.num_audio_tracks + is_track_occupied = any(c for c in self.timeline.clips + if c.track_type == 'audio' and c.track_index == highest_track_index) + if is_track_occupied: + break + else: + self.timeline.num_audio_tracks -= 1 + pruned_something = True + + if pruned_something: + self.timeline_widget.update() + + + def add_track(self, track_type): + if track_type == 'video': + self.timeline.num_video_tracks += 1 + elif track_type == 'audio': + self.timeline.num_audio_tracks += 1 + self.timeline_widget.update() + + def remove_track(self, track_type): + if track_type == 'video' and self.timeline.num_video_tracks > 1: + self.timeline.num_video_tracks -= 1 + elif track_type == 'audio' and self.timeline.num_audio_tracks > 1: + self.timeline.num_audio_tracks -= 1 + self.timeline_widget.update() + + def _create_menu_bar(self): + menu_bar = self.menuBar() + file_menu = menu_bar.addMenu("&File") + new_action = QAction("&New Project", self); new_action.triggered.connect(self.new_project) + open_action = QAction("&Open Project...", self); open_action.triggered.connect(self.open_project) + save_action = QAction("&Save Project As...", self); save_action.triggered.connect(self.save_project_as) + add_video_action = QAction("&Add Video...", self); add_video_action.triggered.connect(self.add_video_clip) + export_action = QAction("&Export Video...", self); export_action.triggered.connect(self.export_video) + settings_action = QAction("Se&ttings...", self); settings_action.triggered.connect(self.open_settings_dialog) + exit_action = QAction("E&xit", self); exit_action.triggered.connect(self.close) + file_menu.addAction(new_action); file_menu.addAction(open_action); file_menu.addAction(save_action) + file_menu.addSeparator(); file_menu.addAction(add_video_action); file_menu.addAction(export_action) + file_menu.addSeparator(); file_menu.addAction(settings_action); file_menu.addSeparator(); file_menu.addAction(exit_action) + + edit_menu = menu_bar.addMenu("&Edit") + split_action = QAction("Split Clip at Playhead", self); split_action.triggered.connect(self.split_clip_at_playhead) + edit_menu.addAction(split_action) + + plugins_menu = menu_bar.addMenu("&Plugins") + for name, data in self.plugin_manager.plugins.items(): + plugin_action = QAction(name, self, checkable=True) + plugin_action.setChecked(data['enabled']) + plugin_action.toggled.connect(lambda checked, n=name: self.toggle_plugin(n, checked)) + plugins_menu.addAction(plugin_action) + self.plugin_menu_actions[name] = plugin_action + + plugins_menu.addSeparator() + manage_action = QAction("Manage plugins...", self) + manage_action.triggered.connect(self.open_manage_plugins_dialog) + plugins_menu.addAction(manage_action) + + self.windows_menu = menu_bar.addMenu("&Windows") + for key, data in self.managed_widgets.items(): + action = QAction(data['name'], self, checkable=True) + action.toggled.connect(lambda checked, k=key: self.toggle_widget_visibility(k, checked)) + data['action'] = action + self.windows_menu.addAction(action) + + def _start_playback_stream_at(self, time_sec): + self._stop_playback_stream() + clip = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) + if not clip: return + self.playback_clip = clip + clip_time = time_sec - clip.timeline_start_sec + clip.clip_start_sec + try: + args = (ffmpeg.input(self.playback_clip.source_path, ss=clip_time).output('pipe:', format='rawvideo', pix_fmt='rgb24', r=self.project_fps).compile()) + self.playback_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + except Exception as e: + print(f"Failed to start playback stream: {e}"); self._stop_playback_stream() + + def _stop_playback_stream(self): + if self.playback_process: + if self.playback_process.poll() is None: + self.playback_process.terminate() + try: self.playback_process.wait(timeout=0.5) + except subprocess.TimeoutExpired: self.playback_process.kill(); self.playback_process.wait() + self.playback_process = None + self.playback_clip = None + + def _set_project_properties_from_clip(self, source_path): + try: + probe = ffmpeg.probe(source_path) + video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) + if video_stream: + self.project_width = int(video_stream['width']); self.project_height = int(video_stream['height']) + if 'r_frame_rate' in video_stream and video_stream['r_frame_rate'] != '0/0': + num, den = map(int, video_stream['r_frame_rate'].split('/')) + if den > 0: self.project_fps = num / den + print(f"Project properties set: {self.project_width}x{self.project_height} @ {self.project_fps:.2f} FPS") + return True + except Exception as e: print(f"Could not probe for project properties: {e}") + return False + + def get_frame_data_at_time(self, time_sec): + clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) + if not clip_at_time: + return (None, 0, 0) + try: + clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec + out, _ = ( + ffmpeg + .input(clip_at_time.source_path, ss=clip_time) + .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') + .run(capture_stdout=True, quiet=True) + ) + return (out, self.project_width, self.project_height) + except ffmpeg.Error as e: + print(f"Error extracting frame data: {e.stderr}") + return (None, 0, 0) + + def get_frame_at_time(self, time_sec): + clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) + black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) + if not clip_at_time: return black_pixmap + try: + clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec + out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time).output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24').run(capture_stdout=True, quiet=True)) + image = QImage(out, self.project_width, self.project_height, QImage.Format.Format_RGB888) + return QPixmap.fromImage(image) + except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return black_pixmap + + def seek_preview(self, time_sec): + self._stop_playback_stream() + self.timeline_widget.playhead_pos_sec = time_sec + self.timeline_widget.update() + frame_pixmap = self.get_frame_at_time(time_sec) + if frame_pixmap: + scaled_pixmap = frame_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.preview_widget.setPixmap(scaled_pixmap) + + def toggle_playback(self): + if self.playback_timer.isActive(): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play") + else: + if not self.timeline.clips: return + if self.timeline_widget.playhead_pos_sec >= self.timeline.get_total_duration(): self.timeline_widget.playhead_pos_sec = 0.0 + self.playback_timer.start(int(1000 / self.project_fps)); self.play_pause_button.setText("Pause") + + def stop_playback(self): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play"); self.seek_preview(0.0) + def step_frame(self, direction): + if not self.timeline.clips: return + self.playback_timer.stop(); self.play_pause_button.setText("Play"); self._stop_playback_stream() + frame_duration = 1.0 / self.project_fps + new_time = self.timeline_widget.playhead_pos_sec + (direction * frame_duration) + self.seek_preview(max(0, min(new_time, self.timeline.get_total_duration()))) + + def advance_playback_frame(self): + frame_duration = 1.0 / self.project_fps + new_time = self.timeline_widget.playhead_pos_sec + frame_duration + if new_time > self.timeline.get_total_duration(): self.stop_playback(); return + self.timeline_widget.playhead_pos_sec = new_time; self.timeline_widget.update() + clip_at_new_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= new_time < c.timeline_end_sec), None) + if not clip_at_new_time: + self._stop_playback_stream() + black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) + scaled_pixmap = black_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.preview_widget.setPixmap(scaled_pixmap); return + if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: self._start_playback_stream_at(new_time) + if self.playback_process: + frame_size = self.project_width * self.project_height * 3 + frame_bytes = self.playback_process.stdout.read(frame_size) + if len(frame_bytes) == frame_size: + image = QImage(frame_bytes, self.project_width, self.project_height, QImage.Format.Format_RGB888) + pixmap = QPixmap.fromImage(image) + scaled_pixmap = pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.preview_widget.setPixmap(scaled_pixmap) + else: self._stop_playback_stream() + + def _load_settings(self): + self.settings_file_was_loaded = False + defaults = {"allow_seek_anywhere": False, "window_visibility": {"preview": True, "timeline": True}, "splitter_state": None, "enabled_plugins": []} + if os.path.exists(self.settings_file): + try: + with open(self.settings_file, "r") as f: self.settings = json.load(f) + self.settings_file_was_loaded = True + for key, value in defaults.items(): + if key not in self.settings: self.settings[key] = value + except (json.JSONDecodeError, IOError): self.settings = defaults + else: self.settings = defaults + + def _save_settings(self): + self.settings["splitter_state"] = self.splitter.saveState().toHex().data().decode('ascii') + visibility_to_save = { + key: data['action'].isChecked() + for key, data in self.managed_widgets.items() if data.get('action') + } + + self.settings["window_visibility"] = visibility_to_save + self.settings['enabled_plugins'] = self.plugin_manager.get_enabled_plugin_names() + try: + with open(self.settings_file, "w") as f: + json.dump(self.settings, f, indent=4) + except IOError as e: + print(f"Error saving settings: {e}") + + def _apply_loaded_settings(self): + visibility_settings = self.settings.get("window_visibility", {}) + for key, data in self.managed_widgets.items(): + if data.get('plugin'): + continue + + is_visible = visibility_settings.get(key, True) + data['widget'].setVisible(is_visible) + if data['action']: data['action'].setChecked(is_visible) + + splitter_state = self.settings.get("splitter_state") + if splitter_state: self.splitter.restoreState(QByteArray.fromHex(splitter_state.encode('ascii'))) + + def on_splitter_moved(self, pos, index): self.splitter_save_timer.start(500) + + def toggle_widget_visibility(self, key, checked): + if self.is_shutting_down: + return + if key in self.managed_widgets: + self.managed_widgets[key]['widget'].setVisible(checked) + self._save_settings() + + def open_settings_dialog(self): + dialog = SettingsDialog(self.settings, self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.settings.update(dialog.get_settings()); self._save_settings(); self.status_label.setText("Settings updated.") + + def new_project(self): + self.timeline.clips.clear(); self.timeline.num_video_tracks = 1; self.timeline.num_audio_tracks = 1 + self.current_project_path = None; self.stop_playback(); self.timeline_widget.update() + self.status_label.setText("New project created. Add video clips to begin.") + + def save_project_as(self): + path, _ = QFileDialog.getSaveFileName(self, "Save Project", "", "JSON Project Files (*.json)") + if not path: return + project_data = { + "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "group_id": c.group_id} for c in self.timeline.clips], + "settings": {"num_video_tracks": self.timeline.num_video_tracks, "num_audio_tracks": self.timeline.num_audio_tracks} + } + try: + with open(path, "w") as f: json.dump(project_data, f, indent=4) + self.current_project_path = path; self.status_label.setText(f"Project saved to {os.path.basename(path)}") + except Exception as e: self.status_label.setText(f"Error saving project: {e}") + + def open_project(self): + path, _ = QFileDialog.getOpenFileName(self, "Open Project", "", "JSON Project Files (*.json)") + if path: self._load_project_from_path(path) + + def _load_project_from_path(self, path): + try: + with open(path, "r") as f: project_data = json.load(f) + self.timeline.clips.clear() + for clip_data in project_data["clips"]: + if not os.path.exists(clip_data["source_path"]): + self.status_label.setText(f"Error: Missing media file {clip_data['source_path']}"); self.timeline.clips.clear(); self.timeline_widget.update(); return + self.timeline.add_clip(TimelineClip(**clip_data)) + + project_settings = project_data.get("settings", {}) + self.timeline.num_video_tracks = project_settings.get("num_video_tracks", 1) + self.timeline.num_audio_tracks = project_settings.get("num_audio_tracks", 1) + + self.current_project_path = path + if self.timeline.clips: self._set_project_properties_from_clip(self.timeline.clips[0].source_path) + self.prune_empty_tracks() + self.timeline_widget.update(); self.stop_playback() + self.status_label.setText(f"Project '{os.path.basename(path)}' loaded.") + except Exception as e: self.status_label.setText(f"Error opening project: {e}") + + def add_video_clip(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Open Video File", "", "Video Files (*.mp4 *.mov *.avi)") + if not file_path: return + if not self.timeline.clips: + if not self._set_project_properties_from_clip(file_path): self.status_label.setText("Error: Could not determine video properties from file."); return + try: + self.status_label.setText(f"Probing {os.path.basename(file_path)}..."); QApplication.processEvents() + probe = ffmpeg.probe(file_path) + video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) + if not video_stream: raise ValueError("No video stream found.") + duration = float(video_stream.get('duration', probe['format'].get('duration', 0))) + has_audio = any(s['codec_type'] == 'audio' for s in probe['streams']) + timeline_start = self.timeline.get_total_duration() + + self._add_clip_to_timeline( + source_path=file_path, + timeline_start_sec=timeline_start, + duration_sec=duration, + clip_start_sec=0, + video_track_index=1, + audio_track_index=1 if has_audio else None + ) + + self.timeline_widget.update(); self.seek_preview(self.timeline_widget.playhead_pos_sec) + except Exception as e: self.status_label.setText(f"Error adding file: {e}") + + def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, clip_start_sec=0.0, video_track_index=None, audio_track_index=None): + group_id = str(uuid.uuid4()) + + if video_track_index is not None: + video_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, video_track_index, 'video', group_id) + self.timeline.add_clip(video_clip) + + if audio_track_index is not None: + audio_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, audio_track_index, 'audio', group_id) + self.timeline.add_clip(audio_clip) + + self.timeline_widget.update() + self.status_label.setText(f"Added {os.path.basename(source_path)}.") + + def _split_at_time(self, clip_to_split, time_sec, new_group_id=None): + if not (clip_to_split.timeline_start_sec < time_sec < clip_to_split.timeline_end_sec): return False + split_point = time_sec - clip_to_split.timeline_start_sec + orig_dur = clip_to_split.duration_sec + + group_id_for_new_clip = new_group_id if new_group_id is not None else clip_to_split.group_id + + new_clip = TimelineClip(clip_to_split.source_path, time_sec, clip_to_split.clip_start_sec + split_point, orig_dur - split_point, clip_to_split.track_index, clip_to_split.track_type, group_id_for_new_clip) + clip_to_split.duration_sec = split_point + self.timeline.add_clip(new_clip) + return True + + def split_clip_at_playhead(self, clip_to_split=None): + playhead_time = self.timeline_widget.playhead_pos_sec + if not clip_to_split: + clip_to_split = next((c for c in self.timeline.clips if c.timeline_start_sec < playhead_time < c.timeline_end_sec), None) + + if not clip_to_split: + self.status_label.setText("Playhead is not over a clip to split.") + return + + linked_clip = next((c for c in self.timeline.clips if c.group_id == clip_to_split.group_id and c.id != clip_to_split.id), None) + + new_right_side_group_id = str(uuid.uuid4()) + + split1 = self._split_at_time(clip_to_split, playhead_time, new_group_id=new_right_side_group_id) + split2 = True + if linked_clip: + split2 = self._split_at_time(linked_clip, playhead_time, new_group_id=new_right_side_group_id) + + if split1 and split2: + self.timeline_widget.update() + self.status_label.setText("Clip split.") + else: + self.status_label.setText("Failed to split clip.") + + def on_split_region(self, region): + start_sec, end_sec = region; + clips = list(self.timeline.clips) + for clip in clips: self._split_at_time(clip, end_sec) + for clip in clips: self._split_at_time(clip, start_sec) + self.timeline_widget.clear_region(region); self.timeline_widget.update(); self.status_label.setText("Region split.") + + def on_split_all_regions(self, regions): + split_points = set() + for start, end in regions: split_points.add(start); split_points.add(end) + for point in sorted(list(split_points)): + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} + + for clip in list(self.timeline.clips): + if clip.group_id in new_group_ids: + self._split_at_time(clip, point, new_group_ids[clip.group_id]) + + self.timeline_widget.clear_all_regions(); self.timeline_widget.update(); self.status_label.setText("All regions split.") + + def on_join_region(self, region): + start_sec, end_sec = region; duration_to_remove = end_sec - start_sec + if duration_to_remove <= 0.01: return + + for point in [start_sec, end_sec]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} + for clip in list(self.timeline.clips): + if clip.group_id in new_group_ids: + self._split_at_time(clip, point, new_group_ids[clip.group_id]) + + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + for clip in clips_to_remove: self.timeline.clips.remove(clip) + for clip in self.timeline.clips: + if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec); self.timeline_widget.clear_region(region) + self.timeline_widget.update(); self.status_label.setText("Region joined (content removed).") + self.prune_empty_tracks() + + def on_join_all_regions(self, regions): + for region in sorted(regions, key=lambda r: r[0], reverse=True): + start_sec, end_sec = region; duration_to_remove = end_sec - start_sec + if duration_to_remove <= 0.01: continue + + for point in [start_sec, end_sec]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} + for clip in list(self.timeline.clips): + if clip.group_id in new_group_ids: + self._split_at_time(clip, point, new_group_ids[clip.group_id]) + + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + for clip in clips_to_remove: + try: self.timeline.clips.remove(clip) + except ValueError: pass + for clip in self.timeline.clips: + if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove + + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec); self.timeline_widget.clear_all_regions() + self.timeline_widget.update(); self.status_label.setText("All regions joined.") + self.prune_empty_tracks() + + def export_video(self): + if not self.timeline.clips: self.status_label.setText("Timeline is empty."); return + output_path, _ = QFileDialog.getSaveFileName(self, "Save Video As", "", "MP4 Files (*.mp4)") + if not output_path: return + + w, h, fr_str, total_dur = self.project_width, self.project_height, str(self.project_fps), self.timeline.get_total_duration() + sample_rate, channel_layout = '44100', 'stereo' + + input_files = {clip.source_path: ffmpeg.input(clip.source_path) for clip in self.timeline.clips} + + last_video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr_str}:d={total_dur}', f='lavfi') + for i in range(self.timeline.num_video_tracks): + track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'video' and c.track_index == i + 1], key=lambda c: c.timeline_start_sec) + if not track_clips: continue + + track_segments = [] + last_end = track_clips[0].timeline_start_sec + + for clip in track_clips: + gap = clip.timeline_start_sec - last_end + if gap > 0.01: + track_segments.append(ffmpeg.input(f'color=c=black@0.0:s={w}x{h}:r={fr_str}:d={gap}', f='lavfi').filter('format', pix_fmts='rgba')) + + v_seg = (input_files[clip.source_path].video.trim(start=clip.clip_start_sec, duration=clip.duration_sec).setpts('PTS-STARTPTS') + .filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black').filter('format', pix_fmts='rgba')) + track_segments.append(v_seg) + last_end = clip.timeline_end_sec + + track_stream = ffmpeg.concat(*track_segments, v=1, a=0).filter('setpts', f'PTS-STARTPTS+{track_clips[0].timeline_start_sec}/TB') + last_video_stream = ffmpeg.overlay(last_video_stream, track_stream) + final_video = last_video_stream.filter('format', pix_fmts='yuv420p').filter('fps', fps=self.project_fps) + + track_audio_streams = [] + for i in range(self.timeline.num_audio_tracks): + track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == i + 1], key=lambda c: c.timeline_start_sec) + if not track_clips: continue + + track_segments = [] + last_end = track_clips[0].timeline_start_sec + + for clip in track_clips: + gap = clip.timeline_start_sec - last_end + if gap > 0.01: + track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap}', f='lavfi')) + + a_seg = input_files[clip.source_path].audio.filter('atrim', start=clip.clip_start_sec, duration=clip.duration_sec).filter('asetpts', 'PTS-STARTPTS') + track_segments.append(a_seg) + last_end = clip.timeline_end_sec + + track_audio_streams.append(ffmpeg.concat(*track_segments, v=0, a=1).filter('adelay', f'{int(track_clips[0].timeline_start_sec * 1000)}ms', all=True)) + + if track_audio_streams: + final_audio = ffmpeg.filter(track_audio_streams, 'amix', inputs=len(track_audio_streams), duration='longest') + else: + final_audio = ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={total_dur}', f='lavfi') + + output_args = {'vcodec': 'libx264', 'acodec': 'aac', 'pix_fmt': 'yuv420p', 'b:v': '5M'} + try: + ffmpeg_cmd = ffmpeg.output(final_video, final_audio, output_path, **output_args).overwrite_output().compile() + self.progress_bar.setVisible(True); self.progress_bar.setValue(0); self.status_label.setText("Exporting...") + self.export_thread = QThread() + self.export_worker = ExportWorker(ffmpeg_cmd, total_dur) + self.export_worker.moveToThread(self.export_thread) + self.export_thread.started.connect(self.export_worker.run_export) + self.export_worker.finished.connect(self.on_export_finished) + self.export_worker.progress.connect(self.progress_bar.setValue) + self.export_worker.finished.connect(self.export_thread.quit) + self.export_worker.finished.connect(self.export_worker.deleteLater) + self.export_thread.finished.connect(self.export_thread.deleteLater) + self.export_thread.finished.connect(self.on_thread_finished_cleanup) + self.export_thread.start() + except ffmpeg.Error as e: + self.status_label.setText(f"FFmpeg error: {e.stderr}") + print(e.stderr) + + def on_export_finished(self, message): + self.status_label.setText(message) + self.progress_bar.setVisible(False) + + def on_thread_finished_cleanup(self): + self.export_thread = None + self.export_worker = None + + def add_dock_widget(self, plugin_instance, widget, title, area=Qt.DockWidgetArea.RightDockWidgetArea, show_on_creation=True): + widget_key = f"plugin_{plugin_instance.name}_{title}".replace(' ', '_').lower() + + dock = QDockWidget(title, self) + dock.setWidget(widget) + self.addDockWidget(area, dock) + + visibility_settings = self.settings.get("window_visibility", {}) + initial_visibility = visibility_settings.get(widget_key, show_on_creation) + + dock.setVisible(initial_visibility) + + action = QAction(title, self, checkable=True) + action.toggled.connect(lambda checked, k=widget_key: self.toggle_widget_visibility(k, checked)) + dock.visibilityChanged.connect(action.setChecked) + + action.setChecked(dock.isVisible()) + + self.windows_menu.addAction(action) + + self.managed_widgets[widget_key] = { + 'widget': dock, + 'name': title, + 'action': action, + 'plugin': plugin_instance.name + } + + return dock + + def update_plugin_ui_visibility(self, plugin_name, is_enabled): + for key, data in self.managed_widgets.items(): + if data.get('plugin') == plugin_name: + data['action'].setVisible(is_enabled) + if not is_enabled: + data['widget'].hide() + + def toggle_plugin(self, name, checked): + if checked: + self.plugin_manager.enable_plugin(name) + else: + self.plugin_manager.disable_plugin(name) + self._save_settings() + + def toggle_plugin_action(self, name, checked): + if name in self.plugin_menu_actions: + action = self.plugin_menu_actions[name] + action.blockSignals(True) + action.setChecked(checked) + action.blockSignals(False) + + def open_manage_plugins_dialog(self): + dialog = ManagePluginsDialog(self.plugin_manager, self) + dialog.app = self + dialog.exec() + + def closeEvent(self, event): + self.is_shutting_down = True + self._save_settings() + self._stop_playback_stream() + if self.export_thread and self.export_thread.isRunning(): + self.export_thread.quit() + self.export_thread.wait() + event.accept() + +if __name__ == '__main__': + app = QApplication(sys.argv) + project_to_load_on_startup = None + if len(sys.argv) > 1: + path = sys.argv[1] + if os.path.exists(path) and path.lower().endswith('.json'): + project_to_load_on_startup = path + print(f"Loading project: {path}") + window = MainWindow(project_to_load=project_to_load_on_startup) + window.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/videoeditor/plugins.py b/videoeditor/plugins.py new file mode 100644 index 000000000..6e50f0963 --- /dev/null +++ b/videoeditor/plugins.py @@ -0,0 +1,266 @@ +import os +import sys +import importlib.util +import subprocess +import shutil +import git +import json +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, + QPushButton, QLabel, QLineEdit, QMessageBox, QProgressBar, + QDialogButtonBox, QWidget, QCheckBox) +from PyQt6.QtCore import Qt, QObject, pyqtSignal, QThread + +class VideoEditorPlugin: + """ + Base class for all plugins. + Plugins should inherit from this class and be located in 'plugins/plugin_name/main.py'. + The main class in main.py must be named 'Plugin'. + """ + def __init__(self, app_instance): + self.app = app_instance + self.name = "Unnamed Plugin" + self.description = "No description provided." + + def initialize(self): + """Called once when the plugin is loaded by the PluginManager.""" + pass + + def enable(self): + """Called when the plugin is enabled by the user (e.g., checking the box in the menu).""" + pass + + def disable(self): + """Called when the plugin is disabled by the user.""" + pass + +class PluginManager: + """Manages the discovery, loading, and lifecycle of plugins.""" + def __init__(self, main_app): + self.app = main_app + self.plugins_dir = "plugins" + self.plugins = {} # { 'plugin_name': {'instance': plugin_instance, 'enabled': False, 'module_path': path} } + if not os.path.exists(self.plugins_dir): + os.makedirs(self.plugins_dir) + + def discover_and_load_plugins(self): + """Scans the plugins directory, loads valid plugins, and calls their initialize method.""" + for plugin_name in os.listdir(self.plugins_dir): + plugin_path = os.path.join(self.plugins_dir, plugin_name) + main_py_path = os.path.join(plugin_path, 'main.py') + if os.path.isdir(plugin_path) and os.path.exists(main_py_path): + try: + spec = importlib.util.spec_from_file_location(f"plugins.{plugin_name}.main", main_py_path) + module = importlib.util.module_from_spec(spec) + + sys.path.insert(0, plugin_path) + spec.loader.exec_module(module) + sys.path.pop(0) + + if hasattr(module, 'Plugin'): + plugin_class = getattr(module, 'Plugin') + instance = plugin_class(self.app) + instance.initialize() + self.plugins[instance.name] = { + 'instance': instance, + 'enabled': False, + 'module_path': plugin_path + } + print(f"Discovered and loaded plugin: {instance.name}") + else: + print(f"Warning: {main_py_path} does not have a 'Plugin' class.") + except Exception as e: + print(f"Error loading plugin {plugin_name}: {e}") + + def load_enabled_plugins_from_settings(self, enabled_plugins_list): + """Enables plugins based on the loaded settings.""" + for name in enabled_plugins_list: + if name in self.plugins: + self.enable_plugin(name) + + def get_enabled_plugin_names(self): + """Returns a list of names of all enabled plugins.""" + return [name for name, data in self.plugins.items() if data['enabled']] + + def enable_plugin(self, name): + """Enables a specific plugin by name and updates the UI.""" + if name in self.plugins and not self.plugins[name]['enabled']: + self.plugins[name]['instance'].enable() + self.plugins[name]['enabled'] = True + # Notify the app to update the menu's checkmark + self.app.toggle_plugin_action(name, True) + self.app.update_plugin_ui_visibility(name, True) + + def disable_plugin(self, name): + """Disables a specific plugin by name and updates the UI.""" + if name in self.plugins and self.plugins[name]['enabled']: + self.plugins[name]['instance'].disable() + self.plugins[name]['enabled'] = False + # Notify the app to update the menu's checkmark + self.app.toggle_plugin_action(name, False) + self.app.update_plugin_ui_visibility(name, False) + + def uninstall_plugin(self, name): + """Uninstalls (deletes) a plugin by name.""" + if name in self.plugins: + path = self.plugins[name]['module_path'] + if self.plugins[name]['enabled']: + self.disable_plugin(name) + + try: + shutil.rmtree(path) + del self.plugins[name] + return True + except OSError as e: + print(f"Error removing plugin directory {path}: {e}") + return False + return False + + +class InstallWorker(QObject): + finished = pyqtSignal(str, bool) + + def __init__(self, url, target_dir): + super().__init__() + self.url = url + self.target_dir = target_dir + + def run(self): + try: + repo_name = self.url.split('/')[-1].replace('.git', '') + clone_path = os.path.join(self.target_dir, repo_name) + if os.path.exists(clone_path): + self.finished.emit(f"Directory '{repo_name}' already exists.", False) + return + + git.Repo.clone_from(self.url, clone_path) + + req_path = os.path.join(clone_path, 'requirements.txt') + if os.path.exists(req_path): + print("Installing plugin requirements...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_path]) + + self.finished.emit(f"Plugin '{repo_name}' installed successfully. Please restart the application.", True) + except Exception as e: + self.finished.emit(f"Installation failed: {e}", False) + + +class ManagePluginsDialog(QDialog): + def __init__(self, plugin_manager, parent=None): + super().__init__(parent) + self.plugin_manager = plugin_manager + self.setWindowTitle("Manage Plugins") + self.setMinimumSize(500, 400) + + self.plugin_checkboxes = {} + + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Installed Plugins:")) + self.list_widget = QListWidget() + layout.addWidget(self.list_widget) + + install_layout = QVBoxLayout() + install_layout.addWidget(QLabel("Install new plugin from GitHub URL:")) + self.url_input = QLineEdit() + self.url_input.setPlaceholderText("e.g., https://github.com/user/repo.git") + self.install_btn = QPushButton("Install") + install_layout.addWidget(self.url_input) + install_layout.addWidget(self.install_btn) + layout.addLayout(install_layout) + + self.status_label = QLabel("Ready.") + layout.addWidget(self.status_label) + + # Dialog buttons + self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) + layout.addWidget(self.button_box) + + # Connections + self.install_btn.clicked.connect(self.install_plugin) + self.button_box.accepted.connect(self.save_changes) + self.button_box.rejected.connect(self.reject) + + self.populate_list() + + def populate_list(self): + self.list_widget.clear() + self.plugin_checkboxes.clear() + + for name, data in sorted(self.plugin_manager.plugins.items()): + item_widget = QWidget() + item_layout = QHBoxLayout(item_widget) + item_layout.setContentsMargins(5, 5, 5, 5) + + checkbox = QCheckBox(name) + checkbox.setChecked(data['enabled']) + checkbox.setToolTip(data['instance'].description) + self.plugin_checkboxes[name] = checkbox + item_layout.addWidget(checkbox, 1) + + uninstall_btn = QPushButton("Uninstall") + uninstall_btn.setFixedWidth(80) + uninstall_btn.clicked.connect(lambda _, n=name: self.handle_uninstall(n)) + item_layout.addWidget(uninstall_btn) + + item_widget.setLayout(item_layout) + + list_item = QListWidgetItem(self.list_widget) + list_item.setSizeHint(item_widget.sizeHint()) + self.list_widget.addItem(list_item) + self.list_widget.setItemWidget(list_item, item_widget) + + def save_changes(self): + for name, checkbox in self.plugin_checkboxes.items(): + if name not in self.plugin_manager.plugins: + continue + + is_checked = checkbox.isChecked() + is_currently_enabled = self.plugin_manager.plugins[name]['enabled'] + + if is_checked and not is_currently_enabled: + self.plugin_manager.enable_plugin(name) + elif not is_checked and is_currently_enabled: + self.plugin_manager.disable_plugin(name) + + self.plugin_manager.app._save_settings() + self.accept() + + def handle_uninstall(self, name): + reply = QMessageBox.question(self, "Confirm Uninstall", + f"Are you sure you want to permanently delete the plugin '{name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + + if reply == QMessageBox.StandardButton.Yes: + if self.plugin_manager.uninstall_plugin(name): + self.status_label.setText(f"Uninstalled '{name}'. Restart the application to fully remove it from the menu.") + self.populate_list() + else: + self.status_label.setText(f"Failed to uninstall '{name}'.") + + def install_plugin(self): + url = self.url_input.text().strip() + if not url.endswith(".git"): + QMessageBox.warning(self, "Invalid URL", "Please provide a valid git repository URL (ending in .git).") + return + + self.install_btn.setEnabled(False) + self.status_label.setText(f"Cloning from {url}...") + + self.install_thread = QThread() + self.install_worker = InstallWorker(url, self.plugin_manager.plugins_dir) + self.install_worker.moveToThread(self.install_thread) + + self.install_thread.started.connect(self.install_worker.run) + self.install_worker.finished.connect(self.on_install_finished) + self.install_worker.finished.connect(self.install_thread.quit) + self.install_worker.finished.connect(self.install_worker.deleteLater) + self.install_thread.finished.connect(self.install_thread.deleteLater) + + self.install_thread.start() + + def on_install_finished(self, message, success): + self.status_label.setText(message) + self.install_btn.setEnabled(True) + if success: + self.url_input.clear() \ No newline at end of file diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py new file mode 100644 index 000000000..873cd5e56 --- /dev/null +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -0,0 +1,308 @@ +import sys +import os +import tempfile +import shutil +import requests +from pathlib import Path + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, + QLineEdit, QPushButton, QLabel, QMessageBox, QCheckBox +) +from PyQt6.QtGui import QImage, QPixmap +from PyQt6.QtCore import QTimer, pyqtSignal, Qt, QSize + +sys.path.append(str(Path(__file__).parent.parent.parent)) +from plugins import VideoEditorPlugin + + +API_BASE_URL = "http://127.0.0.1:5100" + +class WgpClientWidget(QWidget): + generation_complete = pyqtSignal(str) + + def __init__(self): + super().__init__() + + self.last_known_output = None + + layout = QVBoxLayout(self) + form_layout = QFormLayout() + + self.model_input = QLineEdit() + self.model_input.setPlaceholderText("Optional (default: i2v_2_2)") + + previews_layout = QHBoxLayout() + start_preview_layout = QVBoxLayout() + start_preview_layout.addWidget(QLabel("Start Frame")) + self.start_frame_preview = QLabel("N/A") + self.start_frame_preview.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.start_frame_preview.setFixedSize(160, 90) + self.start_frame_preview.setStyleSheet("background-color: #222; color: #888;") + start_preview_layout.addWidget(self.start_frame_preview) + previews_layout.addLayout(start_preview_layout) + + end_preview_layout = QVBoxLayout() + end_preview_layout.addWidget(QLabel("End Frame")) + self.end_frame_preview = QLabel("N/A") + self.end_frame_preview.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.end_frame_preview.setFixedSize(160, 90) + self.end_frame_preview.setStyleSheet("background-color: #222; color: #888;") + end_preview_layout.addWidget(self.end_frame_preview) + previews_layout.addLayout(end_preview_layout) + + self.start_frame_input = QLineEdit() + self.end_frame_input = QLineEdit() + self.duration_input = QLineEdit() + + self.autostart_checkbox = QCheckBox("Start Generation Immediately") + self.autostart_checkbox.setChecked(True) + + self.generate_button = QPushButton("Generate") + + self.status_label = QLabel("Status: Idle") + self.output_label = QLabel("Latest Output File: None") + self.output_label.setWordWrap(True) + + layout.addLayout(previews_layout) + form_layout.addRow("Model Type:", self.model_input) + form_layout.addRow(self.autostart_checkbox) + form_layout.addRow(self.generate_button) + + layout.addLayout(form_layout) + layout.addWidget(self.status_label) + layout.addWidget(self.output_label) + + self.generate_button.clicked.connect(self.generate) + + self.poll_timer = QTimer(self) + self.poll_timer.setInterval(3000) + self.poll_timer.timeout.connect(self.poll_for_output) + + def set_previews(self, start_pixmap, end_pixmap): + if start_pixmap: + self.start_frame_preview.setPixmap(start_pixmap) + else: + self.start_frame_preview.setText("N/A") + self.start_frame_preview.setPixmap(QPixmap()) + + if end_pixmap: + self.end_frame_preview.setPixmap(end_pixmap) + else: + self.end_frame_preview.setText("N/A") + self.end_frame_preview.setPixmap(QPixmap()) + + def start_polling(self): + self.poll_timer.start() + self.check_server_status() + + def stop_polling(self): + self.poll_timer.stop() + + def handle_api_error(self, response, action="performing action"): + try: + error_msg = response.json().get("error", "Unknown error") + except requests.exceptions.JSONDecodeError: + error_msg = response.text + self.status_label.setText(f"Status: Error {action}: {error_msg}") + QMessageBox.warning(self, "API Error", f"Failed while {action}.\n\nServer response:\n{error_msg}") + + def check_server_status(self): + try: + requests.get(f"{API_BASE_URL}/api/latest_output", timeout=1) + self.status_label.setText("Status: Connected to WanGP server.") + except requests.exceptions.ConnectionError: + self.status_label.setText("Status: Error - Cannot connect to WanGP server.") + QMessageBox.critical(self, "Connection Error", f"Could not connect to the WanGP API at {API_BASE_URL}.\n\nPlease ensure wgptool.py is running.") + + + def generate(self): + payload = {} + + model_type = self.model_input.text().strip() + if model_type: + payload['model_type'] = model_type + + if self.start_frame_input.text(): + payload['start_frame'] = self.start_frame_input.text() + if self.end_frame_input.text(): + payload['end_frame'] = self.end_frame_input.text() + + if self.duration_input.text(): + payload['duration_sec'] = self.duration_input.text() + + payload['start_generation'] = self.autostart_checkbox.isChecked() + + self.status_label.setText("Status: Sending parameters...") + try: + response = requests.post(f"{API_BASE_URL}/api/generate", json=payload) + if response.status_code == 200: + if payload['start_generation']: + self.status_label.setText("Status: Parameters set. Generation sent. Polling...") + else: + self.status_label.setText("Status: Parameters set. Waiting for manual start.") + else: + self.handle_api_error(response, "setting parameters") + except requests.exceptions.RequestException as e: + self.status_label.setText(f"Status: Connection error: {e}") + + def poll_for_output(self): + try: + response = requests.get(f"{API_BASE_URL}/api/latest_output") + if response.status_code == 200: + data = response.json() + latest_path = data.get("latest_output_path") + + if latest_path and latest_path != self.last_known_output: + self.last_known_output = latest_path + self.output_label.setText(f"Latest Output File:\n{latest_path}") + self.status_label.setText("Status: New output received! Inserting clip...") + self.generation_complete.emit(latest_path) + else: + if "Error" not in self.status_label.text() and "waiting" not in self.status_label.text().lower(): + self.status_label.setText("Status: Polling for output...") + except requests.exceptions.RequestException: + if "Error" not in self.status_label.text(): + self.status_label.setText("Status: Polling... (Connection issue)") + +class Plugin(VideoEditorPlugin): + def initialize(self): + self.name = "AI Frame Joiner" + self.description = "Uses a local AI server to generate a video between two frames." + self.client_widget = WgpClientWidget() + self.dock_widget = None + self.active_region = None + self.temp_dir = None + self.client_widget.generation_complete.connect(self.insert_generated_clip) + + def enable(self): + if not self.dock_widget: + self.dock_widget = self.app.add_dock_widget(self, self.client_widget, "AI Frame Joiner", show_on_creation=False) + + self.dock_widget.hide() + + self.app.timeline_widget.context_menu_requested.connect(self.on_timeline_context_menu) + self.client_widget.start_polling() + + def disable(self): + try: + self.app.timeline_widget.context_menu_requested.disconnect(self.on_timeline_context_menu) + except TypeError: + pass + self._cleanup_temp_dir() + self.client_widget.stop_polling() + + def _cleanup_temp_dir(self): + if self.temp_dir and os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + self.temp_dir = None + + def _reset_state(self): + self.active_region = None + self._cleanup_temp_dir() + self.client_widget.status_label.setText("Status: Idle") + self.client_widget.output_label.setText("Latest Output File: None") + self.client_widget.set_previews(None, None) + + def on_timeline_context_menu(self, menu, event): + region = self.app.timeline_widget.get_region_at_pos(event.pos()) + if region: + menu.addSeparator() + action = menu.addAction("Join Frames With AI") + action.triggered.connect(lambda: self.setup_generator_for_region(region)) + + def setup_generator_for_region(self, region): + self._reset_state() + self.active_region = region + start_sec, end_sec = region + + start_data, w, h = self.app.get_frame_data_at_time(start_sec) + end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + + if not start_data or not end_data: + QMessageBox.warning(self.app, "Frame Error", "Could not extract start and/or end frames for the selected region.") + return + + preview_size = QSize(160, 90) + start_pixmap, end_pixmap = None, None + + try: + start_img = QImage(start_data, w, h, QImage.Format.Format_RGB888) + start_pixmap = QPixmap.fromImage(start_img).scaled(preview_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + + end_img = QImage(end_data, w, h, QImage.Format.Format_RGB888) + end_pixmap = QPixmap.fromImage(end_img).scaled(preview_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + except Exception as e: + QMessageBox.critical(self.app, "Image Error", f"Could not create preview images: {e}") + + self.client_widget.set_previews(start_pixmap, end_pixmap) + + try: + self.temp_dir = tempfile.mkdtemp(prefix="ai_joiner_") + start_img_path = os.path.join(self.temp_dir, "start_frame.png") + end_img_path = os.path.join(self.temp_dir, "end_frame.png") + + QImage(start_data, w, h, QImage.Format.Format_RGB888).save(start_img_path) + QImage(end_data, w, h, QImage.Format.Format_RGB888).save(end_img_path) + except Exception as e: + QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame images: {e}") + self._cleanup_temp_dir() + return + + duration_sec = end_sec - start_sec + self.client_widget.duration_input.setText(str(duration_sec)) + + self.client_widget.start_frame_input.setText(start_img_path) + self.client_widget.end_frame_input.setText(end_img_path) + self.client_widget.status_label.setText(f"Status: Ready for region {start_sec:.2f}s - {end_sec:.2f}s") + + self.dock_widget.show() + self.dock_widget.raise_() + + def insert_generated_clip(self, video_path): + if not self.active_region: + self.client_widget.status_label.setText("Status: Error - No active region to insert into.") + return + + if not os.path.exists(video_path): + self.client_widget.status_label.setText(f"Status: Error - Output file not found: {video_path}") + return + + start_sec, end_sec = self.active_region + duration = end_sec - start_sec + self.app.status_label.setText(f"Inserting AI clip: {os.path.basename(video_path)}") + + try: + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) + + clips_to_remove = [ + c for c in self.app.timeline.clips + if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec + ] + for clip in clips_to_remove: + if clip in self.app.timeline.clips: + self.app.timeline.clips.remove(clip) + + self.app._add_clip_to_timeline( + source_path=video_path, + timeline_start_sec=start_sec, + clip_start_sec=0, + duration_sec=duration, + video_track_index=1, + audio_track_index=None + ) + + self.app.status_label.setText("AI clip inserted successfully.") + self.client_widget.status_label.setText("Status: Success! Clip inserted.") + self.app.prune_empty_tracks() + + except Exception as e: + error_message = f"Error during clip insertion: {e}" + self.app.status_label.setText(error_message) + self.client_widget.status_label.setText("Status: Failed to insert clip.") + print(error_message) + + finally: + self._cleanup_temp_dir() + self.active_region = None \ No newline at end of file From d7f0da36da3740b1e5769eab257154d9bde16189 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 04:14:11 +1100 Subject: [PATCH 02/67] adjust run command --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b409148a..fb88c7acd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ python main.py **Run the video editor:** ```bash -python videoeditor\main.py +cd videoeditor +python main.py ``` From 7e7007e29c7cf90246cad18c45912a06ff5e54c0 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 04:15:20 +1100 Subject: [PATCH 03/67] forgot to include server --- main.py | 2006 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2006 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 000000000..06292b315 --- /dev/null +++ b/main.py @@ -0,0 +1,2006 @@ +import sys +import os +import threading +import time +import json +import re +from unittest.mock import MagicMock + +# --- Start of Gradio Hijacking --- +# This block creates a mock Gradio module. When wgp.py is imported, +# all calls to `gr.*` will be intercepted by these mock objects, +# preventing any UI from being built and allowing us to use the +# backend logic directly. + +class MockGradioComponent(MagicMock): + """A smarter mock that captures constructor arguments.""" + def __init__(self, *args, **kwargs): + super().__init__(name=f"gr.{kwargs.get('elem_id', 'component')}") + # Store the kwargs so we can inspect them later + self.kwargs = kwargs + self.value = kwargs.get('value') + self.choices = kwargs.get('choices') + + # Mock chaining methods like .click(), .then(), etc. + for method in ['then', 'change', 'click', 'input', 'select', 'upload', 'mount', 'launch', 'on', 'release']: + setattr(self, method, lambda *a, **kw: self) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + +class MockGradioError(Exception): + pass + +class MockGradioModule: # No longer inherits from MagicMock + def __getattr__(self, name): + if name == 'Error': + return lambda *args, **kwargs: MockGradioError(*args) + + # Nullify functions that show pop-ups + if name in ['Info', 'Warning']: + return lambda *args, **kwargs: print(f"Intercepted gr.{name}:", *args) + + return lambda *args, **kwargs: MockGradioComponent(*args, **kwargs) + +sys.modules['gradio'] = MockGradioModule() +sys.modules['gradio.gallery'] = MockGradioModule() # Also mock any submodules used +sys.modules['shared.gradio.gallery'] = MockGradioModule() +# --- End of Gradio Hijacking --- + +# Global placeholder for the wgp module. Will be None if import fails. +wgp = None + +# Load configuration and attempt to import wgp +MAIN_CONFIG_FILE = 'main_config.json' +main_config = {} + +def load_main_config(): + global main_config + try: + with open(MAIN_CONFIG_FILE, 'r') as f: + main_config = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + current_folder = os.path.dirname(os.path.abspath(sys.argv[0])) + main_config = {'wgp_path': current_folder} + +def save_main_config(): + global main_config + try: + with open(MAIN_CONFIG_FILE, 'w') as f: + json.dump(main_config, f, indent=4) + except Exception as e: + print(f"Error saving main_config.json: {e}") + +def setup_and_import_wgp(): + """Adds configured path to sys.path and tries to import wgp.""" + global wgp + wgp_path = main_config.get('wgp_path') + if wgp_path and os.path.isdir(wgp_path) and os.path.isfile(os.path.join(wgp_path, 'wgp.py')): + if wgp_path not in sys.path: + sys.path.insert(0, wgp_path) + try: + import wgp as wgp_module + wgp = wgp_module + return True + except ImportError as e: + print(f"Error: Failed to import wgp.py from the configured path '{wgp_path}'.\nDetails: {e}") + wgp = None + return False + else: + print("Info: WAN2GP folder path not set or invalid. Please configure it in File > Settings.") + wgp = None + return False + +# Load config and attempt import at script start +load_main_config() +wgp_loaded = setup_and_import_wgp() + +# Now import PyQt6 components +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, + QPushButton, QLabel, QLineEdit, QTextEdit, QSlider, QCheckBox, QComboBox, + QFileDialog, QGroupBox, QFormLayout, QTableWidget, QTableWidgetItem, + QHeaderView, QProgressBar, QScrollArea, QListWidget, QListWidgetItem, + QMessageBox, QRadioButton, QDialog +) +from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QTimer, pyqtSlot +from PyQt6.QtGui import QPixmap, QDropEvent, QAction +from PIL.ImageQt import ImageQt + + +class SettingsDialog(QDialog): + """Dialog to configure application settings like the wgp.py path.""" + def __init__(self, config, parent=None): + super().__init__(parent) + self.config = config + self.setWindowTitle("Settings") + self.setMinimumWidth(500) + + layout = QVBoxLayout(self) + form_layout = QFormLayout() + + path_layout = QHBoxLayout() + self.wgp_path_edit = QLineEdit(self.config.get('wgp_path', '')) + self.wgp_path_edit.setPlaceholderText("Path to the folder containing wgp.py") + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(self.browse_for_wgp_folder) + path_layout.addWidget(self.wgp_path_edit) + path_layout.addWidget(browse_btn) + + form_layout.addRow("WAN2GP Folder Path:", path_layout) + layout.addLayout(form_layout) + + button_layout = QHBoxLayout() + save_btn = QPushButton("Save") + save_restart_btn = QPushButton("Save and Restart") + cancel_btn = QPushButton("Cancel") + button_layout.addStretch() + button_layout.addWidget(save_btn) + button_layout.addWidget(save_restart_btn) + button_layout.addWidget(cancel_btn) + layout.addLayout(button_layout) + + save_btn.clicked.connect(self.save_and_close) + save_restart_btn.clicked.connect(self.save_and_restart) + cancel_btn.clicked.connect(self.reject) + + def browse_for_wgp_folder(self): + directory = QFileDialog.getExistingDirectory(self, "Select WAN2GP Folder") + if directory: + self.wgp_path_edit.setText(directory) + + def validate_path(self, path): + if not path or not os.path.isdir(path) or not os.path.isfile(os.path.join(path, 'wgp.py')): + QMessageBox.warning(self, "Invalid Path", "The selected folder does not contain 'wgp.py'. Please select the correct WAN2GP folder.") + return False + return True + + def _save_config(self): + path = self.wgp_path_edit.text() + if not self.validate_path(path): + return False + self.config['wgp_path'] = path + save_main_config() + return True + + def save_and_close(self): + if self._save_config(): + QMessageBox.information(self, "Settings Saved", "Settings have been saved. Please restart the application for changes to take effect.") + self.accept() + + def save_and_restart(self): + if self._save_config(): + self.parent().close() # Close the main window before restarting + os.execv(sys.executable, [sys.executable] + sys.argv) + + +class QueueTableWidget(QTableWidget): + """A QTableWidget with drag-and-drop reordering for rows.""" + rowsMoved = pyqtSignal(int, int) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(self.DragDropMode.InternalMove) + self.setSelectionBehavior(self.SelectionBehavior.SelectRows) + self.setSelectionMode(self.SelectionMode.SingleSelection) + + def dropEvent(self, event: QDropEvent): + if event.source() == self and event.dropAction() == Qt.DropAction.MoveAction: + source_row = self.currentRow() + target_item = self.itemAt(event.position().toPoint()) + dest_row = target_item.row() if target_item else self.rowCount() + + # Adjust destination row if moving down + if source_row < dest_row: + dest_row -=1 + + if source_row != dest_row: + self.rowsMoved.emit(source_row, dest_row) + + event.acceptProposedAction() + else: + super().dropEvent(event) + + +class Worker(QObject): + progress = pyqtSignal(list) + status = pyqtSignal(str) + preview = pyqtSignal(object) + output = pyqtSignal() + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, state): + super().__init__() + self.state = state + self._is_running = True + self._last_progress_phase = None + self._last_preview = None + + def send_cmd(self, cmd, data=None): + if not self._is_running: + return + + def run(self): + def generation_target(): + try: + for _ in wgp.process_tasks(self.state): + if self._is_running: + self.output.emit() + else: + break + except Exception as e: + import traceback + print("Error in generation thread:") + traceback.print_exc() + if "gradio.Error" in str(type(e)): + self.error.emit(str(e)) + else: + self.error.emit(f"An unexpected error occurred: {e}") + finally: + self._is_running = False + + gen_thread = threading.Thread(target=generation_target, daemon=True) + gen_thread.start() + + while self._is_running: + gen = self.state.get('gen', {}) + + current_phase = gen.get("progress_phase") + if current_phase and current_phase != self._last_progress_phase: + self._last_progress_phase = current_phase + + phase_name, step = current_phase + total_steps = gen.get("num_inference_steps", 1) + high_level_status = gen.get("progress_status", "") + + status_msg = wgp.merge_status_context(high_level_status, phase_name) + + progress_args = [(step, total_steps), status_msg] + self.progress.emit(progress_args) + + preview_img = gen.get('preview') + if preview_img is not None and preview_img is not self._last_preview: + self._last_preview = preview_img + self.preview.emit(preview_img) + gen['preview'] = None + + time.sleep(0.1) + + gen_thread.join() + self.finished.emit() + +class ApiBridge(QObject): + """ + An object that lives in the main thread to receive signals from the API thread + and forward them to the MainWindow's slots. + """ + # --- CHANGE: Signal now includes model_type and duration_sec --- + generateSignal = pyqtSignal(object, object, object, object, bool) + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + self.api_bridge = ApiBridge() + + self.widgets = {} + self.state = {} + self.worker = None + self.thread = None + self.lora_map = {} + self.full_resolution_choices = [] + self.latest_output_path = None + + self.setup_menu() + + if not wgp: + self.setWindowTitle("WanGP - Setup Required") + self.setGeometry(100, 100, 600, 200) + self.setup_placeholder_ui() + QTimer.singleShot(100, lambda: self.show_settings_dialog(first_time=True)) + else: + self.setWindowTitle(f"WanGP v{wgp.WanGP_version} - Qt Interface") + self.setGeometry(100, 100, 1400, 950) + self.setup_full_ui() + self.apply_initial_config() + self.connect_signals() + self.init_wgp_state() + + # --- CHANGE: Removed setModelSignal as it's no longer needed --- + self.api_bridge.generateSignal.connect(self._api_generate) + + @pyqtSlot(str) + def _api_set_model(self, model_type): + """This slot is executed in the main GUI thread.""" + if not model_type or not wgp: return + + # 1. Check if the model is valid by looking it up in the master model definition dictionary. + if model_type not in wgp.models_def: + print(f"API Error: Model type '{model_type}' is not a valid model.") + return + + # 2. Check if already selected to avoid unnecessary UI refreshes. + if self.state.get('model_type') == model_type: + print(f"API: Model is already set to {model_type}.") + return + + # 3. Redraw all model dropdowns to ensure the correct hierarchy is displayed + # and the target model is selected. This function handles finding the + # correct Family and Base model for the given finetune model_type. + self.update_model_dropdowns(model_type) + + # 4. Manually trigger the logic that normally runs when the user selects a model. + # This is necessary because update_model_dropdowns blocks signals. + self._on_model_changed() + + # 5. Final check to see if the model was actually set. + if self.state.get('model_type') == model_type: + print(f"API: Successfully set model to {model_type}.") + else: + # This could happen if update_model_dropdowns silently fails to find the model. + print(f"API Error: Failed to set model to '{model_type}'. The model might be hidden by your current configuration.") + + # --- CHANGE: Slot now accepts model_type and duration_sec, and calculates frame count --- + @pyqtSlot(object, object, object, object, bool) + def _api_generate(self, start_frame, end_frame, duration_sec, model_type, start_generation): + """This slot is executed in the main GUI thread.""" + # 1. Set model if a new one is provided + if model_type: + self._api_set_model(model_type) + + # 2. Set frame inputs + if start_frame: + self.widgets['mode_s'].setChecked(True) + self.widgets['image_start'].setText(start_frame) + + if end_frame: + self.widgets['image_end_checkbox'].setChecked(True) + self.widgets['image_end'].setText(end_frame) + + # 3. Calculate video length in frames based on duration and model FPS + if duration_sec is not None: + try: + duration = float(duration_sec) + + # Get base FPS by parsing the "Force FPS" dropdown's default text (e.g., "Model Default (16 fps)") + base_fps = 16 # Fallback + fps_text = self.widgets['force_fps'].itemText(0) + match = re.search(r'\((\d+)\s*fps\)', fps_text) + if match: + base_fps = int(match.group(1)) + + # Temporal upsampling creates more frames in post-processing, so we must account for it here. + upsample_setting = self.widgets['temporal_upsampling'].currentData() + multiplier = 1.0 + if upsample_setting == "rife2": + multiplier = 2.0 + elif upsample_setting == "rife4": + multiplier = 4.0 + + # The number of frames the model needs to generate + video_length_frames = int(duration * base_fps * multiplier) + + self.widgets['video_length'].setValue(video_length_frames) + print(f"API: Calculated video length: {video_length_frames} frames for {duration:.2f}s @ {base_fps*multiplier:.0f} effective FPS.") + + except (ValueError, TypeError) as e: + print(f"API Error: Invalid duration_sec '{duration_sec}': {e}") + + # 4. Conditionally start generation + if start_generation: + self.generate_btn.click() + print("API: Generation started.") + else: + print("API: Parameters set without starting generation.") + + + def setup_menu(self): + menu_bar = self.menuBar() + file_menu = menu_bar.addMenu("&File") + + settings_action = QAction("&Settings", self) + settings_action.triggered.connect(self.show_settings_dialog) + file_menu.addAction(settings_action) + + def show_settings_dialog(self, first_time=False): + dialog = SettingsDialog(main_config, self) + if first_time: + dialog.setWindowTitle("Initial Setup: Configure WAN2GP Path") + dialog.exec() + + def setup_placeholder_ui(self): + main_widget = QWidget() + self.setCentralWidget(main_widget) + layout = QVBoxLayout(main_widget) + + placeholder_label = QLabel( + "

Welcome to WanGP

" + "

The path to your WAN2GP installation (the folder containing wgp.py) is not set.

" + "

Please go to File > Settings to configure the path.

" + ) + placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + placeholder_label.setWordWrap(True) + layout.addWidget(placeholder_label) + + def setup_full_ui(self): + main_widget = QWidget() + self.setCentralWidget(main_widget) + main_layout = QVBoxLayout(main_widget) + + self.header_info = QLabel("Header Info") + main_layout.addWidget(self.header_info) + + self.tabs = QTabWidget() + main_layout.addWidget(self.tabs) + + self.setup_generator_tab() + self.setup_config_tab() + + def create_widget(self, widget_class, name, *args, **kwargs): + widget = widget_class(*args, **kwargs) + self.widgets[name] = widget + return widget + + def _create_slider_with_label(self, name, min_val, max_val, initial_val, scale=1.0, precision=1): + container = QWidget() + hbox = QHBoxLayout(container) + hbox.setContentsMargins(0, 0, 0, 0) + + slider = self.create_widget(QSlider, name, Qt.Orientation.Horizontal) + slider.setRange(min_val, max_val) + slider.setValue(int(initial_val * scale)) + + value_label = self.create_widget(QLabel, f"{name}_label", f"{initial_val:.{precision}f}") + value_label.setMinimumWidth(50) + + slider.valueChanged.connect( + lambda v, lbl=value_label, s=scale, p=precision: lbl.setText(f"{v/s:.{p}f}") + ) + + hbox.addWidget(slider) + hbox.addWidget(value_label) + return container + + def _create_file_input(self, name, label_text): + container = self.create_widget(QWidget, f"{name}_container") + hbox = QHBoxLayout(container) + hbox.setContentsMargins(0, 0, 0, 0) + + line_edit = self.create_widget(QLineEdit, name) + line_edit.setReadOnly(False) # Allow user to paste paths + line_edit.setPlaceholderText("No file selected or path pasted") + + button = QPushButton("Browse...") + + def open_dialog(): + # Allow selecting multiple files for reference images + if "refs" in name: + filenames, _ = QFileDialog.getOpenFileNames(self, f"Select {label_text}") + if filenames: + line_edit.setText(";".join(filenames)) + else: + filename, _ = QFileDialog.getOpenFileName(self, f"Select {label_text}") + if filename: + line_edit.setText(filename) + + button.clicked.connect(open_dialog) + + clear_button = QPushButton("X") + clear_button.setFixedWidth(30) + clear_button.clicked.connect(lambda: line_edit.clear()) + + hbox.addWidget(QLabel(f"{label_text}:")) + hbox.addWidget(line_edit, 1) + hbox.addWidget(button) + hbox.addWidget(clear_button) + return container + + def setup_generator_tab(self): + gen_tab = QWidget() + self.tabs.addTab(gen_tab, "Video Generator") + gen_layout = QHBoxLayout(gen_tab) + + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + gen_layout.addWidget(left_panel, 1) + + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + gen_layout.addWidget(right_panel, 1) + + # Left Panel (Inputs) + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + left_layout.addWidget(scroll_area) + + options_widget = QWidget() + scroll_area.setWidget(options_widget) + options_layout = QVBoxLayout(options_widget) + + # Model Selection + model_layout = QHBoxLayout() + self.widgets['model_family'] = QComboBox() + self.widgets['model_base_type_choice'] = QComboBox() + self.widgets['model_choice'] = QComboBox() + model_layout.addWidget(QLabel("Model:")) + model_layout.addWidget(self.widgets['model_family'], 2) + model_layout.addWidget(self.widgets['model_base_type_choice'], 3) + model_layout.addWidget(self.widgets['model_choice'], 3) + options_layout.addLayout(model_layout) + + # Prompt + options_layout.addWidget(QLabel("Prompt:")) + self.create_widget(QTextEdit, 'prompt').setMinimumHeight(100) + options_layout.addWidget(self.widgets['prompt']) + + options_layout.addWidget(QLabel("Negative Prompt:")) + self.create_widget(QTextEdit, 'negative_prompt').setMinimumHeight(60) + options_layout.addWidget(self.widgets['negative_prompt']) + + # Basic controls + basic_group = QGroupBox("Basic Options") + basic_layout = QFormLayout(basic_group) + + res_container = QWidget() + res_hbox = QHBoxLayout(res_container) + res_hbox.setContentsMargins(0, 0, 0, 0) + res_hbox.addWidget(self.create_widget(QComboBox, 'resolution_group'), 2) + res_hbox.addWidget(self.create_widget(QComboBox, 'resolution'), 3) + basic_layout.addRow("Resolution:", res_container) + + basic_layout.addRow("Video Length:", self._create_slider_with_label('video_length', 1, 737, 81, 1.0, 0)) + basic_layout.addRow("Inference Steps:", self._create_slider_with_label('num_inference_steps', 1, 100, 30, 1.0, 0)) + basic_layout.addRow("Seed:", self.create_widget(QLineEdit, 'seed', '-1')) + options_layout.addWidget(basic_group) + + # Generation Mode and Input Options + mode_options_group = QGroupBox("Generation Mode & Input Options") + mode_options_layout = QVBoxLayout(mode_options_group) + + mode_hbox = QHBoxLayout() + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_t', "Text Prompt Only")) + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_s', "Start with Image")) + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_v', "Continue Video")) + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_l', "Continue Last Video")) + self.widgets['mode_t'].setChecked(True) + mode_options_layout.addLayout(mode_hbox) + + options_hbox = QHBoxLayout() + options_hbox.addWidget(self.create_widget(QCheckBox, 'image_end_checkbox', "Use End Image")) + options_hbox.addWidget(self.create_widget(QCheckBox, 'control_video_checkbox', "Use Control Video")) + options_hbox.addWidget(self.create_widget(QCheckBox, 'ref_image_checkbox', "Use Reference Image(s)")) + mode_options_layout.addLayout(options_hbox) + options_layout.addWidget(mode_options_group) + + # Dynamic Inputs + inputs_group = QGroupBox("Inputs") + inputs_layout = QVBoxLayout(inputs_group) + inputs_layout.addWidget(self._create_file_input('image_start', "Start Image")) + inputs_layout.addWidget(self._create_file_input('image_end', "End Image")) + inputs_layout.addWidget(self._create_file_input('video_source', "Source Video")) + inputs_layout.addWidget(self._create_file_input('video_guide', "Control Video")) + inputs_layout.addWidget(self._create_file_input('video_mask', "Video Mask")) + inputs_layout.addWidget(self._create_file_input('image_refs', "Reference Image(s)")) + denoising_row = QFormLayout() + denoising_row.addRow("Denoising Strength:", self._create_slider_with_label('denoising_strength', 0, 100, 50, 100.0, 2)) + inputs_layout.addLayout(denoising_row) + options_layout.addWidget(inputs_group) + + # Advanced controls + self.advanced_group = self.create_widget(QGroupBox, 'advanced_group', "Advanced Options") + self.advanced_group.setCheckable(True) + self.advanced_group.setChecked(False) + advanced_layout = QVBoxLayout(self.advanced_group) + + advanced_tabs = self.create_widget(QTabWidget, 'advanced_tabs') + advanced_layout.addWidget(advanced_tabs) + + self._setup_adv_tab_general(advanced_tabs) + self._setup_adv_tab_loras(advanced_tabs) + self._setup_adv_tab_speed(advanced_tabs) + self._setup_adv_tab_postproc(advanced_tabs) + self._setup_adv_tab_audio(advanced_tabs) + self._setup_adv_tab_quality(advanced_tabs) + self._setup_adv_tab_sliding_window(advanced_tabs) + self._setup_adv_tab_misc(advanced_tabs) + + options_layout.addWidget(self.advanced_group) + + # Right Panel (Output & Queue) + btn_layout = QHBoxLayout() + self.generate_btn = self.create_widget(QPushButton, 'generate_btn', "Generate") + self.add_to_queue_btn = self.create_widget(QPushButton, 'add_to_queue_btn', "Add to Queue") + self.generate_btn.setEnabled(True) + self.add_to_queue_btn.setEnabled(False) + btn_layout.addWidget(self.generate_btn) + btn_layout.addWidget(self.add_to_queue_btn) + right_layout.addLayout(btn_layout) + + self.status_label = self.create_widget(QLabel, 'status_label', "Idle") + right_layout.addWidget(self.status_label) + self.progress_bar = self.create_widget(QProgressBar, 'progress_bar') + right_layout.addWidget(self.progress_bar) + + preview_group = self.create_widget(QGroupBox, 'preview_group', "Preview") + preview_group.setCheckable(True) + preview_group.setStyleSheet("QGroupBox { border: 1px solid #cccccc; }") + preview_group_layout = QVBoxLayout(preview_group) + + self.preview_image = self.create_widget(QLabel, 'preview_image', "") + self.preview_image.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_image.setMinimumSize(200, 200) + + preview_group_layout.addWidget(self.preview_image) + right_layout.addWidget(preview_group) + + right_layout.addWidget(QLabel("Output:")) + self.output_gallery = self.create_widget(QListWidget, 'output_gallery') + right_layout.addWidget(self.output_gallery) + + right_layout.addWidget(QLabel("Queue:")) + self.queue_table = self.create_widget(QueueTableWidget, 'queue_table') + right_layout.addWidget(self.queue_table) + + queue_btn_layout = QHBoxLayout() + self.remove_queue_btn = self.create_widget(QPushButton, 'remove_queue_btn', "Remove Selected") + self.clear_queue_btn = self.create_widget(QPushButton, 'clear_queue_btn', "Clear Queue") + self.abort_btn = self.create_widget(QPushButton, 'abort_btn', "Abort") + queue_btn_layout.addWidget(self.remove_queue_btn) + queue_btn_layout.addWidget(self.clear_queue_btn) + queue_btn_layout.addWidget(self.abort_btn) + right_layout.addLayout(queue_btn_layout) + + def _setup_adv_tab_general(self, tabs): + tab = QWidget() + tabs.addTab(tab, "General") + layout = QFormLayout(tab) + self.widgets['adv_general_layout'] = layout + + guidance_group = QGroupBox("Guidance") + guidance_layout = self.create_widget(QFormLayout, 'guidance_layout', guidance_group) + guidance_layout.addRow("Guidance (CFG):", self._create_slider_with_label('guidance_scale', 10, 200, 5.0, 10.0, 1)) + + self.widgets['guidance_phases_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Guidance Phases:", self.create_widget(QComboBox, 'guidance_phases')) + + self.widgets['guidance2_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Guidance 2:", self._create_slider_with_label('guidance2_scale', 10, 200, 5.0, 10.0, 1)) + self.widgets['guidance3_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Guidance 3:", self._create_slider_with_label('guidance3_scale', 10, 200, 5.0, 10.0, 1)) + self.widgets['switch_thresh_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Switch Threshold:", self._create_slider_with_label('switch_threshold', 0, 1000, 0, 1.0, 0)) + layout.addRow(guidance_group) + + nag_group = self.create_widget(QGroupBox, 'nag_group', "NAG (Negative Adversarial Guidance)") + nag_layout = QFormLayout(nag_group) + nag_layout.addRow("NAG Scale:", self._create_slider_with_label('NAG_scale', 10, 200, 1.0, 10.0, 1)) + nag_layout.addRow("NAG Tau:", self._create_slider_with_label('NAG_tau', 10, 50, 3.5, 10.0, 1)) + nag_layout.addRow("NAG Alpha:", self._create_slider_with_label('NAG_alpha', 0, 20, 0.5, 10.0, 1)) + layout.addRow(nag_group) + + self.widgets['solver_row_container'] = QWidget() + solver_hbox = QHBoxLayout(self.widgets['solver_row_container']) + solver_hbox.setContentsMargins(0,0,0,0) + solver_hbox.addWidget(QLabel("Sampler Solver:")) + solver_hbox.addWidget(self.create_widget(QComboBox, 'sample_solver')) + layout.addRow(self.widgets['solver_row_container']) + + self.widgets['flow_shift_row_index'] = layout.rowCount() + layout.addRow("Shift Scale:", self._create_slider_with_label('flow_shift', 10, 250, 3.0, 10.0, 1)) + + self.widgets['audio_guidance_row_index'] = layout.rowCount() + layout.addRow("Audio Guidance:", self._create_slider_with_label('audio_guidance_scale', 10, 200, 4.0, 10.0, 1)) + + self.widgets['repeat_generation_row_index'] = layout.rowCount() + layout.addRow("Repeat Generations:", self._create_slider_with_label('repeat_generation', 1, 25, 1, 1.0, 0)) + + combo = self.create_widget(QComboBox, 'multi_images_gen_type') + combo.addItem("Generate all combinations", 0) + combo.addItem("Match images and texts", 1) + self.widgets['multi_images_gen_type_row_index'] = layout.rowCount() + layout.addRow("Multi-Image Mode:", combo) + + def _setup_adv_tab_loras(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Loras") + layout = QVBoxLayout(tab) + layout.addWidget(QLabel("Available Loras (Ctrl+Click to select multiple):")) + lora_list = self.create_widget(QListWidget, 'activated_loras') + lora_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + layout.addWidget(lora_list) + layout.addWidget(QLabel("Loras Multipliers:")) + layout.addWidget(self.create_widget(QTextEdit, 'loras_multipliers')) + + def _setup_adv_tab_speed(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Speed") + layout = QFormLayout(tab) + combo = self.create_widget(QComboBox, 'skip_steps_cache_type') + combo.addItem("None", "") + combo.addItem("Tea Cache", "tea") + combo.addItem("Mag Cache", "mag") + layout.addRow("Cache Type:", combo) + + combo = self.create_widget(QComboBox, 'skip_steps_multiplier') + combo.addItem("x1.5 speed up", 1.5) + combo.addItem("x1.75 speed up", 1.75) + combo.addItem("x2.0 speed up", 2.0) + combo.addItem("x2.25 speed up", 2.25) + combo.addItem("x2.5 speed up", 2.5) + layout.addRow("Acceleration:", combo) + layout.addRow("Start %:", self._create_slider_with_label('skip_steps_start_step_perc', 0, 100, 0, 1.0, 0)) + + def _setup_adv_tab_postproc(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Post-Processing") + layout = QFormLayout(tab) + combo = self.create_widget(QComboBox, 'temporal_upsampling') + combo.addItem("Disabled", "") + combo.addItem("Rife x2 frames/s", "rife2") + combo.addItem("Rife x4 frames/s", "rife4") + layout.addRow("Temporal Upsampling:", combo) + + combo = self.create_widget(QComboBox, 'spatial_upsampling') + combo.addItem("Disabled", "") + combo.addItem("Lanczos x1.5", "lanczos1.5") + combo.addItem("Lanczos x2.0", "lanczos2") + layout.addRow("Spatial Upsampling:", combo) + + layout.addRow("Film Grain Intensity:", self._create_slider_with_label('film_grain_intensity', 0, 100, 0, 100.0, 2)) + layout.addRow("Film Grain Saturation:", self._create_slider_with_label('film_grain_saturation', 0, 100, 0.5, 100.0, 2)) + + def _setup_adv_tab_audio(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Audio") + layout = QFormLayout(tab) + combo = self.create_widget(QComboBox, 'MMAudio_setting') + combo.addItem("Disabled", 0) + combo.addItem("Enabled", 1) + layout.addRow("MMAudio:", combo) + layout.addWidget(self.create_widget(QLineEdit, 'MMAudio_prompt', placeholderText="MMAudio Prompt")) + layout.addWidget(self.create_widget(QLineEdit, 'MMAudio_neg_prompt', placeholderText="MMAudio Negative Prompt")) + layout.addRow(self._create_file_input('audio_source', "Custom Soundtrack")) + + def _setup_adv_tab_quality(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Quality") + layout = QVBoxLayout(tab) + + slg_group = self.create_widget(QGroupBox, 'slg_group', "Skip Layer Guidance") + slg_layout = QFormLayout(slg_group) + slg_combo = self.create_widget(QComboBox, 'slg_switch') + slg_combo.addItem("OFF", 0) + slg_combo.addItem("ON", 1) + slg_layout.addRow("Enable SLG:", slg_combo) + slg_layout.addRow("Start %:", self._create_slider_with_label('slg_start_perc', 0, 100, 10, 1.0, 0)) + slg_layout.addRow("End %:", self._create_slider_with_label('slg_end_perc', 0, 100, 90, 1.0, 0)) + layout.addWidget(slg_group) + + quality_form = QFormLayout() + self.widgets['quality_form_layout'] = quality_form + + apg_combo = self.create_widget(QComboBox, 'apg_switch') + apg_combo.addItem("OFF", 0) + apg_combo.addItem("ON", 1) + self.widgets['apg_switch_row_index'] = quality_form.rowCount() + quality_form.addRow("Adaptive Projected Guidance:", apg_combo) + + cfg_star_combo = self.create_widget(QComboBox, 'cfg_star_switch') + cfg_star_combo.addItem("OFF", 0) + cfg_star_combo.addItem("ON", 1) + self.widgets['cfg_star_switch_row_index'] = quality_form.rowCount() + quality_form.addRow("Classifier-Free Guidance Star:", cfg_star_combo) + + self.widgets['cfg_zero_step_row_index'] = quality_form.rowCount() + quality_form.addRow("CFG Zero below Layer:", self._create_slider_with_label('cfg_zero_step', -1, 39, -1, 1.0, 0)) + + combo = self.create_widget(QComboBox, 'min_frames_if_references') + combo.addItem("Disabled (1 frame)", 1) + combo.addItem("Generate 5 frames", 5) + combo.addItem("Generate 9 frames", 9) + combo.addItem("Generate 13 frames", 13) + combo.addItem("Generate 17 frames", 17) + self.widgets['min_frames_if_references_row_index'] = quality_form.rowCount() + quality_form.addRow("Min Frames for Quality:", combo) + layout.addLayout(quality_form) + + def _setup_adv_tab_sliding_window(self, tabs): + tab = QWidget() + self.widgets['sliding_window_tab_index'] = tabs.count() + tabs.addTab(tab, "Sliding Window") + layout = QFormLayout(tab) + + layout.addRow("Window Size:", self._create_slider_with_label('sliding_window_size', 5, 257, 129, 1.0, 0)) + layout.addRow("Overlap:", self._create_slider_with_label('sliding_window_overlap', 1, 97, 5, 1.0, 0)) + layout.addRow("Color Correction:", self._create_slider_with_label('sliding_window_color_correction_strength', 0, 100, 0, 100.0, 2)) + layout.addRow("Overlap Noise:", self._create_slider_with_label('sliding_window_overlap_noise', 0, 150, 20, 1.0, 0)) + layout.addRow("Discard Last Frames:", self._create_slider_with_label('sliding_window_discard_last_frames', 0, 20, 0, 1.0, 0)) + + def _setup_adv_tab_misc(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Misc") + layout = QFormLayout(tab) + self.widgets['misc_layout'] = layout + + riflex_combo = self.create_widget(QComboBox, 'RIFLEx_setting') + riflex_combo.addItem("Auto", 0) + riflex_combo.addItem("Always ON", 1) + riflex_combo.addItem("Always OFF", 2) + self.widgets['riflex_row_index'] = layout.rowCount() + layout.addRow("RIFLEx Setting:", riflex_combo) + + fps_combo = self.create_widget(QComboBox, 'force_fps') + layout.addRow("Force FPS:", fps_combo) + + profile_combo = self.create_widget(QComboBox, 'override_profile') + profile_combo.addItem("Default Profile", -1) + for text, val in wgp.memory_profile_choices: + profile_combo.addItem(text.split(':')[0], val) + layout.addRow("Override Memory Profile:", profile_combo) + + combo = self.create_widget(QComboBox, 'multi_prompts_gen_type') + combo.addItem("Generate new Video per line", 0) + combo.addItem("Use line for new Sliding Window", 1) + layout.addRow("Multi-Prompt Mode:", combo) + + def setup_config_tab(self): + config_tab = QWidget() + self.tabs.addTab(config_tab, "Configuration") + main_layout = QVBoxLayout(config_tab) + + self.config_status_label = QLabel("Apply changes for them to take effect. Some may require a restart.") + main_layout.addWidget(self.config_status_label) + + config_tabs = QTabWidget() + main_layout.addWidget(config_tabs) + + config_tabs.addTab(self._create_general_config_tab(), "General") + config_tabs.addTab(self._create_performance_config_tab(), "Performance") + config_tabs.addTab(self._create_extensions_config_tab(), "Extensions") + config_tabs.addTab(self._create_outputs_config_tab(), "Outputs") + config_tabs.addTab(self._create_notifications_config_tab(), "Notifications") + + self.apply_config_btn = QPushButton("Apply Changes") + self.apply_config_btn.clicked.connect(self._on_apply_config_changes) + main_layout.addWidget(self.apply_config_btn) + + def _create_scrollable_form_tab(self): + tab_widget = QWidget() + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + layout = QVBoxLayout(tab_widget) + layout.addWidget(scroll_area) + + content_widget = QWidget() + form_layout = QFormLayout(content_widget) + scroll_area.setWidget(content_widget) + + return tab_widget, form_layout + + def _create_config_combo(self, form_layout, label, key, choices, default_value): + combo = QComboBox() + for text, data in choices: + combo.addItem(text, data) + index = combo.findData(wgp.server_config.get(key, default_value)) + if index != -1: combo.setCurrentIndex(index) + self.widgets[f'config_{key}'] = combo + form_layout.addRow(label, combo) + + def _create_config_slider(self, form_layout, label, key, min_val, max_val, default_value, step=1): + container = QWidget() + hbox = QHBoxLayout(container) + hbox.setContentsMargins(0,0,0,0) + + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(min_val, max_val) + slider.setSingleStep(step) + slider.setValue(wgp.server_config.get(key, default_value)) + + value_label = QLabel(str(slider.value())) + value_label.setMinimumWidth(40) + slider.valueChanged.connect(lambda v, lbl=value_label: lbl.setText(str(v))) + + hbox.addWidget(slider) + hbox.addWidget(value_label) + + self.widgets[f'config_{key}'] = slider + form_layout.addRow(label, container) + + def _create_config_checklist(self, form_layout, label, key, choices, default_value): + list_widget = QListWidget() + list_widget.setMinimumHeight(100) + current_values = wgp.server_config.get(key, default_value) + for text, data in choices: + item = QListWidgetItem(text) + item.setData(Qt.ItemDataRole.UserRole, data) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if data in current_values: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + list_widget.addItem(item) + self.widgets[f'config_{key}'] = list_widget + form_layout.addRow(label, list_widget) + + def _create_config_textbox(self, form_layout, label, key, default_value, multi_line=False): + if multi_line: + textbox = QTextEdit(default_value) + textbox.setAcceptRichText(False) + else: + textbox = QLineEdit(default_value) + self.widgets[f'config_{key}'] = textbox + form_layout.addRow(label, textbox) + + def _create_general_config_tab(self): + tab, form = self._create_scrollable_form_tab() + + _, _, dropdown_choices = wgp.get_sorted_dropdown(wgp.displayed_model_types, None, None, False) + self._create_config_checklist(form, "Selectable Models:", "transformer_types", dropdown_choices, wgp.transformer_types) + + self._create_config_combo(form, "Model Hierarchy:", "model_hierarchy_type", [ + ("Two Levels (Family > Model)", 0), + ("Three Levels (Family > Base > Finetune)", 1) + ], 1) + + self._create_config_combo(form, "Video Dimensions:", "fit_canvas", [ + ("Dimensions are Pixels Budget", 0), ("Dimensions are Max Width/Height", 1), + ("Dimensions are Output Width/Height (Cropped)", 2)], 0) + + self._create_config_combo(form, "Attention Type:", "attention_mode", [ + ("Auto (Recommended)", "auto"), ("SDPA", "sdpa"), ("Flash", "flash"), + ("Xformers", "xformers"), ("Sage", "sage"), ("Sage2/2++", "sage2")], "auto") + + self._create_config_combo(form, "Metadata Handling:", "metadata_type", [ + ("Embed in file (Exif/Comment)", "metadata"), ("Export separate JSON", "json"), ("None", "none")], "metadata") + + self._create_config_checklist(form, "RAM Loading Policy:", "preload_model_policy", [ + ("Preload on App Launch", "P"), ("Preload on Model Switch", "S"), ("Unload when Queue is Done", "U")], []) + + self._create_config_combo(form, "Keep Previous Videos:", "clear_file_list", [ + ("None", 0), ("Keep last video", 1), ("Keep last 5", 5), ("Keep last 10", 10), + ("Keep last 20", 20), ("Keep last 30", 30)], 5) + + self._create_config_combo(form, "Display RAM/VRAM Stats:", "display_stats", [("Disabled", 0), ("Enabled", 1)], 0) + + self._create_config_combo(form, "Max Frames Multiplier:", "max_frames_multiplier", + [(f"x{i}", i) for i in range(1, 8)], 1) + + checkpoints_paths_text = "\n".join(wgp.server_config.get("checkpoints_paths", wgp.fl.default_checkpoints_paths)) + checkpoints_textbox = QTextEdit() + checkpoints_textbox.setPlainText(checkpoints_paths_text) + checkpoints_textbox.setAcceptRichText(False) + checkpoints_textbox.setMinimumHeight(60) + self.widgets['config_checkpoints_paths'] = checkpoints_textbox + form.addRow("Checkpoints Paths:", checkpoints_textbox) + + self._create_config_combo(form, "UI Theme (requires restart):", "UI_theme", [("Blue Sky", "default"), ("Classic Gradio", "gradio")], "default") + + return tab + + def _create_performance_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Transformer Quantization:", "transformer_quantization", [("Scaled Int8 (recommended)", "int8"), ("16-bit (no quantization)", "bf16")], "int8") + self._create_config_combo(form, "Transformer Data Type:", "transformer_dtype_policy", [("Best Supported by Hardware", ""), ("FP16", "fp16"), ("BF16", "bf16")], "") + self._create_config_combo(form, "Transformer Calculation:", "mixed_precision", [("16-bit only", "0"), ("Mixed 16/32-bit (better quality)", "1")], "0") + self._create_config_combo(form, "Text Encoder:", "text_encoder_quantization", [("16-bit (more RAM, better quality)", "bf16"), ("8-bit (less RAM)", "int8")], "int8") + self._create_config_combo(form, "VAE Precision:", "vae_precision", [("16-bit (faster, less VRAM)", "16"), ("32-bit (slower, better quality)", "32")], "16") + self._create_config_combo(form, "Compile Transformer:", "compile", [("On (requires Triton)", "transformer"), ("Off", "")], "") + self._create_config_combo(form, "DepthAnything v2 Variant:", "depth_anything_v2_variant", [("Large (more precise)", "vitl"), ("Big (faster)", "vitb")], "vitl") + self._create_config_combo(form, "VAE Tiling:", "vae_config", [("Auto", 0), ("Disabled", 1), ("256x256 (~8GB VRAM)", 2), ("128x128 (~6GB VRAM)", 3)], 0) + self._create_config_combo(form, "Boost:", "boost", [("On", 1), ("Off", 2)], 1) + self._create_config_combo(form, "Memory Profile:", "profile", wgp.memory_profile_choices, wgp.profile_type.LowRAM_LowVRAM) + self._create_config_slider(form, "Preload in VRAM (MB):", "preload_in_VRAM", 0, 40000, 0, 100) + + release_ram_btn = QPushButton("Force Release Models from RAM") + release_ram_btn.clicked.connect(self._on_release_ram) + form.addRow(release_ram_btn) + + return tab + + def _create_extensions_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Prompt Enhancer:", "enhancer_enabled", [("Off", 0), ("Florence 2 + Llama 3.2", 1), ("Florence 2 + Joy Caption (uncensored)", 2)], 0) + self._create_config_combo(form, "Enhancer Mode:", "enhancer_mode", [("Automatic on Generate", 0), ("On Demand Only", 1)], 0) + self._create_config_combo(form, "MMAudio:", "mmaudio_enabled", [("Off", 0), ("Enabled (unloaded after use)", 1), ("Enabled (persistent in RAM)", 2)], 0) + return tab + + def _create_outputs_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Video Codec:", "video_output_codec", [("x265 Balanced", 'libx265_28'), ("x264 Balanced", 'libx264_8'), ("x265 High Quality", 'libx265_8'), ("x264 High Quality", 'libx264_10'), ("x264 Lossless", 'libx264_lossless')], 'libx264_8') + self._create_config_combo(form, "Image Codec:", "image_output_codec", [("JPEG Q85", 'jpeg_85'), ("WEBP Q85", 'webp_85'), ("JPEG Q95", 'jpeg_95'), ("WEBP Q95", 'webp_95'), ("WEBP Lossless", 'webp_lossless'), ("PNG Lossless", 'png')], 'jpeg_95') + self._create_config_textbox(form, "Video Output Folder:", "save_path", "outputs") + self._create_config_textbox(form, "Image Output Folder:", "image_save_path", "outputs") + return tab + + def _create_notifications_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Notification Sound:", "notification_sound_enabled", [("On", 1), ("Off", 0)], 0) + self._create_config_slider(form, "Sound Volume:", "notification_sound_volume", 0, 100, 50, 5) + return tab + + def init_wgp_state(self): + initial_model = wgp.server_config.get("last_model_type", wgp.transformer_type) + all_models, _, _ = wgp.get_sorted_dropdown(wgp.displayed_model_types, None, None, False) + all_model_ids = [m[1] for m in all_models] + if initial_model not in all_model_ids: + initial_model = wgp.transformer_type + + state_dict = {} + state_dict["model_filename"] = wgp.get_model_filename(initial_model, wgp.transformer_quantization, wgp.transformer_dtype_policy) + state_dict["model_type"] = initial_model + state_dict["advanced"] = wgp.advanced + state_dict["last_model_per_family"] = wgp.server_config.get("last_model_per_family", {}) + state_dict["last_model_per_type"] = wgp.server_config.get("last_model_per_type", {}) + state_dict["last_resolution_per_group"] = wgp.server_config.get("last_resolution_per_group", {}) + state_dict["gen"] = {"queue": []} + + self.state = state_dict + self.advanced_group.setChecked(wgp.advanced) + + self.update_model_dropdowns(initial_model) + self.refresh_ui_from_model_change(initial_model) + self._update_input_visibility() # Set initial visibility + + def update_model_dropdowns(self, current_model_type): + family_mock, base_type_mock, choice_mock = wgp.generate_dropdown_model_list(current_model_type) + + self.widgets['model_family'].blockSignals(True) + self.widgets['model_base_type_choice'].blockSignals(True) + self.widgets['model_choice'].blockSignals(True) + + self.widgets['model_family'].clear() + if family_mock.choices: + for display_name, internal_key in family_mock.choices: + self.widgets['model_family'].addItem(display_name, internal_key) + index = self.widgets['model_family'].findData(family_mock.value) + if index != -1: self.widgets['model_family'].setCurrentIndex(index) + + self.widgets['model_base_type_choice'].clear() + if base_type_mock.choices: + for label, value in base_type_mock.choices: + self.widgets['model_base_type_choice'].addItem(label, value) + index = self.widgets['model_base_type_choice'].findData(base_type_mock.value) + if index != -1: self.widgets['model_base_type_choice'].setCurrentIndex(index) + self.widgets['model_base_type_choice'].setVisible(base_type_mock.kwargs.get('visible', True)) + + self.widgets['model_choice'].clear() + if choice_mock.choices: + for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) + index = self.widgets['model_choice'].findData(choice_mock.value) + if index != -1: self.widgets['model_choice'].setCurrentIndex(index) + self.widgets['model_choice'].setVisible(choice_mock.kwargs.get('visible', True)) + + self.widgets['model_family'].blockSignals(False) + self.widgets['model_base_type_choice'].blockSignals(False) + self.widgets['model_choice'].blockSignals(False) + + def refresh_ui_from_model_change(self, model_type): + """Update UI controls with default settings when the model is changed.""" + self.header_info.setText(wgp.generate_header(model_type, wgp.compile, wgp.attention_mode)) + ui_defaults = wgp.get_default_settings(model_type) + wgp.set_model_settings(self.state, model_type, ui_defaults) + + model_def = wgp.get_model_def(model_type) + base_model_type = wgp.get_base_model_type(model_type) + model_filename = self.state.get('model_filename', '') + + image_outputs = model_def.get("image_outputs", False) + vace = wgp.test_vace_module(model_type) + t2v = base_model_type in ['t2v', 't2v_2_2'] + i2v = wgp.test_class_i2v(model_type) + fantasy = base_model_type in ["fantasy"] + multitalk = model_def.get("multitalk_class", False) + any_audio_guidance = fantasy or multitalk + sliding_window_enabled = wgp.test_any_sliding_window(model_type) + recammaster = base_model_type in ["recam_1.3B"] + ltxv = "ltxv" in model_filename + diffusion_forcing = "diffusion_forcing" in model_filename + any_skip_layer_guidance = model_def.get("skip_layer_guidance", False) + any_cfg_zero = model_def.get("cfg_zero", False) + any_cfg_star = model_def.get("cfg_star", False) + any_apg = model_def.get("adaptive_projected_guidance", False) + v2i_switch_supported = model_def.get("v2i_switch_supported", False) + + self._update_generation_mode_visibility(model_def) + + for widget in self.widgets.values(): + if hasattr(widget, 'blockSignals'): widget.blockSignals(True) + + self.widgets['prompt'].setText(ui_defaults.get("prompt", "")) + self.widgets['negative_prompt'].setText(ui_defaults.get("negative_prompt", "")) + self.widgets['seed'].setText(str(ui_defaults.get("seed", -1))) + + video_length_val = ui_defaults.get("video_length", 81) + self.widgets['video_length'].setValue(video_length_val) + self.widgets['video_length_label'].setText(str(video_length_val)) + + steps_val = ui_defaults.get("num_inference_steps", 30) + self.widgets['num_inference_steps'].setValue(steps_val) + self.widgets['num_inference_steps_label'].setText(str(steps_val)) + + self.widgets['resolution_group'].blockSignals(True) + self.widgets['resolution'].blockSignals(True) + + current_res_choice = ui_defaults.get("resolution") + model_resolutions = model_def.get("resolutions", None) + self.full_resolution_choices, current_res_choice = wgp.get_resolution_choices(current_res_choice, model_resolutions) + available_groups, selected_group_resolutions, selected_group = wgp.group_resolutions(model_def, self.full_resolution_choices, current_res_choice) + + self.widgets['resolution_group'].clear() + self.widgets['resolution_group'].addItems(available_groups) + group_index = self.widgets['resolution_group'].findText(selected_group) + if group_index != -1: + self.widgets['resolution_group'].setCurrentIndex(group_index) + + self.widgets['resolution'].clear() + for label, value in selected_group_resolutions: + self.widgets['resolution'].addItem(label, value) + res_index = self.widgets['resolution'].findData(current_res_choice) + if res_index != -1: + self.widgets['resolution'].setCurrentIndex(res_index) + + self.widgets['resolution_group'].blockSignals(False) + self.widgets['resolution'].blockSignals(False) + + + for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'audio_source']: + if name in self.widgets: + self.widgets[name].clear() + + guidance_layout = self.widgets['guidance_layout'] + guidance_max = model_def.get("guidance_max_phases", 1) + guidance_layout.setRowVisible(self.widgets['guidance_phases_row_index'], guidance_max > 1) + + adv_general_layout = self.widgets['adv_general_layout'] + adv_general_layout.setRowVisible(self.widgets['flow_shift_row_index'], not image_outputs) + adv_general_layout.setRowVisible(self.widgets['audio_guidance_row_index'], any_audio_guidance) + adv_general_layout.setRowVisible(self.widgets['repeat_generation_row_index'], not image_outputs) + adv_general_layout.setRowVisible(self.widgets['multi_images_gen_type_row_index'], i2v) + + self.widgets['slg_group'].setVisible(any_skip_layer_guidance) + quality_form_layout = self.widgets['quality_form_layout'] + quality_form_layout.setRowVisible(self.widgets['apg_switch_row_index'], any_apg) + quality_form_layout.setRowVisible(self.widgets['cfg_star_switch_row_index'], any_cfg_star) + quality_form_layout.setRowVisible(self.widgets['cfg_zero_step_row_index'], any_cfg_zero) + quality_form_layout.setRowVisible(self.widgets['min_frames_if_references_row_index'], v2i_switch_supported and image_outputs) + + self.widgets['advanced_tabs'].setTabVisible(self.widgets['sliding_window_tab_index'], sliding_window_enabled and not image_outputs) + + misc_layout = self.widgets['misc_layout'] + misc_layout.setRowVisible(self.widgets['riflex_row_index'], not (recammaster or ltxv or diffusion_forcing)) + + + index = self.widgets['multi_images_gen_type'].findData(ui_defaults.get('multi_images_gen_type', 0)) + if index != -1: self.widgets['multi_images_gen_type'].setCurrentIndex(index) + + guidance_val = ui_defaults.get("guidance_scale", 5.0) + self.widgets['guidance_scale'].setValue(int(guidance_val * 10)) + self.widgets['guidance_scale_label'].setText(f"{guidance_val:.1f}") + + guidance2_val = ui_defaults.get("guidance2_scale", 5.0) + self.widgets['guidance2_scale'].setValue(int(guidance2_val * 10)) + self.widgets['guidance2_scale_label'].setText(f"{guidance2_val:.1f}") + + guidance3_val = ui_defaults.get("guidance3_scale", 5.0) + self.widgets['guidance3_scale'].setValue(int(guidance3_val * 10)) + self.widgets['guidance3_scale_label'].setText(f"{guidance3_val:.1f}") + self.widgets['guidance_phases'].clear() + + if guidance_max >= 1: self.widgets['guidance_phases'].addItem("One Phase", 1) + if guidance_max >= 2: self.widgets['guidance_phases'].addItem("Two Phases", 2) + if guidance_max >= 3: self.widgets['guidance_phases'].addItem("Three Phases", 3) + + index = self.widgets['guidance_phases'].findData(ui_defaults.get("guidance_phases", 1)) + if index != -1: self.widgets['guidance_phases'].setCurrentIndex(index) + + switch_thresh_val = ui_defaults.get("switch_threshold", 0) + self.widgets['switch_threshold'].setValue(switch_thresh_val) + self.widgets['switch_threshold_label'].setText(str(switch_thresh_val)) + + nag_scale_val = ui_defaults.get('NAG_scale', 1.0) + self.widgets['NAG_scale'].setValue(int(nag_scale_val * 10)) + self.widgets['NAG_scale_label'].setText(f"{nag_scale_val:.1f}") + + nag_tau_val = ui_defaults.get('NAG_tau', 3.5) + self.widgets['NAG_tau'].setValue(int(nag_tau_val * 10)) + self.widgets['NAG_tau_label'].setText(f"{nag_tau_val:.1f}") + + nag_alpha_val = ui_defaults.get('NAG_alpha', 0.5) + self.widgets['NAG_alpha'].setValue(int(nag_alpha_val * 10)) + self.widgets['NAG_alpha_label'].setText(f"{nag_alpha_val:.1f}") + + self.widgets['nag_group'].setVisible(vace or t2v or i2v) + + self.widgets['sample_solver'].clear() + sampler_choices = model_def.get("sample_solvers", []) + self.widgets['solver_row_container'].setVisible(bool(sampler_choices)) + if sampler_choices: + for label, value in sampler_choices: self.widgets['sample_solver'].addItem(label, value) + solver_val = ui_defaults.get('sample_solver', sampler_choices[0][1]) + index = self.widgets['sample_solver'].findData(solver_val) + if index != -1: self.widgets['sample_solver'].setCurrentIndex(index) + + flow_val = ui_defaults.get("flow_shift", 3.0) + self.widgets['flow_shift'].setValue(int(flow_val * 10)) + self.widgets['flow_shift_label'].setText(f"{flow_val:.1f}") + + audio_guidance_val = ui_defaults.get("audio_guidance_scale", 4.0) + self.widgets['audio_guidance_scale'].setValue(int(audio_guidance_val * 10)) + self.widgets['audio_guidance_scale_label'].setText(f"{audio_guidance_val:.1f}") + + repeat_val = ui_defaults.get("repeat_generation", 1) + self.widgets['repeat_generation'].setValue(repeat_val) + self.widgets['repeat_generation_label'].setText(str(repeat_val)) + + available_loras, _, _, _, _, _ = wgp.setup_loras(model_type, None, wgp.get_lora_dir(model_type), "") + self.state['loras'] = available_loras + self.lora_map = {os.path.basename(p): p for p in available_loras} + lora_list_widget = self.widgets['activated_loras'] + lora_list_widget.clear() + lora_list_widget.addItems(sorted(self.lora_map.keys())) + selected_loras = ui_defaults.get('activated_loras', []) + for i in range(lora_list_widget.count()): + item = lora_list_widget.item(i) + is_selected = any(item.text() == os.path.basename(p) for p in selected_loras) + if is_selected: + item.setSelected(True) + self.widgets['loras_multipliers'].setText(ui_defaults.get('loras_multipliers', '')) + + skip_cache_val = ui_defaults.get('skip_steps_cache_type', "") + index = self.widgets['skip_steps_cache_type'].findData(skip_cache_val) + if index != -1: self.widgets['skip_steps_cache_type'].setCurrentIndex(index) + + skip_mult = ui_defaults.get('skip_steps_multiplier', 1.5) + index = self.widgets['skip_steps_multiplier'].findData(skip_mult) + if index != -1: self.widgets['skip_steps_multiplier'].setCurrentIndex(index) + + skip_perc_val = ui_defaults.get('skip_steps_start_step_perc', 0) + self.widgets['skip_steps_start_step_perc'].setValue(skip_perc_val) + self.widgets['skip_steps_start_step_perc_label'].setText(str(skip_perc_val)) + + temp_up_val = ui_defaults.get('temporal_upsampling', "") + index = self.widgets['temporal_upsampling'].findData(temp_up_val) + if index != -1: self.widgets['temporal_upsampling'].setCurrentIndex(index) + + spat_up_val = ui_defaults.get('spatial_upsampling', "") + index = self.widgets['spatial_upsampling'].findData(spat_up_val) + if index != -1: self.widgets['spatial_upsampling'].setCurrentIndex(index) + + film_grain_i = ui_defaults.get('film_grain_intensity', 0) + self.widgets['film_grain_intensity'].setValue(int(film_grain_i * 100)) + self.widgets['film_grain_intensity_label'].setText(f"{film_grain_i:.2f}") + + film_grain_s = ui_defaults.get('film_grain_saturation', 0.5) + self.widgets['film_grain_saturation'].setValue(int(film_grain_s * 100)) + self.widgets['film_grain_saturation_label'].setText(f"{film_grain_s:.2f}") + + self.widgets['MMAudio_setting'].setCurrentIndex(ui_defaults.get('MMAudio_setting', 0)) + self.widgets['MMAudio_prompt'].setText(ui_defaults.get('MMAudio_prompt', '')) + self.widgets['MMAudio_neg_prompt'].setText(ui_defaults.get('MMAudio_neg_prompt', '')) + + self.widgets['slg_switch'].setCurrentIndex(ui_defaults.get('slg_switch', 0)) + slg_start_val = ui_defaults.get('slg_start_perc', 10) + self.widgets['slg_start_perc'].setValue(slg_start_val) + self.widgets['slg_start_perc_label'].setText(str(slg_start_val)) + slg_end_val = ui_defaults.get('slg_end_perc', 90) + self.widgets['slg_end_perc'].setValue(slg_end_val) + self.widgets['slg_end_perc_label'].setText(str(slg_end_val)) + + self.widgets['apg_switch'].setCurrentIndex(ui_defaults.get('apg_switch', 0)) + self.widgets['cfg_star_switch'].setCurrentIndex(ui_defaults.get('cfg_star_switch', 0)) + + cfg_zero_val = ui_defaults.get('cfg_zero_step', -1) + self.widgets['cfg_zero_step'].setValue(cfg_zero_val) + self.widgets['cfg_zero_step_label'].setText(str(cfg_zero_val)) + + min_frames_val = ui_defaults.get('min_frames_if_references', 1) + index = self.widgets['min_frames_if_references'].findData(min_frames_val) + if index != -1: self.widgets['min_frames_if_references'].setCurrentIndex(index) + + self.widgets['RIFLEx_setting'].setCurrentIndex(ui_defaults.get('RIFLEx_setting', 0)) + + fps = wgp.get_model_fps(model_type) + force_fps_choices = [ + (f"Model Default ({fps} fps)", ""), ("Auto", "auto"), ("Control Video fps", "control"), + ("Source Video fps", "source"), ("15", "15"), ("16", "16"), ("23", "23"), + ("24", "24"), ("25", "25"), ("30", "30") + ] + self.widgets['force_fps'].clear() + for label, value in force_fps_choices: self.widgets['force_fps'].addItem(label, value) + force_fps_val = ui_defaults.get('force_fps', "") + index = self.widgets['force_fps'].findData(force_fps_val) + if index != -1: self.widgets['force_fps'].setCurrentIndex(index) + + override_prof_val = ui_defaults.get('override_profile', -1) + index = self.widgets['override_profile'].findData(override_prof_val) + if index != -1: self.widgets['override_profile'].setCurrentIndex(index) + + self.widgets['multi_prompts_gen_type'].setCurrentIndex(ui_defaults.get('multi_prompts_gen_type', 0)) + + denoising_val = ui_defaults.get("denoising_strength", 0.5) + self.widgets['denoising_strength'].setValue(int(denoising_val * 100)) + self.widgets['denoising_strength_label'].setText(f"{denoising_val:.2f}") + + sw_size = ui_defaults.get("sliding_window_size", 129) + self.widgets['sliding_window_size'].setValue(sw_size) + self.widgets['sliding_window_size_label'].setText(str(sw_size)) + + sw_overlap = ui_defaults.get("sliding_window_overlap", 5) + self.widgets['sliding_window_overlap'].setValue(sw_overlap) + self.widgets['sliding_window_overlap_label'].setText(str(sw_overlap)) + + sw_color = ui_defaults.get("sliding_window_color_correction_strength", 0) + self.widgets['sliding_window_color_correction_strength'].setValue(int(sw_color * 100)) + self.widgets['sliding_window_color_correction_strength_label'].setText(f"{sw_color:.2f}") + + sw_noise = ui_defaults.get("sliding_window_overlap_noise", 20) + self.widgets['sliding_window_overlap_noise'].setValue(sw_noise) + self.widgets['sliding_window_overlap_noise_label'].setText(str(sw_noise)) + + sw_discard = ui_defaults.get("sliding_window_discard_last_frames", 0) + self.widgets['sliding_window_discard_last_frames'].setValue(sw_discard) + self.widgets['sliding_window_discard_last_frames_label'].setText(str(sw_discard)) + + for widget in self.widgets.values(): + if hasattr(widget, 'blockSignals'): widget.blockSignals(False) + self._update_dynamic_ui() + self._update_input_visibility() + + def _update_dynamic_ui(self): + """Update UI visibility based on current selections.""" + phases = self.widgets['guidance_phases'].currentData() or 1 + guidance_layout = self.widgets['guidance_layout'] + guidance_layout.setRowVisible(self.widgets['guidance2_row_index'], phases >= 2) + guidance_layout.setRowVisible(self.widgets['guidance3_row_index'], phases >= 3) + guidance_layout.setRowVisible(self.widgets['switch_thresh_row_index'], phases >= 2) + + def _update_generation_mode_visibility(self, model_def): + """Shows/hides the main generation mode options based on the selected model.""" + allowed = model_def.get("image_prompt_types_allowed", "") + + choices = [] + if "T" in allowed or not allowed: + choices.append(("Text Prompt Only" if "S" in allowed else "New Video", "T")) + if "S" in allowed: + choices.append(("Start Video with Image", "S")) + if "V" in allowed: + choices.append(("Continue Video", "V")) + if "L" in allowed: + choices.append(("Continue Last Video", "L")) + + button_map = { + "T": self.widgets['mode_t'], + "S": self.widgets['mode_s'], + "V": self.widgets['mode_v'], + "L": self.widgets['mode_l'], + } + + for btn in button_map.values(): + btn.setVisible(False) + + allowed_values = [c[1] for c in choices] + for label, value in choices: + if value in button_map: + btn = button_map[value] + btn.setText(label) + btn.setVisible(True) + + current_checked_value = None + for value, btn in button_map.items(): + if btn.isChecked(): + current_checked_value = value + break + + # If the currently selected mode is now hidden, reset to a visible default + if current_checked_value is None or not button_map[current_checked_value].isVisible(): + if allowed_values: + button_map[allowed_values[0]].setChecked(True) + + + end_image_visible = "E" in allowed + self.widgets['image_end_checkbox'].setVisible(end_image_visible) + if not end_image_visible: + self.widgets['image_end_checkbox'].setChecked(False) + + # Control Video Checkbox (Based on model_def.get("guide_preprocessing")) + control_video_visible = model_def.get("guide_preprocessing") is not None + self.widgets['control_video_checkbox'].setVisible(control_video_visible) + if not control_video_visible: + self.widgets['control_video_checkbox'].setChecked(False) + + # Reference Image Checkbox (Based on model_def.get("image_ref_choices")) + ref_image_visible = model_def.get("image_ref_choices") is not None + self.widgets['ref_image_checkbox'].setVisible(ref_image_visible) + if not ref_image_visible: + self.widgets['ref_image_checkbox'].setChecked(False) + + + def _update_input_visibility(self): + """Shows/hides input fields based on the selected generation mode.""" + is_s_mode = self.widgets['mode_s'].isChecked() + is_v_mode = self.widgets['mode_v'].isChecked() + is_l_mode = self.widgets['mode_l'].isChecked() + + use_end = self.widgets['image_end_checkbox'].isChecked() and self.widgets['image_end_checkbox'].isVisible() + use_control = self.widgets['control_video_checkbox'].isChecked() and self.widgets['control_video_checkbox'].isVisible() + use_ref = self.widgets['ref_image_checkbox'].isChecked() and self.widgets['ref_image_checkbox'].isVisible() + + self.widgets['image_start_container'].setVisible(is_s_mode) + self.widgets['video_source_container'].setVisible(is_v_mode) + + end_checkbox_enabled = is_s_mode or is_v_mode or is_l_mode + self.widgets['image_end_checkbox'].setEnabled(end_checkbox_enabled) + self.widgets['image_end_container'].setVisible(use_end and end_checkbox_enabled) + + self.widgets['video_guide_container'].setVisible(use_control) + self.widgets['video_mask_container'].setVisible(use_control) + self.widgets['image_refs_container'].setVisible(use_ref) + + def connect_signals(self): + self.widgets['model_family'].currentIndexChanged.connect(self._on_family_changed) + self.widgets['model_base_type_choice'].currentIndexChanged.connect(self._on_base_type_changed) + self.widgets['model_choice'].currentIndexChanged.connect(self._on_model_changed) + self.widgets['resolution_group'].currentIndexChanged.connect(self._on_resolution_group_changed) + self.widgets['guidance_phases'].currentIndexChanged.connect(self._update_dynamic_ui) + + self.widgets['mode_t'].toggled.connect(self._update_input_visibility) + self.widgets['mode_s'].toggled.connect(self._update_input_visibility) + self.widgets['mode_v'].toggled.connect(self._update_input_visibility) + self.widgets['mode_l'].toggled.connect(self._update_input_visibility) + + self.widgets['image_end_checkbox'].toggled.connect(self._update_input_visibility) + self.widgets['control_video_checkbox'].toggled.connect(self._update_input_visibility) + self.widgets['ref_image_checkbox'].toggled.connect(self._update_input_visibility) + self.widgets['preview_group'].toggled.connect(self._on_preview_toggled) + + self.generate_btn.clicked.connect(self._on_generate) + self.add_to_queue_btn.clicked.connect(self._on_add_to_queue) + self.remove_queue_btn.clicked.connect(self._on_remove_selected_from_queue) + self.clear_queue_btn.clicked.connect(self._on_clear_queue) + self.abort_btn.clicked.connect(self._on_abort) + self.queue_table.rowsMoved.connect(self._on_queue_rows_moved) + + def apply_initial_config(self): + is_visible = main_config.get('preview_visible', True) + self.widgets['preview_group'].setChecked(is_visible) + self.widgets['preview_image'].setVisible(is_visible) + + def _on_preview_toggled(self, checked): + self.widgets['preview_image'].setVisible(checked) + main_config['preview_visible'] = checked + save_main_config() + + def _on_family_changed(self, index): + family = self.widgets['model_family'].currentData() + if not family or not self.state: return + + base_type_mock, choice_mock = wgp.change_model_family(self.state, family) + + self.widgets['model_base_type_choice'].blockSignals(True) + self.widgets['model_base_type_choice'].clear() + if base_type_mock.choices: + for label, value in base_type_mock.choices: + self.widgets['model_base_type_choice'].addItem(label, value) + index = self.widgets['model_base_type_choice'].findData(base_type_mock.value) + if index != -1: + self.widgets['model_base_type_choice'].setCurrentIndex(index) + self.widgets['model_base_type_choice'].setVisible(base_type_mock.kwargs.get('visible', True)) + self.widgets['model_base_type_choice'].blockSignals(False) + + self.widgets['model_choice'].blockSignals(True) + self.widgets['model_choice'].clear() + if choice_mock.choices: + for label, value in choice_mock.choices: + self.widgets['model_choice'].addItem(label, value) + index = self.widgets['model_choice'].findData(choice_mock.value) + if index != -1: + self.widgets['model_choice'].setCurrentIndex(index) + self.widgets['model_choice'].setVisible(choice_mock.kwargs.get('visible', True)) + self.widgets['model_choice'].blockSignals(False) + + self._on_model_changed() + + def _on_base_type_changed(self, index): + family = self.widgets['model_family'].currentData() + base_type = self.widgets['model_base_type_choice'].currentData() + if not family or not base_type or not self.state: return + + base_type_mock, choice_mock = wgp.change_model_base_types(self.state, family, base_type) + + self.widgets['model_choice'].blockSignals(True) + self.widgets['model_choice'].clear() + if choice_mock.choices: + for label, value in choice_mock.choices: + self.widgets['model_choice'].addItem(label, value) + index = self.widgets['model_choice'].findData(choice_mock.value) + if index != -1: + self.widgets['model_choice'].setCurrentIndex(index) + self.widgets['model_choice'].setVisible(choice_mock.kwargs.get('visible', True)) + self.widgets['model_choice'].blockSignals(False) + + self._on_model_changed() + + def _on_model_changed(self): + model_type = self.widgets['model_choice'].currentData() + if not model_type or model_type == self.state['model_type']: return + wgp.change_model(self.state, model_type) + self.refresh_ui_from_model_change(model_type) + + def _on_resolution_group_changed(self): + selected_group = self.widgets['resolution_group'].currentText() + if not selected_group or not hasattr(self, 'full_resolution_choices'): + return + + model_type = self.state['model_type'] + model_def = wgp.get_model_def(model_type) + model_resolutions = model_def.get("resolutions", None) + + group_resolution_choices = [] + if model_resolutions is None: + group_resolution_choices = [res for res in self.full_resolution_choices if wgp.categorize_resolution(res[1]) == selected_group] + else: + return + + last_resolution_per_group = self.state.get("last_resolution_per_group", {}) + last_resolution = last_resolution_per_group.get(selected_group, "") + + is_last_res_valid = any(last_resolution == res[1] for res in group_resolution_choices) + if not is_last_res_valid and group_resolution_choices: + last_resolution = group_resolution_choices[0][1] + + self.widgets['resolution'].blockSignals(True) + self.widgets['resolution'].clear() + for label, value in group_resolution_choices: + self.widgets['resolution'].addItem(label, value) + + index = self.widgets['resolution'].findData(last_resolution) + if index != -1: + self.widgets['resolution'].setCurrentIndex(index) + self.widgets['resolution'].blockSignals(False) + + def collect_inputs(self): + """Gather all settings from UI widgets into a dictionary.""" + # Start with all possible defaults. This dictionary will be modified and returned. + full_inputs = wgp.get_current_model_settings(self.state).copy() + + # Add dummy/default values for UI elements present in Gradio but not yet in PyQt. + # These are expected by the backend logic. + full_inputs['lset_name'] = "" + # The PyQt UI is focused on generating videos, so image_mode is 0. + full_inputs['image_mode'] = 0 + + # Defensively initialize keys that are accessed directly in wgp.py but may not + # be in the saved model settings or fully implemented in the UI yet. + # This prevents both KeyErrors and TypeErrors for missing arguments. + expected_keys = { + "audio_guide": None, "audio_guide2": None, "image_guide": None, + "image_mask": None, "speakers_locations": "", "frames_positions": "", + "keep_frames_video_guide": "", "keep_frames_video_source": "", + "video_guide_outpainting": "", "switch_threshold2": 0, + "model_switch_phase": 1, "batch_size": 1, + "control_net_weight_alt": 1.0, + "image_refs_relative_size": 50, + } + for key, default_value in expected_keys.items(): + if key not in full_inputs: + full_inputs[key] = default_value + + # Overwrite defaults with values from the PyQt UI widgets + full_inputs['prompt'] = self.widgets['prompt'].toPlainText() + full_inputs['negative_prompt'] = self.widgets['negative_prompt'].toPlainText() + full_inputs['resolution'] = self.widgets['resolution'].currentData() + full_inputs['video_length'] = self.widgets['video_length'].value() + full_inputs['num_inference_steps'] = self.widgets['num_inference_steps'].value() + full_inputs['seed'] = int(self.widgets['seed'].text()) + + # Build prompt_type strings based on mode selections + image_prompt_type = "" + video_prompt_type = "" + + if self.widgets['mode_s'].isChecked(): + image_prompt_type = 'S' + elif self.widgets['mode_v'].isChecked(): + image_prompt_type = 'V' + elif self.widgets['mode_l'].isChecked(): + image_prompt_type = 'L' + else: # mode_t is checked + image_prompt_type = '' + + if self.widgets['image_end_checkbox'].isVisible() and self.widgets['image_end_checkbox'].isChecked(): + image_prompt_type += 'E' + + if self.widgets['control_video_checkbox'].isVisible() and self.widgets['control_video_checkbox'].isChecked(): + video_prompt_type += 'V' # This 'V' is for Control Video (V2V) + if self.widgets['ref_image_checkbox'].isVisible() and self.widgets['ref_image_checkbox'].isChecked(): + video_prompt_type += 'I' # 'I' for Reference Image + + full_inputs['image_prompt_type'] = image_prompt_type + full_inputs['video_prompt_type'] = video_prompt_type + + # File Inputs + for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'audio_source']: + if name in self.widgets: + path = self.widgets[name].text() + full_inputs[name] = path if path else None + + paths = self.widgets['image_refs'].text().split(';') + full_inputs['image_refs'] = [p.strip() for p in paths if p.strip()] if paths and paths[0] else None + + full_inputs['denoising_strength'] = self.widgets['denoising_strength'].value() / 100.0 + + if self.advanced_group.isChecked(): + full_inputs['guidance_scale'] = self.widgets['guidance_scale'].value() / 10.0 + full_inputs['guidance_phases'] = self.widgets['guidance_phases'].currentData() + full_inputs['guidance2_scale'] = self.widgets['guidance2_scale'].value() / 10.0 + full_inputs['guidance3_scale'] = self.widgets['guidance3_scale'].value() / 10.0 + full_inputs['switch_threshold'] = self.widgets['switch_threshold'].value() + + full_inputs['NAG_scale'] = self.widgets['NAG_scale'].value() / 10.0 + full_inputs['NAG_tau'] = self.widgets['NAG_tau'].value() / 10.0 + full_inputs['NAG_alpha'] = self.widgets['NAG_alpha'].value() / 10.0 + + full_inputs['sample_solver'] = self.widgets['sample_solver'].currentData() + full_inputs['flow_shift'] = self.widgets['flow_shift'].value() / 10.0 + full_inputs['audio_guidance_scale'] = self.widgets['audio_guidance_scale'].value() / 10.0 + full_inputs['repeat_generation'] = self.widgets['repeat_generation'].value() + full_inputs['multi_images_gen_type'] = self.widgets['multi_images_gen_type'].currentData() + + lora_list_widget = self.widgets['activated_loras'] + selected_items = lora_list_widget.selectedItems() + full_inputs['activated_loras'] = [self.lora_map[item.text()] for item in selected_items if item.text() in self.lora_map] + full_inputs['loras_multipliers'] = self.widgets['loras_multipliers'].toPlainText() + + full_inputs['skip_steps_cache_type'] = self.widgets['skip_steps_cache_type'].currentData() + full_inputs['skip_steps_multiplier'] = self.widgets['skip_steps_multiplier'].currentData() + full_inputs['skip_steps_start_step_perc'] = self.widgets['skip_steps_start_step_perc'].value() + + full_inputs['temporal_upsampling'] = self.widgets['temporal_upsampling'].currentData() + full_inputs['spatial_upsampling'] = self.widgets['spatial_upsampling'].currentData() + full_inputs['film_grain_intensity'] = self.widgets['film_grain_intensity'].value() / 100.0 + full_inputs['film_grain_saturation'] = self.widgets['film_grain_saturation'].value() / 100.0 + + full_inputs['MMAudio_setting'] = self.widgets['MMAudio_setting'].currentData() + full_inputs['MMAudio_prompt'] = self.widgets['MMAudio_prompt'].text() + full_inputs['MMAudio_neg_prompt'] = self.widgets['MMAudio_neg_prompt'].text() + + full_inputs['RIFLEx_setting'] = self.widgets['RIFLEx_setting'].currentData() + full_inputs['force_fps'] = self.widgets['force_fps'].currentData() + full_inputs['override_profile'] = self.widgets['override_profile'].currentData() + full_inputs['multi_prompts_gen_type'] = self.widgets['multi_prompts_gen_type'].currentData() + + full_inputs['slg_switch'] = self.widgets['slg_switch'].currentData() + full_inputs['slg_start_perc'] = self.widgets['slg_start_perc'].value() + full_inputs['slg_end_perc'] = self.widgets['slg_end_perc'].value() + full_inputs['apg_switch'] = self.widgets['apg_switch'].currentData() + full_inputs['cfg_star_switch'] = self.widgets['cfg_star_switch'].currentData() + full_inputs['cfg_zero_step'] = self.widgets['cfg_zero_step'].value() + full_inputs['min_frames_if_references'] = self.widgets['min_frames_if_references'].currentData() + + full_inputs['sliding_window_size'] = self.widgets['sliding_window_size'].value() + full_inputs['sliding_window_overlap'] = self.widgets['sliding_window_overlap'].value() + full_inputs['sliding_window_color_correction_strength'] = self.widgets['sliding_window_color_correction_strength'].value() / 100.0 + full_inputs['sliding_window_overlap_noise'] = self.widgets['sliding_window_overlap_noise'].value() + full_inputs['sliding_window_discard_last_frames'] = self.widgets['sliding_window_discard_last_frames'].value() + + return full_inputs + + def _prepare_state_for_generation(self): + if 'gen' in self.state: + self.state['gen'].pop('abort', None) + self.state['gen'].pop('in_progress', None) + + def _on_generate(self): + try: + is_running = self.thread and self.thread.isRunning() + self._add_task_to_queue_and_update_ui() + if not is_running: + self.start_generation() + except Exception: + import traceback + traceback.print_exc() + + + def _on_add_to_queue(self): + try: + self._add_task_to_queue_and_update_ui() + except Exception: + import traceback + traceback.print_exc() + + def _add_task_to_queue_and_update_ui(self): + self._add_task_to_queue() + self.update_queue_table() + + def _add_task_to_queue(self): + queue_size_before = len(self.state["gen"]["queue"]) + all_inputs = self.collect_inputs() + keys_to_remove = ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality'] + for key in keys_to_remove: + all_inputs.pop(key, None) + + all_inputs['state'] = self.state + wgp.set_model_settings(self.state, self.state['model_type'], all_inputs) + + self.state["validate_success"] = 1 + wgp.process_prompt_and_add_tasks(self.state, self.state['model_type']) + + + def start_generation(self): + if not self.state['gen']['queue']: + return + self._prepare_state_for_generation() + self.generate_btn.setEnabled(False) + self.add_to_queue_btn.setEnabled(True) + + self.thread = QThread() + self.worker = Worker(self.state) + self.worker.moveToThread(self.thread) + + self.thread.started.connect(self.worker.run) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + self.thread.finished.connect(self.on_generation_finished) + + self.worker.status.connect(self.status_label.setText) + self.worker.progress.connect(self.update_progress) + self.worker.preview.connect(self.update_preview) + self.worker.output.connect(self.update_queue_and_gallery) + self.worker.error.connect(self.on_generation_error) + + self.thread.start() + self.update_queue_table() + + def on_generation_finished(self): + time.sleep(0.1) + self.status_label.setText("Finished.") + self.progress_bar.setValue(0) + self.generate_btn.setEnabled(True) + self.add_to_queue_btn.setEnabled(False) + self.thread = None + self.worker = None + self.update_queue_table() + + def on_generation_error(self, err_msg): + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Critical) + msg_box.setText("Generation Error") + msg_box.setInformativeText(str(err_msg)) + msg_box.setWindowTitle("Error") + msg_box.exec() + self.on_generation_finished() + + def update_progress(self, data): + if len(data) > 1 and isinstance(data[0], tuple): + step, total = data[0] + self.progress_bar.setMaximum(total) + self.progress_bar.setValue(step) + self.status_label.setText(str(data[1])) + if step <= 1: + self.update_queue_table() + elif len(data) > 1: + self.status_label.setText(str(data[1])) + + def update_preview(self, pil_image): + if pil_image: + q_image = ImageQt(pil_image) + pixmap = QPixmap.fromImage(q_image) + self.preview_image.setPixmap(pixmap.scaled( + self.preview_image.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + )) + + def update_queue_and_gallery(self): + self.update_queue_table() + + file_list = self.state.get('gen', {}).get('file_list', []) + self.output_gallery.clear() + self.output_gallery.addItems(file_list) + if file_list: + self.output_gallery.setCurrentRow(len(file_list) - 1) + self.latest_output_path = file_list[-1] # <-- ADDED for API access + + def update_queue_table(self): + with wgp.lock: + queue = self.state.get('gen', {}).get('queue', []) + is_running = self.thread and self.thread.isRunning() + queue_to_display = queue if is_running else [None] + queue + + table_data = wgp.get_queue_table(queue_to_display) + + self.queue_table.setRowCount(0) + self.queue_table.setRowCount(len(table_data)) + self.queue_table.setColumnCount(4) + self.queue_table.setHorizontalHeaderLabels(["Qty", "Prompt", "Length", "Steps"]) + + for row_idx, row_data in enumerate(table_data): + prompt_html = row_data[1] + try: + prompt_text = prompt_html.split('>')[1].split('<')[0] + except IndexError: + prompt_text = str(row_data[1]) + + self.queue_table.setItem(row_idx, 0, QTableWidgetItem(str(row_data[0]))) + self.queue_table.setItem(row_idx, 1, QTableWidgetItem(prompt_text)) + self.queue_table.setItem(row_idx, 2, QTableWidgetItem(str(row_data[2]))) + self.queue_table.setItem(row_idx, 3, QTableWidgetItem(str(row_data[3]))) + + self.queue_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.queue_table.resizeColumnsToContents() + + def _on_remove_selected_from_queue(self): + selected_row = self.queue_table.currentRow() + if selected_row < 0: + return + + with wgp.lock: + is_running = self.thread and self.thread.isRunning() + offset = 1 if is_running else 0 + + queue = self.state.get('gen', {}).get('queue', []) + if len(queue) > selected_row + offset: + queue.pop(selected_row + offset) + self.update_queue_table() + + def _on_queue_rows_moved(self, source_row, dest_row): + with wgp.lock: + queue = self.state.get('gen', {}).get('queue', []) + is_running = self.thread and self.thread.isRunning() + offset = 1 if is_running else 0 + + real_source_idx = source_row + offset + real_dest_idx = dest_row + offset + + moved_item = queue.pop(real_source_idx) + queue.insert(real_dest_idx, moved_item) + self.update_queue_table() + + def _on_clear_queue(self): + wgp.clear_queue_action(self.state) + self.update_queue_table() + + def _on_abort(self): + if self.worker: + wgp.abort_generation(self.state) + self.status_label.setText("Aborting...") + self.worker._is_running = False + + def _on_release_ram(self): + wgp.release_RAM() + QMessageBox.information(self, "RAM Released", "Models stored in RAM have been released.") + + def _on_apply_config_changes(self): + changes = {} + + list_widget = self.widgets['config_transformer_types'] + checked_items = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] + changes['transformer_types_choices'] = checked_items + + list_widget = self.widgets['config_preload_model_policy'] + checked_items = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] + changes['preload_model_policy_choice'] = checked_items + + changes['model_hierarchy_type_choice'] = self.widgets['config_model_hierarchy_type'].currentData() + changes['checkpoints_paths'] = self.widgets['config_checkpoints_paths'].toPlainText() + + for key in ["fit_canvas", "attention_mode", "metadata_type", "clear_file_list", "display_stats", "max_frames_multiplier", "UI_theme"]: + changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() + + for key in ["transformer_quantization", "transformer_dtype_policy", "mixed_precision", "text_encoder_quantization", "vae_precision", "compile", "depth_anything_v2_variant", "vae_config", "boost", "profile"]: + changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() + changes['preload_in_VRAM_choice'] = self.widgets['config_preload_in_VRAM'].value() + + for key in ["enhancer_enabled", "enhancer_mode", "mmaudio_enabled"]: + changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() + + for key in ["video_output_codec", "image_output_codec", "save_path", "image_save_path"]: + widget = self.widgets[f'config_{key}'] + changes[f'{key}_choice'] = widget.currentData() if isinstance(widget, QComboBox) else widget.text() + + changes['notification_sound_enabled_choice'] = self.widgets['config_notification_sound_enabled'].currentData() + changes['notification_sound_volume_choice'] = self.widgets['config_notification_sound_volume'].value() + + changes['last_resolution_choice'] = self.widgets['resolution'].currentData() + + try: + msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = wgp.apply_changes(self.state, **changes) + self.config_status_label.setText("Changes applied successfully. Some settings may require a restart.") + + self.header_info.setText(wgp.generate_header(self.state['model_type'], wgp.compile, wgp.attention_mode)) + + if family_mock.choices is not None or choice_mock.choices is not None: + self.update_model_dropdowns(wgp.transformer_type) + self.refresh_ui_from_model_change(wgp.transformer_type) + + except Exception as e: + self.config_status_label.setText(f"Error applying changes: {e}") + import traceback + traceback.print_exc() + + def closeEvent(self, event): + if wgp: + wgp.autosave_queue() + save_main_config() + event.accept() + +# ===================================================================== +# --- START OF API SERVER ADDITION --- +# ===================================================================== +try: + from flask import Flask, request, jsonify + FLASK_AVAILABLE = True +except ImportError: + FLASK_AVAILABLE = False + print("Flask not installed. API server will not be available. Please run: pip install Flask") + +# Global reference to the main window, to be populated after instantiation. +main_window_instance = None +api_server = Flask(__name__) if FLASK_AVAILABLE else None + +def run_api_server(): + """Function to run the Flask server.""" + if api_server: + print("Starting API server on http://127.0.0.1:5100") + api_server.run(port=5100, host='127.0.0.1', debug=False) + +if FLASK_AVAILABLE: + # --- CHANGE: Removed /api/set_model endpoint --- + + @api_server.route('/api/generate', methods=['POST']) + def generate(): + if not main_window_instance: + return jsonify({"error": "Application not ready"}), 503 + + data = request.json + start_frame = data.get('start_frame') + end_frame = data.get('end_frame') + # --- CHANGE: Get duration_sec and model_type --- + duration_sec = data.get('duration_sec') + model_type = data.get('model_type') + start_generation = data.get('start_generation', False) + + # --- CHANGE: Emit the signal with the new parameters --- + main_window_instance.api_bridge.generateSignal.emit( + start_frame, end_frame, duration_sec, model_type, start_generation + ) + + if start_generation: + return jsonify({"message": "Parameters set and generation request sent."}) + else: + return jsonify({"message": "Parameters set without starting generation."}) + + + @api_server.route('/api/latest_output', methods=['GET']) + def get_latest_output(): + if not main_window_instance: + return jsonify({"error": "Application not ready"}), 503 + + path = main_window_instance.latest_output_path + return jsonify({"latest_output_path": path}) + +# ===================================================================== +# --- END OF API SERVER ADDITION --- +# ===================================================================== + +if __name__ == '__main__': + app = QApplication(sys.argv) + + # Create and show the main window + window = MainWindow() + main_window_instance = window # Assign to global for API access + window.show() + + # Start the Flask API server in a separate thread + if FLASK_AVAILABLE: + api_thread = threading.Thread(target=run_api_server, daemon=True) + api_thread.start() + + sys.exit(app.exec()) \ No newline at end of file From 93991812289a3e617b7a0d496557e8d9c21b8c99 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 04:42:13 +1100 Subject: [PATCH 04/67] Update README.md --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fb88c7acd..7a5a7f788 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,16 @@ cd videoeditor python main.py ``` +## Screenshots +image +image +image +image -# WanGP + + + +# What follows is the standard WanGP Readme -----

From 4033106e458836f6215882b83fcbb8da3c1bb9a8 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 04:46:18 +1100 Subject: [PATCH 05/67] Update README.md --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7a5a7f788..6464a18f0 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,11 @@ python main.py ``` ## Screenshots -image -image -image -image +image +image +image +image + From e3ad47e7a39674ae5f77c2be88cbd609d3949767 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 07:59:47 +1100 Subject: [PATCH 06/67] add undo/redo, delete clips, project media window, recent projects. fixed ai plugin stalling when no server present --- videoeditor/main.py | 842 +++++++++++++++----- videoeditor/plugins/ai_frame_joiner/main.py | 5 +- videoeditor/undo.py | 114 +++ 3 files changed, 779 insertions(+), 182 deletions(-) create mode 100644 videoeditor/undo.py diff --git a/videoeditor/main.py b/videoeditor/main.py index 717628c42..a3f155e09 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -5,45 +5,18 @@ import re import json import ffmpeg +import copy from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QFileDialog, QLabel, QScrollArea, QFrame, QProgressBar, QDialog, - QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget) + QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget, QListWidget, QListWidgetItem, QMessageBox) from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, - QPixmap, QImage) + QPixmap, QImage, QDrag) from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, - pyqtSignal, QTimer, QByteArray) + pyqtSignal, QTimer, QByteArray, QMimeData) from plugins import PluginManager, ManagePluginsDialog -import requests -import zipfile -from tqdm import tqdm - -def download_ffmpeg(): - if os.name != 'nt': return - exes = ['ffmpeg.exe', 'ffprobe.exe', 'ffplay.exe'] - if all(os.path.exists(e) for e in exes): return - api_url = 'https://api.github.com/repos/GyanD/codexffmpeg/releases/latest' - r = requests.get(api_url, headers={'Accept': 'application/vnd.github+json'}) - assets = r.json().get('assets', []) - zip_asset = next((a for a in assets if 'essentials_build.zip' in a['name']), None) - if not zip_asset: return - zip_url = zip_asset['browser_download_url'] - zip_name = zip_asset['name'] - with requests.get(zip_url, stream=True) as resp: - total = int(resp.headers.get('Content-Length', 0)) - with open(zip_name, 'wb') as f, tqdm(total=total, unit='B', unit_scale=True) as pbar: - for chunk in resp.iter_content(chunk_size=8192): - f.write(chunk) - pbar.update(len(chunk)) - with zipfile.ZipFile(zip_name) as z: - for f in z.namelist(): - if f.endswith(tuple(exes)) and '/bin/' in f: - z.extract(f) - os.rename(f, os.path.basename(f)) - os.remove(zip_name) - -download_ffmpeg() +from undo import UndoStack, TimelineStateChangeCommand, MoveClipsCommand class TimelineClip: def __init__(self, source_path, timeline_start_sec, clip_start_sec, duration_sec, track_index, track_type, group_id): @@ -111,15 +84,19 @@ class TimelineWidget(QWidget): AUDIO_TRACKS_SEPARATOR_Y = 15 split_requested = pyqtSignal(object) + delete_clip_requested = pyqtSignal(object) playhead_moved = pyqtSignal(float) split_region_requested = pyqtSignal(list) split_all_regions_requested = pyqtSignal(list) join_region_requested = pyqtSignal(list) join_all_regions_requested = pyqtSignal(list) + delete_region_requested = pyqtSignal(list) + delete_all_regions_requested = pyqtSignal(list) add_track = pyqtSignal(str) remove_track = pyqtSignal(str) operation_finished = pyqtSignal() - context_menu_requested = pyqtSignal(QMenu, 'QContextMenuEvent') + context_menu_requested = pyqtSignal(QMenu, 'QContextMenuEvent') + clips_moved = pyqtSignal(list) def __init__(self, timeline_model, settings, parent=None): super().__init__(parent) @@ -128,6 +105,7 @@ def __init__(self, timeline_model, settings, parent=None): self.playhead_pos_sec = 0.0 self.setMinimumHeight(300) self.setMouseTracking(True) + self.setAcceptDrops(True) self.selection_regions = [] self.dragging_clip = None self.dragging_linked_clip = None @@ -135,7 +113,7 @@ def __init__(self, timeline_model, settings, parent=None): self.creating_selection_region = False self.dragging_selection_region = None self.drag_start_pos = QPoint() - self.drag_original_timeline_start = 0 + self.drag_original_clip_states = {} # Store {'clip_id': (start_sec, track_index)} self.selection_drag_start_sec = 0.0 self.drag_selection_start_values = None @@ -147,6 +125,11 @@ def __init__(self, timeline_model, settings, parent=None): self.video_tracks_y_start = 0 self.audio_tracks_y_start = 0 + + self.drag_over_active = False + self.drag_over_rect = QRectF() + self.drag_over_audio_rect = QRectF() + self.drag_has_audio = False def sec_to_x(self, sec): return self.HEADER_WIDTH + int(sec * self.PIXELS_PER_SECOND) def x_to_sec(self, x): return float(x - self.HEADER_WIDTH) / self.PIXELS_PER_SECOND if x > self.HEADER_WIDTH else 0.0 @@ -160,6 +143,16 @@ def paintEvent(self, event): self.draw_timescale(painter) self.draw_tracks_and_clips(painter) self.draw_selections(painter) + + if self.drag_over_active: + painter.fillRect(self.drag_over_rect, QColor(0, 255, 0, 80)) + if self.drag_has_audio: + painter.fillRect(self.drag_over_audio_rect, QColor(0, 255, 0, 80)) + painter.setPen(QColor(0, 255, 0, 150)) + painter.drawRect(self.drag_over_rect) + if self.drag_has_audio: + painter.drawRect(self.drag_over_audio_rect) + self.draw_playhead(painter) total_width = self.sec_to_x(self.timeline.get_total_duration()) + 100 @@ -368,14 +361,20 @@ def mousePressEvent(self, event: QMouseEvent): self.dragging_playhead = False self.dragging_selection_region = None self.creating_selection_region = False + self.drag_original_clip_states.clear() for clip in reversed(self.timeline.clips): clip_rect = self.get_clip_rect(clip) if clip_rect.contains(QPointF(event.pos())): self.dragging_clip = clip + self.drag_original_clip_states[clip.id] = (clip.timeline_start_sec, clip.track_index) + self.dragging_linked_clip = next((c for c in self.timeline.clips if c.group_id == clip.group_id and c.id != clip.id), None) + if self.dragging_linked_clip: + self.drag_original_clip_states[self.dragging_linked_clip.id] = \ + (self.dragging_linked_clip.timeline_start_sec, self.dragging_linked_clip.track_index) + self.drag_start_pos = event.pos() - self.drag_original_timeline_start = clip.timeline_start_sec break if not self.dragging_clip: @@ -429,25 +428,23 @@ def mouseMoveEvent(self, event: QMouseEvent): elif self.dragging_clip: self.highlighted_track_info = None new_track_info = self.y_to_track_info(event.pos().y()) + + original_start_sec, _ = self.drag_original_clip_states[self.dragging_clip.id] + if new_track_info: new_track_type, new_track_index = new_track_info if new_track_type == self.dragging_clip.track_type: self.dragging_clip.track_index = new_track_index if self.dragging_linked_clip: + # For now, linked clips move to same-number track index self.dragging_linked_clip.track_index = new_track_index - if self.dragging_clip.track_type == 'video': - if new_track_index > self.timeline.num_audio_tracks: - self.timeline.num_audio_tracks = new_track_index - self.highlighted_track_info = ('audio', new_track_index) - else: - if new_track_index > self.timeline.num_video_tracks: - self.timeline.num_video_tracks = new_track_index - self.highlighted_track_info = ('video', new_track_index) + delta_x = event.pos().x() - self.drag_start_pos.x() time_delta = delta_x / self.PIXELS_PER_SECOND - new_start_time = self.drag_original_timeline_start + time_delta + new_start_time = original_start_sec + time_delta + # Basic collision detection (can be improved) for other_clip in self.timeline.clips: if other_clip.id == self.dragging_clip.id: continue if self.dragging_linked_clip and other_clip.id == self.dragging_linked_clip.id: continue @@ -485,14 +482,130 @@ def mouseReleaseEvent(self, event: QMouseEvent): self.dragging_playhead = False if self.dragging_clip: + move_data = [] + # Check main dragged clip + orig_start, orig_track = self.drag_original_clip_states[self.dragging_clip.id] + if orig_start != self.dragging_clip.timeline_start_sec or orig_track != self.dragging_clip.track_index: + move_data.append({ + 'clip_id': self.dragging_clip.id, + 'old_start': orig_start, 'new_start': self.dragging_clip.timeline_start_sec, + 'old_track': orig_track, 'new_track': self.dragging_clip.track_index + }) + # Check linked clip + if self.dragging_linked_clip: + orig_start_link, orig_track_link = self.drag_original_clip_states[self.dragging_linked_clip.id] + if orig_start_link != self.dragging_linked_clip.timeline_start_sec or orig_track_link != self.dragging_linked_clip.track_index: + move_data.append({ + 'clip_id': self.dragging_linked_clip.id, + 'old_start': orig_start_link, 'new_start': self.dragging_linked_clip.timeline_start_sec, + 'old_track': orig_track_link, 'new_track': self.dragging_linked_clip.track_index + }) + + if move_data: + self.clips_moved.emit(move_data) + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) self.highlighted_track_info = None self.operation_finished.emit() + self.dragging_clip = None self.dragging_linked_clip = None + self.drag_original_clip_states.clear() self.update() + def dragEnterEvent(self, event): + if event.mimeData().hasFormat('application/x-vnd.video.filepath'): + event.acceptProposedAction() + else: + event.ignore() + + def dragLeaveEvent(self, event): + self.drag_over_active = False + self.update() + + def dragMoveEvent(self, event): + mime_data = event.mimeData() + if not mime_data.hasFormat('application/x-vnd.video.filepath'): + event.ignore() + return + + event.acceptProposedAction() + + json_data = json.loads(mime_data.data('application/x-vnd.video.filepath').data().decode('utf-8')) + duration = json_data['duration'] + self.drag_has_audio = json_data['has_audio'] + + pos = event.position() + track_info = self.y_to_track_info(pos.y()) + start_sec = self.x_to_sec(pos.x()) + + if track_info: + self.drag_over_active = True + track_type, track_index = track_info + + width = int(duration * self.PIXELS_PER_SECOND) + x = self.sec_to_x(start_sec) + + if track_type == 'video': + visual_index = self.timeline.num_video_tracks - track_index + y = self.video_tracks_y_start + visual_index * self.TRACK_HEIGHT + self.drag_over_rect = QRectF(x, y, width, self.TRACK_HEIGHT) + if self.drag_has_audio: + audio_y = self.audio_tracks_y_start # Default to track 1 + self.drag_over_audio_rect = QRectF(x, audio_y, width, self.TRACK_HEIGHT) + + elif track_type == 'audio': + visual_index = track_index - 1 + y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT + self.drag_over_audio_rect = QRectF(x, y, width, self.TRACK_HEIGHT) + # For now, just show video on track 1 if dragging onto audio + video_y = self.video_tracks_y_start + (self.timeline.num_video_tracks - 1) * self.TRACK_HEIGHT + self.drag_over_rect = QRectF(x, video_y, width, self.TRACK_HEIGHT) + else: + self.drag_over_active = False + + self.update() + + def dropEvent(self, event): + self.drag_over_active = False + self.update() + + mime_data = event.mimeData() + if not mime_data.hasFormat('application/x-vnd.video.filepath'): + return + + json_data = json.loads(mime_data.data('application/x-vnd.video.filepath').data().decode('utf-8')) + file_path = json_data['path'] + duration = json_data['duration'] + has_audio = json_data['has_audio'] + + pos = event.position() + start_sec = self.x_to_sec(pos.x()) + track_info = self.y_to_track_info(pos.y()) + + if not track_info: + return + + drop_track_type, drop_track_index = track_info + video_track_idx = 1 + audio_track_idx = 1 if has_audio else None + + if drop_track_type == 'video': + video_track_idx = drop_track_index + elif drop_track_type == 'audio': + audio_track_idx = drop_track_index + + main_window = self.window() + main_window._add_clip_to_timeline( + source_path=file_path, + timeline_start_sec=start_sec, + duration_sec=duration, + clip_start_sec=0, + video_track_index=video_track_idx, + audio_track_index=audio_track_idx + ) + def contextMenuEvent(self, event: 'QContextMenuEvent'): menu = QMenu(self) @@ -502,6 +615,8 @@ def contextMenuEvent(self, event: 'QContextMenuEvent'): split_all_action = menu.addAction("Split All Regions") join_this_action = menu.addAction("Join This Region") join_all_action = menu.addAction("Join All Regions") + delete_this_action = menu.addAction("Delete This Region") + delete_all_action = menu.addAction("Delete All Regions") menu.addSeparator() clear_this_action = menu.addAction("Clear This Region") clear_all_action = menu.addAction("Clear All Regions") @@ -509,6 +624,8 @@ def contextMenuEvent(self, event: 'QContextMenuEvent'): split_all_action.triggered.connect(lambda: self.split_all_regions_requested.emit(self.selection_regions)) join_this_action.triggered.connect(lambda: self.join_region_requested.emit(region_at_pos)) join_all_action.triggered.connect(lambda: self.join_all_regions_requested.emit(self.selection_regions)) + delete_this_action.triggered.connect(lambda: self.delete_region_requested.emit(region_at_pos)) + delete_all_action.triggered.connect(lambda: self.delete_all_regions_requested.emit(self.selection_regions)) clear_this_action.triggered.connect(lambda: self.clear_region(region_at_pos)) clear_all_action.triggered.connect(self.clear_all_regions) @@ -521,10 +638,12 @@ def contextMenuEvent(self, event: 'QContextMenuEvent'): if clip_at_pos: if not menu.isEmpty(): menu.addSeparator() split_action = menu.addAction("Split Clip") + delete_action = menu.addAction("Delete Clip") playhead_time = self.playhead_pos_sec is_playhead_over_clip = (clip_at_pos.timeline_start_sec < playhead_time < clip_at_pos.timeline_end_sec) split_action.setEnabled(is_playhead_over_clip) split_action.triggered.connect(lambda: self.split_requested.emit(clip_at_pos)) + delete_action.triggered.connect(lambda: self.delete_clip_requested.emit(clip_at_pos)) self.context_menu_requested.emit(menu, event) @@ -540,22 +659,111 @@ def clear_all_regions(self): self.selection_regions.clear() self.update() + class SettingsDialog(QDialog): def __init__(self, parent_settings, parent=None): super().__init__(parent) self.setWindowTitle("Settings") self.setMinimumWidth(350) layout = QVBoxLayout(self) - self.seek_anywhere_checkbox = QCheckBox("Allow seeking by clicking anywhere on the timeline") - self.seek_anywhere_checkbox.setChecked(parent_settings.get("allow_seek_anywhere", False)) - layout.addWidget(self.seek_anywhere_checkbox) + self.confirm_on_exit_checkbox = QCheckBox("Confirm before exiting") + self.confirm_on_exit_checkbox.setChecked(parent_settings.get("confirm_on_exit", True)) + layout.addWidget(self.confirm_on_exit_checkbox) button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) def get_settings(self): - return {"allow_seek_anywhere": self.seek_anywhere_checkbox.isChecked()} + return { + "confirm_on_exit": self.confirm_on_exit_checkbox.isChecked() + } + +class MediaListWidget(QListWidget): + def __init__(self, main_window, parent=None): + super().__init__(parent) + self.main_window = main_window + self.setDragEnabled(True) + self.setAcceptDrops(False) + self.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + + def startDrag(self, supportedActions): + drag = QDrag(self) + mime_data = QMimeData() + + item = self.currentItem() + if not item: return + + path = item.data(Qt.ItemDataRole.UserRole) + media_info = self.main_window.media_properties.get(path) + if not media_info: return + + payload = { + "path": path, + "duration": media_info['duration'], + "has_audio": media_info['has_audio'] + } + + mime_data.setData('application/x-vnd.video.filepath', QByteArray(json.dumps(payload).encode('utf-8'))) + drag.setMimeData(mime_data) + drag.exec(Qt.DropAction.CopyAction) + +class ProjectMediaWidget(QWidget): + media_removed = pyqtSignal(str) + add_media_requested = pyqtSignal() + add_to_timeline_requested = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent + layout = QVBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + + self.media_list = MediaListWidget(self.main_window, self) + self.media_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.media_list.customContextMenuRequested.connect(self.show_context_menu) + layout.addWidget(self.media_list) + + button_layout = QHBoxLayout() + add_button = QPushButton("Add") + remove_button = QPushButton("Remove") + button_layout.addWidget(add_button) + button_layout.addWidget(remove_button) + layout.addLayout(button_layout) + + add_button.clicked.connect(self.add_media_requested.emit) + remove_button.clicked.connect(self.remove_selected_media) + def show_context_menu(self, pos): + item = self.media_list.itemAt(pos) + if not item: + return + + menu = QMenu() + add_action = menu.addAction("Add to timeline at playhead") + + action = menu.exec(self.media_list.mapToGlobal(pos)) + + if action == add_action: + file_path = item.data(Qt.ItemDataRole.UserRole) + self.add_to_timeline_requested.emit(file_path) + + def add_media_item(self, file_path): + if not any(self.media_list.item(i).data(Qt.ItemDataRole.UserRole) == file_path for i in range(self.media_list.count())): + item = QListWidgetItem(os.path.basename(file_path)) + item.setData(Qt.ItemDataRole.UserRole, file_path) + self.media_list.addItem(item) + + def remove_selected_media(self): + selected_items = self.media_list.selectedItems() + if not selected_items: return + + for item in selected_items: + file_path = item.data(Qt.ItemDataRole.UserRole) + self.media_removed.emit(file_path) + self.media_list.takeItem(self.media_list.row(item)) + + def clear_list(self): + self.media_list.clear() class MainWindow(QMainWindow): def __init__(self, project_to_load=None): @@ -565,6 +773,9 @@ def __init__(self, project_to_load=None): self.setDockOptions(QMainWindow.DockOption.AnimatedDocks | QMainWindow.DockOption.AllowNestedDocks) self.timeline = Timeline() + self.undo_stack = UndoStack() + self.media_pool = [] + self.media_properties = {} # To store duration, has_audio etc. self.export_thread = None self.export_worker = None self.current_project_path = None @@ -583,6 +794,30 @@ def __init__(self, project_to_load=None): self.playback_process = None self.playback_clip = None + self._setup_ui() + self._connect_signals() + + self.plugin_manager.load_enabled_plugins_from_settings(self.settings.get("enabled_plugins", [])) + self._apply_loaded_settings() + self.seek_preview(0) + + if not self.settings_file_was_loaded: self._save_settings() + if project_to_load: QTimer.singleShot(100, lambda: self._load_project_from_path(project_to_load)) + + def _get_current_timeline_state(self): + """Helper to capture a snapshot of the timeline for undo/redo.""" + return ( + copy.deepcopy(self.timeline.clips), + self.timeline.num_video_tracks, + self.timeline.num_audio_tracks + ) + + def _setup_ui(self): + self.media_dock = QDockWidget("Project Media", self) + self.project_media_widget = ProjectMediaWidget(self) + self.media_dock.setWidget(self.project_media_widget) + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.media_dock) + self.splitter = QSplitter(Qt.Orientation.Vertical) self.preview_widget = QLabel() @@ -592,14 +827,14 @@ def __init__(self, project_to_load=None): self.preview_widget.setStyleSheet("background-color: black; color: white;") self.splitter.addWidget(self.preview_widget) - self.timeline_widget = TimelineWidget(self.timeline, self.settings) + self.timeline_widget = TimelineWidget(self.timeline, self.settings, self) self.timeline_scroll_area = QScrollArea() self.timeline_scroll_area.setWidgetResizable(True) self.timeline_scroll_area.setWidget(self.timeline_widget) self.timeline_scroll_area.setFrameShape(QFrame.Shape.NoFrame) self.timeline_scroll_area.setMinimumHeight(250) self.splitter.addWidget(self.timeline_scroll_area) - + container_widget = QWidget() main_layout = QVBoxLayout(container_widget) main_layout.setContentsMargins(0,0,0,0) @@ -634,7 +869,8 @@ def __init__(self, project_to_load=None): self.managed_widgets = { 'preview': {'widget': self.preview_widget, 'name': 'Video Preview', 'action': None}, - 'timeline': {'widget': self.timeline_scroll_area, 'name': 'Timeline', 'action': None} + 'timeline': {'widget': self.timeline_scroll_area, 'name': 'Timeline', 'action': None}, + 'project_media': {'widget': self.media_dock, 'name': 'Project Media', 'action': None} } self.plugin_menu_actions = {} self.windows_menu = None @@ -643,14 +879,20 @@ def __init__(self, project_to_load=None): self.splitter_save_timer = QTimer(self) self.splitter_save_timer.setSingleShot(True) self.splitter_save_timer.timeout.connect(self._save_settings) + + def _connect_signals(self): self.splitter.splitterMoved.connect(self.on_splitter_moved) self.timeline_widget.split_requested.connect(self.split_clip_at_playhead) + self.timeline_widget.delete_clip_requested.connect(self.delete_clip) self.timeline_widget.playhead_moved.connect(self.seek_preview) + self.timeline_widget.clips_moved.connect(self.on_clips_moved) self.timeline_widget.split_region_requested.connect(self.on_split_region) self.timeline_widget.split_all_regions_requested.connect(self.on_split_all_regions) self.timeline_widget.join_region_requested.connect(self.on_join_region) self.timeline_widget.join_all_regions_requested.connect(self.on_join_all_regions) + self.timeline_widget.delete_region_requested.connect(self.on_delete_region) + self.timeline_widget.delete_all_regions_requested.connect(self.on_delete_all_regions) self.timeline_widget.add_track.connect(self.add_track) self.timeline_widget.remove_track.connect(self.remove_track) self.timeline_widget.operation_finished.connect(self.prune_empty_tracks) @@ -661,16 +903,57 @@ def __init__(self, project_to_load=None): self.frame_forward_button.clicked.connect(lambda: self.step_frame(1)) self.playback_timer.timeout.connect(self.advance_playback_frame) - self.plugin_manager.load_enabled_plugins_from_settings(self.settings.get("enabled_plugins", [])) - self._apply_loaded_settings() - self.seek_preview(0) + self.project_media_widget.add_media_requested.connect(self.add_video_clip) + self.project_media_widget.media_removed.connect(self.on_media_removed_from_pool) + self.project_media_widget.add_to_timeline_requested.connect(self.on_add_to_timeline_at_playhead) - if not self.settings_file_was_loaded: self._save_settings() - if project_to_load: QTimer.singleShot(100, lambda: self._load_project_from_path(project_to_load)) + self.undo_stack.history_changed.connect(self.update_undo_redo_actions) + self.undo_stack.timeline_changed.connect(self.on_timeline_changed_by_undo) + + def on_timeline_changed_by_undo(self): + """Called when undo/redo modifies the timeline.""" + self.prune_empty_tracks() + self.timeline_widget.update() + # You may want to update other UI elements here as well + self.status_label.setText("Operation undone/redone.") + + def update_undo_redo_actions(self): + self.undo_action.setEnabled(self.undo_stack.can_undo()) + self.undo_action.setText(f"Undo {self.undo_stack.undo_text()}" if self.undo_stack.can_undo() else "Undo") + + self.redo_action.setEnabled(self.undo_stack.can_redo()) + self.redo_action.setText(f"Redo {self.undo_stack.redo_text()}" if self.undo_stack.can_redo() else "Redo") + + def on_clips_moved(self, move_data): + command = MoveClipsCommand("Move Clip", self.timeline, move_data) + # The change is already applied visually, so we push without redoing + self.undo_stack.undo_stack.append(command) + self.undo_stack.redo_stack.clear() + self.undo_stack.history_changed.emit() + self.status_label.setText("Clip moved.") + + def on_add_to_timeline_at_playhead(self, file_path): + media_info = self.media_properties.get(file_path) + if not media_info: + self.status_label.setText(f"Error: Could not find properties for {os.path.basename(file_path)}") + return + + playhead_pos = self.timeline_widget.playhead_pos_sec + duration = media_info['duration'] + has_audio = media_info['has_audio'] + + self._add_clip_to_timeline( + source_path=file_path, + timeline_start_sec=playhead_pos, + duration_sec=duration, + clip_start_sec=0.0, + video_track_index=1, + audio_track_index=1 if has_audio else None + ) def prune_empty_tracks(self): pruned_something = False - + # Prune Video Tracks while self.timeline.num_video_tracks > 1: highest_track_index = self.timeline.num_video_tracks is_track_occupied = any(c for c in self.timeline.clips @@ -680,7 +963,8 @@ def prune_empty_tracks(self): else: self.timeline.num_video_tracks -= 1 pruned_something = True - + + # Prune Audio Tracks while self.timeline.num_audio_tracks > 1: highest_track_index = self.timeline.num_audio_tracks is_track_occupied = any(c for c in self.timeline.clips @@ -690,42 +974,64 @@ def prune_empty_tracks(self): else: self.timeline.num_audio_tracks -= 1 pruned_something = True - + if pruned_something: self.timeline_widget.update() - def add_track(self, track_type): + old_state = self._get_current_timeline_state() if track_type == 'video': self.timeline.num_video_tracks += 1 elif track_type == 'audio': self.timeline.num_audio_tracks += 1 - self.timeline_widget.update() + new_state = self._get_current_timeline_state() + + command = TimelineStateChangeCommand(f"Add {track_type.capitalize()} Track", self.timeline, *old_state, *new_state) + self.undo_stack.push(command) + # Manually undo the direct change, because push() will redo() it. + command.undo() + self.undo_stack.push(command) def remove_track(self, track_type): + old_state = self._get_current_timeline_state() if track_type == 'video' and self.timeline.num_video_tracks > 1: self.timeline.num_video_tracks -= 1 elif track_type == 'audio' and self.timeline.num_audio_tracks > 1: self.timeline.num_audio_tracks -= 1 - self.timeline_widget.update() + else: + return # No change made + new_state = self._get_current_timeline_state() + + command = TimelineStateChangeCommand(f"Remove {track_type.capitalize()} Track", self.timeline, *old_state, *new_state) + command.undo() # Invert state because we already made the change + self.undo_stack.push(command) + def _create_menu_bar(self): menu_bar = self.menuBar() file_menu = menu_bar.addMenu("&File") new_action = QAction("&New Project", self); new_action.triggered.connect(self.new_project) open_action = QAction("&Open Project...", self); open_action.triggered.connect(self.open_project) + self.recent_menu = file_menu.addMenu("Recent") save_action = QAction("&Save Project As...", self); save_action.triggered.connect(self.save_project_as) - add_video_action = QAction("&Add Video...", self); add_video_action.triggered.connect(self.add_video_clip) + add_video_action = QAction("&Add Media to Project...", self); add_video_action.triggered.connect(self.add_video_clip) export_action = QAction("&Export Video...", self); export_action.triggered.connect(self.export_video) settings_action = QAction("Se&ttings...", self); settings_action.triggered.connect(self.open_settings_dialog) exit_action = QAction("E&xit", self); exit_action.triggered.connect(self.close) - file_menu.addAction(new_action); file_menu.addAction(open_action); file_menu.addAction(save_action) + file_menu.addAction(new_action); file_menu.addAction(open_action); file_menu.addSeparator() + file_menu.addAction(save_action) file_menu.addSeparator(); file_menu.addAction(add_video_action); file_menu.addAction(export_action) file_menu.addSeparator(); file_menu.addAction(settings_action); file_menu.addSeparator(); file_menu.addAction(exit_action) - + self._update_recent_files_menu() + edit_menu = menu_bar.addMenu("&Edit") + self.undo_action = QAction("Undo", self); self.undo_action.setShortcut("Ctrl+Z"); self.undo_action.triggered.connect(self.undo_stack.undo) + self.redo_action = QAction("Redo", self); self.redo_action.setShortcut("Ctrl+Y"); self.redo_action.triggered.connect(self.undo_stack.redo) + edit_menu.addAction(self.undo_action); edit_menu.addAction(self.redo_action); edit_menu.addSeparator() + split_action = QAction("Split Clip at Playhead", self); split_action.triggered.connect(self.split_clip_at_playhead) edit_menu.addAction(split_action) + self.update_undo_redo_actions() # Set initial state plugins_menu = menu_bar.addMenu("&Plugins") for name, data in self.plugin_manager.plugins.items(): @@ -742,8 +1048,14 @@ def _create_menu_bar(self): self.windows_menu = menu_bar.addMenu("&Windows") for key, data in self.managed_widgets.items(): + if data['widget'] is self.preview_widget: continue # Preview is part of splitter, not a dock action = QAction(data['name'], self, checkable=True) - action.toggled.connect(lambda checked, k=key: self.toggle_widget_visibility(k, checked)) + if hasattr(data['widget'], 'visibilityChanged'): + action.toggled.connect(data['widget'].setVisible) + data['widget'].visibilityChanged.connect(action.setChecked) + else: + action.toggled.connect(lambda checked, k=key: self.toggle_widget_visibility(k, checked)) + data['action'] = action self.windows_menu.addAction(action) @@ -783,6 +1095,10 @@ def _set_project_properties_from_clip(self, source_path): return False def get_frame_data_at_time(self, time_sec): + """ + Plugin API: Extracts raw frame data at a specific time. + Returns a tuple of (bytes, width, height) or (None, 0, 0) on failure. + """ clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) if not clip_at_time: return (None, 0, 0) @@ -858,7 +1174,7 @@ def advance_playback_frame(self): def _load_settings(self): self.settings_file_was_loaded = False - defaults = {"allow_seek_anywhere": False, "window_visibility": {"preview": True, "timeline": True}, "splitter_state": None, "enabled_plugins": []} + defaults = {"window_visibility": {"project_media": True}, "splitter_state": None, "enabled_plugins": [], "recent_files": [], "confirm_on_exit": True} if os.path.exists(self.settings_file): try: with open(self.settings_file, "r") as f: self.settings = json.load(f) @@ -874,33 +1190,27 @@ def _save_settings(self): key: data['action'].isChecked() for key, data in self.managed_widgets.items() if data.get('action') } - self.settings["window_visibility"] = visibility_to_save self.settings['enabled_plugins'] = self.plugin_manager.get_enabled_plugin_names() try: - with open(self.settings_file, "w") as f: - json.dump(self.settings, f, indent=4) - except IOError as e: - print(f"Error saving settings: {e}") + with open(self.settings_file, "w") as f: json.dump(self.settings, f, indent=4) + except IOError as e: print(f"Error saving settings: {e}") def _apply_loaded_settings(self): visibility_settings = self.settings.get("window_visibility", {}) for key, data in self.managed_widgets.items(): - if data.get('plugin'): - continue - + if data.get('plugin'): continue is_visible = visibility_settings.get(key, True) - data['widget'].setVisible(is_visible) + if data['widget'] is not self.preview_widget: # preview handled by splitter state + data['widget'].setVisible(is_visible) if data['action']: data['action'].setChecked(is_visible) - splitter_state = self.settings.get("splitter_state") if splitter_state: self.splitter.restoreState(QByteArray.fromHex(splitter_state.encode('ascii'))) def on_splitter_moved(self, pos, index): self.splitter_save_timer.start(500) def toggle_widget_visibility(self, key, checked): - if self.is_shutting_down: - return + if self.is_shutting_down: return if key in self.managed_widgets: self.managed_widgets[key]['widget'].setVisible(checked) self._save_settings() @@ -912,19 +1222,25 @@ def open_settings_dialog(self): def new_project(self): self.timeline.clips.clear(); self.timeline.num_video_tracks = 1; self.timeline.num_audio_tracks = 1 + self.media_pool.clear(); self.media_properties.clear(); self.project_media_widget.clear_list() self.current_project_path = None; self.stop_playback(); self.timeline_widget.update() - self.status_label.setText("New project created. Add video clips to begin.") + self.undo_stack = UndoStack() + self.undo_stack.history_changed.connect(self.update_undo_redo_actions) + self.update_undo_redo_actions() + self.status_label.setText("New project created. Add media to begin.") def save_project_as(self): path, _ = QFileDialog.getSaveFileName(self, "Save Project", "", "JSON Project Files (*.json)") if not path: return project_data = { + "media_pool": self.media_pool, "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "group_id": c.group_id} for c in self.timeline.clips], "settings": {"num_video_tracks": self.timeline.num_video_tracks, "num_audio_tracks": self.timeline.num_audio_tracks} } try: with open(path, "w") as f: json.dump(project_data, f, indent=4) self.current_project_path = path; self.status_label.setText(f"Project saved to {os.path.basename(path)}") + self._add_to_recent_files(path) except Exception as e: self.status_label.setText(f"Error saving project: {e}") def open_project(self): @@ -934,10 +1250,14 @@ def open_project(self): def _load_project_from_path(self, path): try: with open(path, "r") as f: project_data = json.load(f) - self.timeline.clips.clear() + self.new_project() # Clear existing state + + self.media_pool = project_data.get("media_pool", []) + for p in self.media_pool: self._add_media_to_pool(p) + for clip_data in project_data["clips"]: if not os.path.exists(clip_data["source_path"]): - self.status_label.setText(f"Error: Missing media file {clip_data['source_path']}"); self.timeline.clips.clear(); self.timeline_widget.update(); return + self.status_label.setText(f"Error: Missing media file {clip_data['source_path']}"); self.new_project(); return self.timeline.add_clip(TimelineClip(**clip_data)) project_settings = project_data.get("settings", {}) @@ -949,35 +1269,77 @@ def _load_project_from_path(self, path): self.prune_empty_tracks() self.timeline_widget.update(); self.stop_playback() self.status_label.setText(f"Project '{os.path.basename(path)}' loaded.") + self._add_to_recent_files(path) except Exception as e: self.status_label.setText(f"Error opening project: {e}") - def add_video_clip(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Open Video File", "", "Video Files (*.mp4 *.mov *.avi)") - if not file_path: return - if not self.timeline.clips: - if not self._set_project_properties_from_clip(file_path): self.status_label.setText("Error: Could not determine video properties from file."); return + def _add_to_recent_files(self, path): + recent = self.settings.get("recent_files", []) + if path in recent: recent.remove(path) + recent.insert(0, path) + self.settings["recent_files"] = recent[:10] + self._update_recent_files_menu() + self._save_settings() + + def _update_recent_files_menu(self): + self.recent_menu.clear() + recent_files = self.settings.get("recent_files", []) + for path in recent_files: + if os.path.exists(path): + action = QAction(os.path.basename(path), self) + action.triggered.connect(lambda checked, p=path: self._load_project_from_path(p)) + self.recent_menu.addAction(action) + + def _add_media_to_pool(self, file_path): + if file_path in self.media_pool: return True try: self.status_label.setText(f"Probing {os.path.basename(file_path)}..."); QApplication.processEvents() probe = ffmpeg.probe(file_path) video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) if not video_stream: raise ValueError("No video stream found.") + duration = float(video_stream.get('duration', probe['format'].get('duration', 0))) has_audio = any(s['codec_type'] == 'audio' for s in probe['streams']) - timeline_start = self.timeline.get_total_duration() - - self._add_clip_to_timeline( - source_path=file_path, - timeline_start_sec=timeline_start, - duration_sec=duration, - clip_start_sec=0, - video_track_index=1, - audio_track_index=1 if has_audio else None - ) + + self.media_properties[file_path] = {'duration': duration, 'has_audio': has_audio} + self.media_pool.append(file_path) + self.project_media_widget.add_media_item(file_path) + + self.status_label.setText(f"Added {os.path.basename(file_path)} to project.") + return True + except Exception as e: + self.status_label.setText(f"Error probing file: {e}") + return False + + def on_media_removed_from_pool(self, file_path): + old_state = self._get_current_timeline_state() + + if file_path in self.media_pool: self.media_pool.remove(file_path) + if file_path in self.media_properties: del self.media_properties[file_path] + + clips_to_remove = [c for c in self.timeline.clips if c.source_path == file_path] + for clip in clips_to_remove: self.timeline.clips.remove(clip) + + new_state = self._get_current_timeline_state() + command = TimelineStateChangeCommand("Remove Media From Project", self.timeline, *old_state, *new_state) + command.undo() + self.undo_stack.push(command) + - self.timeline_widget.update(); self.seek_preview(self.timeline_widget.playhead_pos_sec) - except Exception as e: self.status_label.setText(f"Error adding file: {e}") + def add_video_clip(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Open Video File", "", "Video Files (*.mp4 *.mov *.avi)") + if not file_path: return + + if not self.timeline.clips and not self.media_pool: + if not self._set_project_properties_from_clip(file_path): + self.status_label.setText("Error: Could not determine video properties from file."); return + + if self._add_media_to_pool(file_path): + self.status_label.setText(f"Added {os.path.basename(file_path)} to project media.") + else: + self.status_label.setText(f"Failed to add {os.path.basename(file_path)}.") def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, clip_start_sec=0.0, video_track_index=None, audio_track_index=None): + old_state = self._get_current_timeline_state() group_id = str(uuid.uuid4()) if video_track_index is not None: @@ -988,14 +1350,16 @@ def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, c audio_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, audio_track_index, 'audio', group_id) self.timeline.add_clip(audio_clip) - self.timeline_widget.update() - self.status_label.setText(f"Added {os.path.basename(source_path)}.") + new_state = self._get_current_timeline_state() + command = TimelineStateChangeCommand("Add Clip", self.timeline, *old_state, *new_state) + # We already performed the action, so we need to undo it before letting the stack redo it. + command.undo() + self.undo_stack.push(command) def _split_at_time(self, clip_to_split, time_sec, new_group_id=None): if not (clip_to_split.timeline_start_sec < time_sec < clip_to_split.timeline_end_sec): return False split_point = time_sec - clip_to_split.timeline_start_sec orig_dur = clip_to_split.duration_sec - group_id_for_new_clip = new_group_id if new_group_id is not None else clip_to_split.group_id new_clip = TimelineClip(clip_to_split.source_path, time_sec, clip_to_split.clip_start_sec + split_point, orig_dur - split_point, clip_to_split.track_index, clip_to_split.track_type, group_id_for_new_clip) @@ -1006,88 +1370,199 @@ def _split_at_time(self, clip_to_split, time_sec, new_group_id=None): def split_clip_at_playhead(self, clip_to_split=None): playhead_time = self.timeline_widget.playhead_pos_sec if not clip_to_split: - clip_to_split = next((c for c in self.timeline.clips if c.timeline_start_sec < playhead_time < c.timeline_end_sec), None) - - if not clip_to_split: - self.status_label.setText("Playhead is not over a clip to split.") - return - + clips_at_playhead = [c for c in self.timeline.clips if c.timeline_start_sec < playhead_time < c.timeline_end_sec] + if not clips_at_playhead: + self.status_label.setText("Playhead is not over a clip to split.") + return + clip_to_split = clips_at_playhead[0] + + old_state = self._get_current_timeline_state() + linked_clip = next((c for c in self.timeline.clips if c.group_id == clip_to_split.group_id and c.id != clip_to_split.id), None) - new_right_side_group_id = str(uuid.uuid4()) split1 = self._split_at_time(clip_to_split, playhead_time, new_group_id=new_right_side_group_id) - split2 = True if linked_clip: - split2 = self._split_at_time(linked_clip, playhead_time, new_group_id=new_right_side_group_id) + self._split_at_time(linked_clip, playhead_time, new_group_id=new_right_side_group_id) - if split1 and split2: - self.timeline_widget.update() - self.status_label.setText("Clip split.") + if split1: + new_state = self._get_current_timeline_state() + command = TimelineStateChangeCommand("Split Clip", self.timeline, *old_state, *new_state) + command.undo() + self.undo_stack.push(command) else: self.status_label.setText("Failed to split clip.") - def on_split_region(self, region): - start_sec, end_sec = region; - clips = list(self.timeline.clips) - for clip in clips: self._split_at_time(clip, end_sec) - for clip in clips: self._split_at_time(clip, start_sec) - self.timeline_widget.clear_region(region); self.timeline_widget.update(); self.status_label.setText("Region split.") - def on_split_all_regions(self, regions): - split_points = set() - for start, end in regions: split_points.add(start); split_points.add(end) - for point in sorted(list(split_points)): - group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} - new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} - - for clip in list(self.timeline.clips): - if clip.group_id in new_group_ids: - self._split_at_time(clip, point, new_group_ids[clip.group_id]) + def delete_clip(self, clip_to_delete): + old_state = self._get_current_timeline_state() - self.timeline_widget.clear_all_regions(); self.timeline_widget.update(); self.status_label.setText("All regions split.") - - def on_join_region(self, region): - start_sec, end_sec = region; duration_to_remove = end_sec - start_sec - if duration_to_remove <= 0.01: return + linked_clip = next((c for c in self.timeline.clips if c.group_id == clip_to_delete.group_id and c.id != clip_to_delete.id), None) - for point in [start_sec, end_sec]: - group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} - new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} - for clip in list(self.timeline.clips): - if clip.group_id in new_group_ids: - self._split_at_time(clip, point, new_group_ids[clip.group_id]) - - clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] - for clip in clips_to_remove: self.timeline.clips.remove(clip) - for clip in self.timeline.clips: - if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec); self.timeline_widget.clear_region(region) - self.timeline_widget.update(); self.status_label.setText("Region joined (content removed).") + if clip_to_delete in self.timeline.clips: self.timeline.clips.remove(clip_to_delete) + if linked_clip and linked_clip in self.timeline.clips: self.timeline.clips.remove(linked_clip) + + new_state = self._get_current_timeline_state() + command = TimelineStateChangeCommand("Delete Clip", self.timeline, *old_state, *new_state) + command.undo() + self.undo_stack.push(command) self.prune_empty_tracks() - def on_join_all_regions(self, regions): - for region in sorted(regions, key=lambda r: r[0], reverse=True): - start_sec, end_sec = region; duration_to_remove = end_sec - start_sec - if duration_to_remove <= 0.01: continue + def _perform_complex_timeline_change(self, description, change_function): + """Wrapper for complex operations to make them undoable.""" + old_state = self._get_current_timeline_state() + + # Execute the provided function which will modify the timeline + change_function() + + new_state = self._get_current_timeline_state() + + # If no change occurred, don't add to undo stack + if old_state[0] == new_state[0] and old_state[1] == new_state[1] and old_state[2] == new_state[2]: + return + + command = TimelineStateChangeCommand(description, self.timeline, *old_state, *new_state) + # The change was already made, so we need to "undo" it before letting the stack "redo" it. + command.undo() + self.undo_stack.push(command) - for point in [start_sec, end_sec]: + def on_split_region(self, region): + def action(): + start_sec, end_sec = region + clips = list(self.timeline.clips) # Work on a copy + for clip in clips: self._split_at_time(clip, end_sec) + for clip in clips: self._split_at_time(clip, start_sec) + self.timeline_widget.clear_region(region) + self._perform_complex_timeline_change("Split Region", action) + + def on_split_all_regions(self, regions): + def action(): + split_points = set() + for start, end in regions: + split_points.add(start) + split_points.add(end) + + for point in sorted(list(split_points)): group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) + self.timeline_widget.clear_all_regions() + self._perform_complex_timeline_change("Split All Regions", action) + + def on_join_region(self, region): + def action(): + start_sec, end_sec = region + duration_to_remove = end_sec - start_sec + if duration_to_remove <= 0.01: return + + # Split any clips that cross the region boundaries + for point in [start_sec, end_sec]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} + for clip in list(self.timeline.clips): + if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) + + # Remove clips fully inside the region + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + for clip in clips_to_remove: self.timeline.clips.remove(clip) + + # Ripple delete: move subsequent clips to the left + for clip in self.timeline.clips: + if clip.timeline_start_sec >= end_sec: + clip.timeline_start_sec -= duration_to_remove + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline_widget.clear_region(region) + self._perform_complex_timeline_change("Join Region", action) + + def on_join_all_regions(self, regions): + def action(): + # Process regions from end to start to avoid index issues + for region in sorted(regions, key=lambda r: r[0], reverse=True): + start_sec, end_sec = region + duration_to_remove = end_sec - start_sec + if duration_to_remove <= 0.01: continue + + # Split clips at boundaries + for point in [start_sec, end_sec]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} + for clip in list(self.timeline.clips): + if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) + + # Remove clips inside region + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + for clip in clips_to_remove: + try: self.timeline.clips.remove(clip) + except ValueError: pass # Already removed + + # Ripple + for clip in self.timeline.clips: + if clip.timeline_start_sec >= end_sec: + clip.timeline_start_sec -= duration_to_remove + + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline_widget.clear_all_regions() + self._perform_complex_timeline_change("Join All Regions", action) + + def on_delete_region(self, region): + def action(): + start_sec, end_sec = region + duration_to_remove = end_sec - start_sec + if duration_to_remove <= 0.01: return + + # Split any clips that cross the region boundaries + for point in [start_sec, end_sec]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} + for clip in list(self.timeline.clips): + if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) + + # Remove clips fully inside the region clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] - for clip in clips_to_remove: - try: self.timeline.clips.remove(clip) - except ValueError: pass + for clip in clips_to_remove: self.timeline.clips.remove(clip) + + # Ripple delete: move subsequent clips to the left for clip in self.timeline.clips: - if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove + if clip.timeline_start_sec >= end_sec: + clip.timeline_start_sec -= duration_to_remove + + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline_widget.clear_region(region) + self._perform_complex_timeline_change("Delete Region", action) + + def on_delete_all_regions(self, regions): + def action(): + # Process regions from end to start to avoid index issues + for region in sorted(regions, key=lambda r: r[0], reverse=True): + start_sec, end_sec = region + duration_to_remove = end_sec - start_sec + if duration_to_remove <= 0.01: continue + + # Split clips at boundaries + for point in [start_sec, end_sec]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} + for clip in list(self.timeline.clips): + if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) + + # Remove clips inside region + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + for clip in clips_to_remove: + try: self.timeline.clips.remove(clip) + except ValueError: pass # Already removed + + # Ripple + for clip in self.timeline.clips: + if clip.timeline_start_sec >= end_sec: + clip.timeline_start_sec -= duration_to_remove + + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline_widget.clear_all_regions() + self._perform_complex_timeline_change("Delete All Regions", action) - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec); self.timeline_widget.clear_all_regions() - self.timeline_widget.update(); self.status_label.setText("All regions joined.") - self.prune_empty_tracks() def export_video(self): if not self.timeline.clips: self.status_label.setText("Timeline is empty."); return @@ -1174,45 +1649,29 @@ def on_thread_finished_cleanup(self): def add_dock_widget(self, plugin_instance, widget, title, area=Qt.DockWidgetArea.RightDockWidgetArea, show_on_creation=True): widget_key = f"plugin_{plugin_instance.name}_{title}".replace(' ', '_').lower() - dock = QDockWidget(title, self) dock.setWidget(widget) self.addDockWidget(area, dock) - visibility_settings = self.settings.get("window_visibility", {}) initial_visibility = visibility_settings.get(widget_key, show_on_creation) - dock.setVisible(initial_visibility) - action = QAction(title, self, checkable=True) - action.toggled.connect(lambda checked, k=widget_key: self.toggle_widget_visibility(k, checked)) + action.toggled.connect(dock.setVisible) dock.visibilityChanged.connect(action.setChecked) - action.setChecked(dock.isVisible()) - self.windows_menu.addAction(action) - - self.managed_widgets[widget_key] = { - 'widget': dock, - 'name': title, - 'action': action, - 'plugin': plugin_instance.name - } - + self.managed_widgets[widget_key] = {'widget': dock, 'name': title, 'action': action, 'plugin': plugin_instance.name} return dock def update_plugin_ui_visibility(self, plugin_name, is_enabled): for key, data in self.managed_widgets.items(): if data.get('plugin') == plugin_name: data['action'].setVisible(is_enabled) - if not is_enabled: - data['widget'].hide() + if not is_enabled: data['widget'].hide() def toggle_plugin(self, name, checked): - if checked: - self.plugin_manager.enable_plugin(name) - else: - self.plugin_manager.disable_plugin(name) + if checked: self.plugin_manager.enable_plugin(name) + else: self.plugin_manager.disable_plugin(name) self._save_settings() def toggle_plugin_action(self, name, checked): @@ -1228,6 +1687,27 @@ def open_manage_plugins_dialog(self): dialog.exec() def closeEvent(self, event): + if self.settings.get("confirm_on_exit", True): + msg_box = QMessageBox(self) + msg_box.setWindowTitle("Confirm Exit") + msg_box.setText("Are you sure you want to exit?") + msg_box.setInformativeText("Any unsaved changes will be lost.") + msg_box.setIcon(QMessageBox.Icon.Question) + msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + msg_box.setDefaultButton(QMessageBox.StandardButton.No) + + dont_ask_cb = QCheckBox("Don't ask again") + msg_box.setCheckBox(dont_ask_cb) + + reply = msg_box.exec() + + if dont_ask_cb.isChecked(): + self.settings['confirm_on_exit'] = False + + if reply == QMessageBox.StandardButton.No: + event.ignore() + return + self.is_shutting_down = True self._save_settings() self._stop_playback_stream() diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index 873cd5e56..56b94efe6 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -139,6 +139,7 @@ def generate(self): if response.status_code == 200: if payload['start_generation']: self.status_label.setText("Status: Parameters set. Generation sent. Polling...") + self.start_polling() else: self.status_label.setText("Status: Parameters set. Waiting for manual start.") else: @@ -154,6 +155,7 @@ def poll_for_output(self): latest_path = data.get("latest_output_path") if latest_path and latest_path != self.last_known_output: + self.stop_polling() self.last_known_output = latest_path self.output_label.setText(f"Latest Output File:\n{latest_path}") self.status_label.setText("Status: New output received! Inserting clip...") @@ -182,7 +184,6 @@ def enable(self): self.dock_widget.hide() self.app.timeline_widget.context_menu_requested.connect(self.on_timeline_context_menu) - self.client_widget.start_polling() def disable(self): try: @@ -216,6 +217,8 @@ def setup_generator_for_region(self, region): self.active_region = region start_sec, end_sec = region + self.client_widget.check_server_status() + start_data, w, h = self.app.get_frame_data_at_time(start_sec) end_data, _, _ = self.app.get_frame_data_at_time(end_sec) diff --git a/videoeditor/undo.py b/videoeditor/undo.py new file mode 100644 index 000000000..b43f4fb46 --- /dev/null +++ b/videoeditor/undo.py @@ -0,0 +1,114 @@ +import copy +from PyQt6.QtCore import QObject, pyqtSignal + +class UndoCommand: + def __init__(self, description=""): + self.description = description + + def undo(self): + raise NotImplementedError + + def redo(self): + raise NotImplementedError + +class CompositeCommand(UndoCommand): + def __init__(self, description, commands): + super().__init__(description) + self.commands = commands + + def undo(self): + for cmd in reversed(self.commands): + cmd.undo() + + def redo(self): + for cmd in self.commands: + cmd.redo() + +class UndoStack(QObject): + history_changed = pyqtSignal() + timeline_changed = pyqtSignal() + + def __init__(self): + super().__init__() + self.undo_stack = [] + self.redo_stack = [] + + def push(self, command): + self.undo_stack.append(command) + self.redo_stack.clear() + command.redo() + self.history_changed.emit() + self.timeline_changed.emit() + + def undo(self): + if not self.can_undo(): + return + command = self.undo_stack.pop() + self.redo_stack.append(command) + command.undo() + self.history_changed.emit() + self.timeline_changed.emit() + + def redo(self): + if not self.can_redo(): + return + command = self.redo_stack.pop() + self.undo_stack.append(command) + command.redo() + self.history_changed.emit() + self.timeline_changed.emit() + + def can_undo(self): + return bool(self.undo_stack) + + def can_redo(self): + return bool(self.redo_stack) + + def undo_text(self): + return self.undo_stack[-1].description if self.can_undo() else "" + + def redo_text(self): + return self.redo_stack[-1].description if self.can_redo() else "" + +class TimelineStateChangeCommand(UndoCommand): + def __init__(self, description, timeline_model, old_clips, old_v_tracks, old_a_tracks, new_clips, new_v_tracks, new_a_tracks): + super().__init__(description) + self.timeline = timeline_model + self.old_clips_state = old_clips + self.old_v_tracks = old_v_tracks + self.old_a_tracks = old_a_tracks + self.new_clips_state = new_clips + self.new_v_tracks = new_v_tracks + self.new_a_tracks = new_a_tracks + + def undo(self): + self.timeline.clips = self.old_clips_state + self.timeline.num_video_tracks = self.old_v_tracks + self.timeline.num_audio_tracks = self.old_a_tracks + + def redo(self): + self.timeline.clips = self.new_clips_state + self.timeline.num_video_tracks = self.new_v_tracks + self.timeline.num_audio_tracks = self.new_a_tracks + + +class MoveClipsCommand(UndoCommand): + def __init__(self, description, timeline_model, move_data): + super().__init__(description) + self.timeline = timeline_model + self.move_data = move_data + + def _apply_state(self, state_key_prefix): + for data in self.move_data: + clip_id = data['clip_id'] + clip = next((c for c in self.timeline.clips if c.id == clip_id), None) + if clip: + clip.timeline_start_sec = data[f'{state_key_prefix}_start'] + clip.track_index = data[f'{state_key_prefix}_track'] + self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + + def undo(self): + self._apply_state('old') + + def redo(self): + self._apply_state('new') \ No newline at end of file From a21356f4f5aa6fe2a68adf19b33f1929a6bdd24a Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 08:27:09 +1100 Subject: [PATCH 07/67] add ability to drag tracks onto ghost tracks --- videoeditor/main.py | 177 +++++++++++++++++++++++++------------------- 1 file changed, 99 insertions(+), 78 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index a3f155e09..561244779 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -96,7 +96,6 @@ class TimelineWidget(QWidget): remove_track = pyqtSignal(str) operation_finished = pyqtSignal() context_menu_requested = pyqtSignal(QMenu, 'QContextMenuEvent') - clips_moved = pyqtSignal(list) def __init__(self, timeline_model, settings, parent=None): super().__init__(parent) @@ -116,8 +115,10 @@ def __init__(self, timeline_model, settings, parent=None): self.drag_original_clip_states = {} # Store {'clip_id': (start_sec, track_index)} self.selection_drag_start_sec = 0.0 self.drag_selection_start_values = None + self.drag_start_state = None self.highlighted_track_info = None + self.highlighted_ghost_track_info = None self.add_video_track_btn_rect = QRect() self.remove_video_track_btn_rect = QRect() self.add_audio_track_btn_rect = QRect() @@ -292,6 +293,18 @@ def draw_tracks_and_clips(self, painter): highlight_rect = QRect(self.HEADER_WIDTH, y, self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) painter.fillRect(highlight_rect, QColor(255, 255, 0, 40)) + if self.highlighted_ghost_track_info: + track_type, track_index = self.highlighted_ghost_track_info + y = -1 + if track_type == 'video': + y = self.TIMESCALE_HEIGHT + elif track_type == 'audio': + y = self.audio_tracks_y_start + self.timeline.num_audio_tracks * self.TRACK_HEIGHT + + if y != -1: + highlight_rect = QRect(self.HEADER_WIDTH, y, self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(highlight_rect, QColor(255, 255, 0, 40)) + # Draw clips for clip in self.timeline.clips: clip_rect = self.get_clip_rect(clip) @@ -323,17 +336,30 @@ def draw_playhead(self, painter): painter.drawLine(playhead_x, 0, playhead_x, self.height()) def y_to_track_info(self, y): + # Check for ghost video track + if self.TIMESCALE_HEIGHT <= y < self.video_tracks_y_start: + return ('video', self.timeline.num_video_tracks + 1) + + # Check for existing video tracks video_tracks_end_y = self.video_tracks_y_start + self.timeline.num_video_tracks * self.TRACK_HEIGHT if self.video_tracks_y_start <= y < video_tracks_end_y: visual_index = (y - self.video_tracks_y_start) // self.TRACK_HEIGHT track_index = self.timeline.num_video_tracks - visual_index return ('video', track_index) + # Check for existing audio tracks audio_tracks_end_y = self.audio_tracks_y_start + self.timeline.num_audio_tracks * self.TRACK_HEIGHT if self.audio_tracks_y_start <= y < audio_tracks_end_y: visual_index = (y - self.audio_tracks_y_start) // self.TRACK_HEIGHT track_index = visual_index + 1 return ('audio', track_index) + + # Check for ghost audio track + add_audio_btn_y_start = self.audio_tracks_y_start + self.timeline.num_audio_tracks * self.TRACK_HEIGHT + add_audio_btn_y_end = add_audio_btn_y_start + self.TRACK_HEIGHT + if add_audio_btn_y_start <= y < add_audio_btn_y_end: + return ('audio', self.timeline.num_audio_tracks + 1) + return None def get_region_at_pos(self, pos: QPoint): @@ -367,6 +393,7 @@ def mousePressEvent(self, event: QMouseEvent): clip_rect = self.get_clip_rect(clip) if clip_rect.contains(QPointF(event.pos())): self.dragging_clip = clip + self.drag_start_state = self.window()._get_current_timeline_state() self.drag_original_clip_states[clip.id] = (clip.timeline_start_sec, clip.track_index) self.dragging_linked_clip = next((c for c in self.timeline.clips if c.group_id == clip.group_id and c.id != clip.id), None) @@ -427,24 +454,29 @@ def mouseMoveEvent(self, event: QMouseEvent): self.update() elif self.dragging_clip: self.highlighted_track_info = None + self.highlighted_ghost_track_info = None new_track_info = self.y_to_track_info(event.pos().y()) original_start_sec, _ = self.drag_original_clip_states[self.dragging_clip.id] if new_track_info: new_track_type, new_track_index = new_track_info + + is_ghost_track = (new_track_type == 'video' and new_track_index > self.timeline.num_video_tracks) or \ + (new_track_type == 'audio' and new_track_index > self.timeline.num_audio_tracks) + + if is_ghost_track: + self.highlighted_ghost_track_info = new_track_info + else: + self.highlighted_track_info = new_track_info + if new_track_type == self.dragging_clip.track_type: self.dragging_clip.track_index = new_track_index - if self.dragging_linked_clip: - # For now, linked clips move to same-number track index - self.dragging_linked_clip.track_index = new_track_index - delta_x = event.pos().x() - self.drag_start_pos.x() time_delta = delta_x / self.PIXELS_PER_SECOND new_start_time = original_start_sec + time_delta - # Basic collision detection (can be improved) for other_clip in self.timeline.clips: if other_clip.id == self.dragging_clip.id: continue if self.dragging_linked_clip and other_clip.id == self.dragging_linked_clip.id: continue @@ -482,35 +514,27 @@ def mouseReleaseEvent(self, event: QMouseEvent): self.dragging_playhead = False if self.dragging_clip: - move_data = [] - # Check main dragged clip orig_start, orig_track = self.drag_original_clip_states[self.dragging_clip.id] - if orig_start != self.dragging_clip.timeline_start_sec or orig_track != self.dragging_clip.track_index: - move_data.append({ - 'clip_id': self.dragging_clip.id, - 'old_start': orig_start, 'new_start': self.dragging_clip.timeline_start_sec, - 'old_track': orig_track, 'new_track': self.dragging_clip.track_index - }) - # Check linked clip + moved = (orig_start != self.dragging_clip.timeline_start_sec or + orig_track != self.dragging_clip.track_index) + if self.dragging_linked_clip: orig_start_link, orig_track_link = self.drag_original_clip_states[self.dragging_linked_clip.id] - if orig_start_link != self.dragging_linked_clip.timeline_start_sec or orig_track_link != self.dragging_linked_clip.track_index: - move_data.append({ - 'clip_id': self.dragging_linked_clip.id, - 'old_start': orig_start_link, 'new_start': self.dragging_linked_clip.timeline_start_sec, - 'old_track': orig_track_link, 'new_track': self.dragging_linked_clip.track_index - }) + moved = moved or (orig_start_link != self.dragging_linked_clip.timeline_start_sec or + orig_track_link != self.dragging_linked_clip.track_index) + + if moved: + self.window().finalize_clip_drag(self.drag_start_state) - if move_data: - self.clips_moved.emit(move_data) - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) self.highlighted_track_info = None + self.highlighted_ghost_track_info = None self.operation_finished.emit() self.dragging_clip = None self.dragging_linked_clip = None self.drag_original_clip_states.clear() + self.drag_start_state = None self.update() @@ -522,6 +546,8 @@ def dragEnterEvent(self, event): def dragLeaveEvent(self, event): self.drag_over_active = False + self.highlighted_ghost_track_info = None + self.highlighted_track_info = None self.update() def dragMoveEvent(self, event): @@ -543,6 +569,15 @@ def dragMoveEvent(self, event): if track_info: self.drag_over_active = True track_type, track_index = track_info + + is_ghost_track = (track_type == 'video' and track_index > self.timeline.num_video_tracks) or \ + (track_type == 'audio' and track_index > self.timeline.num_audio_tracks) + if is_ghost_track: + self.highlighted_ghost_track_info = track_info + self.highlighted_track_info = None + else: + self.highlighted_ghost_track_info = None + self.highlighted_track_info = track_info width = int(duration * self.PIXELS_PER_SECOND) x = self.sec_to_x(start_sec) @@ -564,11 +599,15 @@ def dragMoveEvent(self, event): self.drag_over_rect = QRectF(x, video_y, width, self.TRACK_HEIGHT) else: self.drag_over_active = False + self.highlighted_ghost_track_info = None + self.highlighted_track_info = None self.update() def dropEvent(self, event): self.drag_over_active = False + self.highlighted_ghost_track_info = None + self.highlighted_track_info = None self.update() mime_data = event.mimeData() @@ -775,7 +814,7 @@ def __init__(self, project_to_load=None): self.timeline = Timeline() self.undo_stack = UndoStack() self.media_pool = [] - self.media_properties = {} # To store duration, has_audio etc. + self.media_properties = {} self.export_thread = None self.export_worker = None self.current_project_path = None @@ -805,7 +844,6 @@ def __init__(self, project_to_load=None): if project_to_load: QTimer.singleShot(100, lambda: self._load_project_from_path(project_to_load)) def _get_current_timeline_state(self): - """Helper to capture a snapshot of the timeline for undo/redo.""" return ( copy.deepcopy(self.timeline.clips), self.timeline.num_video_tracks, @@ -886,7 +924,6 @@ def _connect_signals(self): self.timeline_widget.split_requested.connect(self.split_clip_at_playhead) self.timeline_widget.delete_clip_requested.connect(self.delete_clip) self.timeline_widget.playhead_moved.connect(self.seek_preview) - self.timeline_widget.clips_moved.connect(self.on_clips_moved) self.timeline_widget.split_region_requested.connect(self.on_split_region) self.timeline_widget.split_all_regions_requested.connect(self.on_split_all_regions) self.timeline_widget.join_region_requested.connect(self.on_join_region) @@ -911,10 +948,8 @@ def _connect_signals(self): self.undo_stack.timeline_changed.connect(self.on_timeline_changed_by_undo) def on_timeline_changed_by_undo(self): - """Called when undo/redo modifies the timeline.""" self.prune_empty_tracks() self.timeline_widget.update() - # You may want to update other UI elements here as well self.status_label.setText("Operation undone/redone.") def update_undo_redo_actions(self): @@ -924,13 +959,23 @@ def update_undo_redo_actions(self): self.redo_action.setEnabled(self.undo_stack.can_redo()) self.redo_action.setText(f"Redo {self.undo_stack.redo_text()}" if self.undo_stack.can_redo() else "Redo") - def on_clips_moved(self, move_data): - command = MoveClipsCommand("Move Clip", self.timeline, move_data) - # The change is already applied visually, so we push without redoing - self.undo_stack.undo_stack.append(command) - self.undo_stack.redo_stack.clear() - self.undo_stack.history_changed.emit() - self.status_label.setText("Clip moved.") + def finalize_clip_drag(self, old_state_tuple): + current_clips, _, _ = self._get_current_timeline_state() + + max_v_idx = max([c.track_index for c in current_clips if c.track_type == 'video'] + [1]) + max_a_idx = max([c.track_index for c in current_clips if c.track_type == 'audio'] + [1]) + + if max_v_idx > self.timeline.num_video_tracks: + self.timeline.num_video_tracks = max_v_idx + + if max_a_idx > self.timeline.num_audio_tracks: + self.timeline.num_audio_tracks = max_a_idx + + new_state_tuple = self._get_current_timeline_state() + + command = TimelineStateChangeCommand("Move Clip", self.timeline, *old_state_tuple, *new_state_tuple) + command.undo() + self.undo_stack.push(command) def on_add_to_timeline_at_playhead(self, file_path): media_info = self.media_properties.get(file_path) @@ -953,7 +998,6 @@ def on_add_to_timeline_at_playhead(self, file_path): def prune_empty_tracks(self): pruned_something = False - # Prune Video Tracks while self.timeline.num_video_tracks > 1: highest_track_index = self.timeline.num_video_tracks is_track_occupied = any(c for c in self.timeline.clips @@ -963,8 +1007,7 @@ def prune_empty_tracks(self): else: self.timeline.num_video_tracks -= 1 pruned_something = True - - # Prune Audio Tracks + while self.timeline.num_audio_tracks > 1: highest_track_index = self.timeline.num_audio_tracks is_track_occupied = any(c for c in self.timeline.clips @@ -988,7 +1031,6 @@ def add_track(self, track_type): command = TimelineStateChangeCommand(f"Add {track_type.capitalize()} Track", self.timeline, *old_state, *new_state) self.undo_stack.push(command) - # Manually undo the direct change, because push() will redo() it. command.undo() self.undo_stack.push(command) @@ -999,11 +1041,11 @@ def remove_track(self, track_type): elif track_type == 'audio' and self.timeline.num_audio_tracks > 1: self.timeline.num_audio_tracks -= 1 else: - return # No change made + return new_state = self._get_current_timeline_state() command = TimelineStateChangeCommand(f"Remove {track_type.capitalize()} Track", self.timeline, *old_state, *new_state) - command.undo() # Invert state because we already made the change + command.undo() self.undo_stack.push(command) @@ -1031,7 +1073,7 @@ def _create_menu_bar(self): split_action = QAction("Split Clip at Playhead", self); split_action.triggered.connect(self.split_clip_at_playhead) edit_menu.addAction(split_action) - self.update_undo_redo_actions() # Set initial state + self.update_undo_redo_actions() plugins_menu = menu_bar.addMenu("&Plugins") for name, data in self.plugin_manager.plugins.items(): @@ -1048,7 +1090,7 @@ def _create_menu_bar(self): self.windows_menu = menu_bar.addMenu("&Windows") for key, data in self.managed_widgets.items(): - if data['widget'] is self.preview_widget: continue # Preview is part of splitter, not a dock + if data['widget'] is self.preview_widget: continue action = QAction(data['name'], self, checkable=True) if hasattr(data['widget'], 'visibilityChanged'): action.toggled.connect(data['widget'].setVisible) @@ -1095,10 +1137,6 @@ def _set_project_properties_from_clip(self, source_path): return False def get_frame_data_at_time(self, time_sec): - """ - Plugin API: Extracts raw frame data at a specific time. - Returns a tuple of (bytes, width, height) or (None, 0, 0) on failure. - """ clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) if not clip_at_time: return (None, 0, 0) @@ -1201,7 +1239,7 @@ def _apply_loaded_settings(self): for key, data in self.managed_widgets.items(): if data.get('plugin'): continue is_visible = visibility_settings.get(key, True) - if data['widget'] is not self.preview_widget: # preview handled by splitter state + if data['widget'] is not self.preview_widget: data['widget'].setVisible(is_visible) if data['action']: data['action'].setChecked(is_visible) splitter_state = self.settings.get("splitter_state") @@ -1250,7 +1288,7 @@ def open_project(self): def _load_project_from_path(self, path): try: with open(path, "r") as f: project_data = json.load(f) - self.new_project() # Clear existing state + self.new_project() self.media_pool = project_data.get("media_pool", []) for p in self.media_pool: self._add_media_to_pool(p) @@ -1343,16 +1381,19 @@ def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, c group_id = str(uuid.uuid4()) if video_track_index is not None: + if video_track_index > self.timeline.num_video_tracks: + self.timeline.num_video_tracks = video_track_index video_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, video_track_index, 'video', group_id) self.timeline.add_clip(video_clip) if audio_track_index is not None: + if audio_track_index > self.timeline.num_audio_tracks: + self.timeline.num_audio_tracks = audio_track_index audio_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, audio_track_index, 'audio', group_id) self.timeline.add_clip(audio_clip) new_state = self._get_current_timeline_state() command = TimelineStateChangeCommand("Add Clip", self.timeline, *old_state, *new_state) - # We already performed the action, so we need to undo it before letting the stack redo it. command.undo() self.undo_stack.push(command) @@ -1409,27 +1450,21 @@ def delete_clip(self, clip_to_delete): self.prune_empty_tracks() def _perform_complex_timeline_change(self, description, change_function): - """Wrapper for complex operations to make them undoable.""" old_state = self._get_current_timeline_state() - - # Execute the provided function which will modify the timeline change_function() new_state = self._get_current_timeline_state() - - # If no change occurred, don't add to undo stack if old_state[0] == new_state[0] and old_state[1] == new_state[1] and old_state[2] == new_state[2]: return command = TimelineStateChangeCommand(description, self.timeline, *old_state, *new_state) - # The change was already made, so we need to "undo" it before letting the stack "redo" it. command.undo() self.undo_stack.push(command) def on_split_region(self, region): def action(): start_sec, end_sec = region - clips = list(self.timeline.clips) # Work on a copy + clips = list(self.timeline.clips) for clip in clips: self._split_at_time(clip, end_sec) for clip in clips: self._split_at_time(clip, start_sec) self.timeline_widget.clear_region(region) @@ -1457,18 +1492,15 @@ def action(): duration_to_remove = end_sec - start_sec if duration_to_remove <= 0.01: return - # Split any clips that cross the region boundaries for point in [start_sec, end_sec]: group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - # Remove clips fully inside the region clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] for clip in clips_to_remove: self.timeline.clips.remove(clip) - # Ripple delete: move subsequent clips to the left for clip in self.timeline.clips: if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove @@ -1479,26 +1511,21 @@ def action(): def on_join_all_regions(self, regions): def action(): - # Process regions from end to start to avoid index issues for region in sorted(regions, key=lambda r: r[0], reverse=True): start_sec, end_sec = region duration_to_remove = end_sec - start_sec if duration_to_remove <= 0.01: continue - - # Split clips at boundaries + for point in [start_sec, end_sec]: group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - - # Remove clips inside region clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] for clip in clips_to_remove: try: self.timeline.clips.remove(clip) - except ValueError: pass # Already removed - - # Ripple + except ValueError: pass + for clip in self.timeline.clips: if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove @@ -1513,18 +1540,15 @@ def action(): duration_to_remove = end_sec - start_sec if duration_to_remove <= 0.01: return - # Split any clips that cross the region boundaries for point in [start_sec, end_sec]: group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - # Remove clips fully inside the region clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] for clip in clips_to_remove: self.timeline.clips.remove(clip) - # Ripple delete: move subsequent clips to the left for clip in self.timeline.clips: if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove @@ -1535,24 +1559,21 @@ def action(): def on_delete_all_regions(self, regions): def action(): - # Process regions from end to start to avoid index issues for region in sorted(regions, key=lambda r: r[0], reverse=True): start_sec, end_sec = region duration_to_remove = end_sec - start_sec if duration_to_remove <= 0.01: continue - - # Split clips at boundaries + for point in [start_sec, end_sec]: group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - - # Remove clips inside region + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] for clip in clips_to_remove: try: self.timeline.clips.remove(clip) - except ValueError: pass # Already removed + except ValueError: pass # Ripple for clip in self.timeline.clips: From 354c0d1a40126733bf074a65230148d351504355 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 08:33:02 +1100 Subject: [PATCH 08/67] fix bug dragging ghost tracks from project media --- videoeditor/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 561244779..34d9450c2 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -290,7 +290,7 @@ def draw_tracks_and_clips(self, painter): y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT if y != -1: - highlight_rect = QRect(self.HEADER_WIDTH, y, self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) + highlight_rect = QRect(self.HEADER_WIDTH, int(y), self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) painter.fillRect(highlight_rect, QColor(255, 255, 0, 40)) if self.highlighted_ghost_track_info: @@ -302,10 +302,9 @@ def draw_tracks_and_clips(self, painter): y = self.audio_tracks_y_start + self.timeline.num_audio_tracks * self.TRACK_HEIGHT if y != -1: - highlight_rect = QRect(self.HEADER_WIDTH, y, self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) + highlight_rect = QRect(self.HEADER_WIDTH, int(y), self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) painter.fillRect(highlight_rect, QColor(255, 255, 0, 40)) - # Draw clips for clip in self.timeline.clips: clip_rect = self.get_clip_rect(clip) base_color = QColor("#46A") if clip.track_type == 'video' else QColor("#48C") From cad961803f499533c9cb6fa95424dbb425699bb7 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 12:00:08 +1100 Subject: [PATCH 09/67] add dynamic scroll and dynamic timescale --- videoeditor/main.py | 166 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 139 insertions(+), 27 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 34d9450c2..6aee83fd6 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -77,7 +77,6 @@ def run_export(self): self.finished.emit(f"An exception occurred during export: {e}") class TimelineWidget(QWidget): - PIXELS_PER_SECOND = 50 TIMESCALE_HEIGHT = 30 HEADER_WIDTH = 120 TRACK_HEIGHT = 50 @@ -97,11 +96,18 @@ class TimelineWidget(QWidget): operation_finished = pyqtSignal() context_menu_requested = pyqtSignal(QMenu, 'QContextMenuEvent') - def __init__(self, timeline_model, settings, parent=None): + def __init__(self, timeline_model, settings, project_fps, parent=None): super().__init__(parent) self.timeline = timeline_model self.settings = settings self.playhead_pos_sec = 0.0 + self.scroll_area = None + + self.pixels_per_second = 50.0 + self.max_pixels_per_second = 1.0 # Will be updated by set_project_fps + self.project_fps = 25.0 + self.set_project_fps(project_fps) + self.setMinimumHeight(300) self.setMouseTracking(True) self.setAcceptDrops(True) @@ -132,8 +138,15 @@ def __init__(self, timeline_model, settings, parent=None): self.drag_over_audio_rect = QRectF() self.drag_has_audio = False - def sec_to_x(self, sec): return self.HEADER_WIDTH + int(sec * self.PIXELS_PER_SECOND) - def x_to_sec(self, x): return float(x - self.HEADER_WIDTH) / self.PIXELS_PER_SECOND if x > self.HEADER_WIDTH else 0.0 + def set_project_fps(self, fps): + self.project_fps = fps if fps > 0 else 25.0 + # Set max zoom to be 10 pixels per frame + self.max_pixels_per_second = self.project_fps * 20 + self.pixels_per_second = min(self.pixels_per_second, self.max_pixels_per_second) + self.update() + + def sec_to_x(self, sec): return self.HEADER_WIDTH + int(sec * self.pixels_per_second) + def x_to_sec(self, x): return float(x - self.HEADER_WIDTH) / self.pixels_per_second if x > self.HEADER_WIDTH and self.pixels_per_second > 0 else 0.0 def paintEvent(self, event): painter = QPainter(self) @@ -156,7 +169,7 @@ def paintEvent(self, event): self.draw_playhead(painter) - total_width = self.sec_to_x(self.timeline.get_total_duration()) + 100 + total_width = self.sec_to_x(self.timeline.get_total_duration()) + 200 total_height = self.calculate_total_height() self.setMinimumSize(max(self.parent().width(), total_width), total_height) @@ -225,30 +238,82 @@ def draw_headers(self, painter): painter.drawText(self.add_audio_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "Add Track (+)") painter.restore() + + def _format_timecode(self, seconds): + if abs(seconds) < 1e-9: seconds = 0 + sign = "-" if seconds < 0 else "" + seconds = abs(seconds) + + h = int(seconds / 3600) + m = int((seconds % 3600) / 60) + s = seconds % 60 + + if h > 0: return f"{sign}{h}h:{m:02d}m" + if m > 0 or seconds >= 59.99: return f"{sign}{m}m:{int(round(s)):02d}s" + + precision = 2 if s < 1 else 1 if s < 10 else 0 + val = f"{s:.{precision}f}" + # Remove trailing .0 or .00 + if '.' in val: val = val.rstrip('0').rstrip('.') + return f"{sign}{val}s" def draw_timescale(self, painter): painter.save() painter.setPen(QColor("#AAA")) painter.setFont(QFont("Arial", 8)) font_metrics = QFontMetrics(painter.font()) - + painter.fillRect(QRect(self.HEADER_WIDTH, 0, self.width() - self.HEADER_WIDTH, self.TIMESCALE_HEIGHT), QColor("#222")) painter.drawLine(self.HEADER_WIDTH, self.TIMESCALE_HEIGHT - 1, self.width(), self.TIMESCALE_HEIGHT - 1) - max_time_to_draw = self.x_to_sec(self.width()) - major_interval_sec = 5 - minor_interval_sec = 1 + frame_dur = 1.0 / self.project_fps + intervals = [ + frame_dur, 2*frame_dur, 5*frame_dur, 10*frame_dur, + 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, + 60, 120, 300, 600, 900, 1800, + 3600, 2*3600, 5*3600, 10*3600 + ] + + min_pixel_dist = 70 + major_interval = next((i for i in intervals if i * self.pixels_per_second > min_pixel_dist), intervals[-1]) - for t_sec in range(int(max_time_to_draw) + 2): + minor_interval = 0 + for divisor in [5, 4, 2]: + if (major_interval / divisor) * self.pixels_per_second > 10: + minor_interval = major_interval / divisor + break + + start_sec = self.x_to_sec(self.HEADER_WIDTH) + end_sec = self.x_to_sec(self.width()) + + def draw_ticks(interval, height): + if interval <= 1e-6: return + start_tick_num = int(start_sec / interval) + end_tick_num = int(end_sec / interval) + 1 + for i in range(start_tick_num, end_tick_num + 1): + t_sec = i * interval + x = self.sec_to_x(t_sec) + if x > self.width(): break + if x >= self.HEADER_WIDTH: + painter.drawLine(x, self.TIMESCALE_HEIGHT - height, x, self.TIMESCALE_HEIGHT) + + if frame_dur * self.pixels_per_second > 4: + draw_ticks(frame_dur, 3) + if minor_interval > 0: + draw_ticks(minor_interval, 6) + + start_major_tick = int(start_sec / major_interval) + end_major_tick = int(end_sec / major_interval) + 1 + for i in range(start_major_tick, end_major_tick + 1): + t_sec = i * major_interval x = self.sec_to_x(t_sec) - - if t_sec % major_interval_sec == 0: - painter.drawLine(x, self.TIMESCALE_HEIGHT - 10, x, self.TIMESCALE_HEIGHT - 1) - label = f"{t_sec}s" + if x > self.width() + 50: break + if x >= self.HEADER_WIDTH - 50: + painter.drawLine(x, self.TIMESCALE_HEIGHT - 12, x, self.TIMESCALE_HEIGHT) + label = self._format_timecode(t_sec) label_width = font_metrics.horizontalAdvance(label) - painter.drawText(x - label_width // 2, self.TIMESCALE_HEIGHT - 12, label) - elif t_sec % minor_interval_sec == 0: - painter.drawLine(x, self.TIMESCALE_HEIGHT - 5, x, self.TIMESCALE_HEIGHT - 1) + painter.drawText(x - label_width // 2, self.TIMESCALE_HEIGHT - 14, label) + painter.restore() def get_clip_rect(self, clip): @@ -260,7 +325,7 @@ def get_clip_rect(self, clip): y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT x = self.sec_to_x(clip.timeline_start_sec) - w = int(clip.duration_sec * self.PIXELS_PER_SECOND) + w = int(clip.duration_sec * self.pixels_per_second) clip_height = self.TRACK_HEIGHT - 10 y += (self.TRACK_HEIGHT - clip_height) / 2 return QRectF(x, y, w, clip_height) @@ -323,7 +388,7 @@ def draw_tracks_and_clips(self, painter): def draw_selections(self, painter): for start_sec, end_sec in self.selection_regions: x = self.sec_to_x(start_sec) - w = int((end_sec - start_sec) * self.PIXELS_PER_SECOND) + w = int((end_sec - start_sec) * self.pixels_per_second) selection_rect = QRectF(x, self.TIMESCALE_HEIGHT, w, self.height() - self.TIMESCALE_HEIGHT) painter.fillRect(selection_rect, QColor(100, 100, 255, 80)) painter.setPen(QColor(150, 150, 255, 150)) @@ -371,6 +436,46 @@ def get_region_at_pos(self, pos: QPoint): return region return None + def wheelEvent(self, event: QMouseEvent): + if not self.scroll_area or event.position().x() < self.HEADER_WIDTH: + event.ignore() + return + + scrollbar = self.scroll_area.horizontalScrollBar() + mouse_x_abs = event.position().x() + + time_at_cursor = self.x_to_sec(mouse_x_abs) + + delta = event.angleDelta().y() + zoom_factor = 1.15 + old_pps = self.pixels_per_second + + if delta > 0: new_pps = old_pps * zoom_factor + else: new_pps = old_pps / zoom_factor + + min_pps = 1 / (3600 * 10) # Min zoom of 1px per 10 hours + new_pps = max(min_pps, min(new_pps, self.max_pixels_per_second)) + + if abs(new_pps - old_pps) < 1e-9: + return + + self.pixels_per_second = new_pps + # This will trigger a paintEvent, which resizes the widget. + # The scroll area will update its scrollbar ranges in response. + self.update() + + # The new absolute x-coordinate for the time under the cursor + new_mouse_x_abs = self.sec_to_x(time_at_cursor) + + # The amount the content "shifted" at the cursor's location + shift_amount = new_mouse_x_abs - mouse_x_abs + + # Adjust the scrollbar by this shift amount to keep the content under the cursor + new_scroll_value = scrollbar.value() + shift_amount + scrollbar.setValue(int(new_scroll_value)) + + event.accept() + def mousePressEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton: if event.pos().x() < self.HEADER_WIDTH: @@ -435,7 +540,7 @@ def mouseMoveEvent(self, event: QMouseEvent): if self.dragging_selection_region: delta_x = event.pos().x() - self.drag_start_pos.x() - time_delta = delta_x / self.PIXELS_PER_SECOND + time_delta = delta_x / self.pixels_per_second original_start, original_end = self.drag_selection_start_values duration = original_end - original_start @@ -473,7 +578,7 @@ def mouseMoveEvent(self, event: QMouseEvent): self.dragging_clip.track_index = new_track_index delta_x = event.pos().x() - self.drag_start_pos.x() - time_delta = delta_x / self.PIXELS_PER_SECOND + time_delta = delta_x / self.pixels_per_second new_start_time = original_start_sec + time_delta for other_clip in self.timeline.clips: @@ -504,7 +609,7 @@ def mouseReleaseEvent(self, event: QMouseEvent): self.creating_selection_region = False if self.selection_regions: start, end = self.selection_regions[-1] - if (end - start) * self.PIXELS_PER_SECOND < 2: + if (end - start) * self.pixels_per_second < 2: self.selection_regions.pop() if self.dragging_selection_region: @@ -578,7 +683,7 @@ def dragMoveEvent(self, event): self.highlighted_ghost_track_info = None self.highlighted_track_info = track_info - width = int(duration * self.PIXELS_PER_SECOND) + width = int(duration * self.pixels_per_second) x = self.sec_to_x(start_sec) if track_type == 'video': @@ -864,9 +969,11 @@ def _setup_ui(self): self.preview_widget.setStyleSheet("background-color: black; color: white;") self.splitter.addWidget(self.preview_widget) - self.timeline_widget = TimelineWidget(self.timeline, self.settings, self) + self.timeline_widget = TimelineWidget(self.timeline, self.settings, self.project_fps, self) self.timeline_scroll_area = QScrollArea() - self.timeline_scroll_area.setWidgetResizable(True) + self.timeline_widget.scroll_area = self.timeline_scroll_area + self.timeline_scroll_area.setWidgetResizable(False) + self.timeline_widget.setMinimumWidth(2000) self.timeline_scroll_area.setWidget(self.timeline_widget) self.timeline_scroll_area.setFrameShape(QFrame.Shape.NoFrame) self.timeline_scroll_area.setMinimumHeight(250) @@ -1129,7 +1236,9 @@ def _set_project_properties_from_clip(self, source_path): self.project_width = int(video_stream['width']); self.project_height = int(video_stream['height']) if 'r_frame_rate' in video_stream and video_stream['r_frame_rate'] != '0/0': num, den = map(int, video_stream['r_frame_rate'].split('/')) - if den > 0: self.project_fps = num / den + if den > 0: + self.project_fps = num / den + self.timeline_widget.set_project_fps(self.project_fps) print(f"Project properties set: {self.project_width}x{self.project_height} @ {self.project_fps:.2f} FPS") return True except Exception as e: print(f"Could not probe for project properties: {e}") @@ -1260,7 +1369,10 @@ def open_settings_dialog(self): def new_project(self): self.timeline.clips.clear(); self.timeline.num_video_tracks = 1; self.timeline.num_audio_tracks = 1 self.media_pool.clear(); self.media_properties.clear(); self.project_media_widget.clear_list() - self.current_project_path = None; self.stop_playback(); self.timeline_widget.update() + self.current_project_path = None; self.stop_playback() + self.project_fps = 25.0 + self.timeline_widget.set_project_fps(self.project_fps) + self.timeline_widget.update() self.undo_stack = UndoStack() self.undo_stack.history_changed.connect(self.update_undo_redo_actions) self.update_undo_redo_actions() From eacb49814b7b13845598377c41e785489670fbd9 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 10 Oct 2025 23:21:10 +1100 Subject: [PATCH 10/67] fix ai joiner to start polling for outputs during manual generate, stopped selector sliding around --- videoeditor/main.py | 51 +++++++++++---------- videoeditor/plugins/ai_frame_joiner/main.py | 12 ++--- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 6aee83fd6..0c08e7b9a 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -153,7 +153,11 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.fillRect(self.rect(), QColor("#333")) - self.draw_headers(painter) + h_offset = 0 + if self.scroll_area and self.scroll_area.horizontalScrollBar(): + h_offset = self.scroll_area.horizontalScrollBar().value() + + self.draw_headers(painter, h_offset) self.draw_timescale(painter) self.draw_tracks_and_clips(painter) self.draw_selections(painter) @@ -178,7 +182,7 @@ def calculate_total_height(self): audio_tracks_height = (self.timeline.num_audio_tracks + 1) * self.TRACK_HEIGHT return self.TIMESCALE_HEIGHT + video_tracks_height + self.AUDIO_TRACKS_SEPARATOR_Y + audio_tracks_height + 20 - def draw_headers(self, painter): + def draw_headers(self, painter, h_offset): painter.save() painter.setPen(QColor("#AAA")) header_font = QFont("Arial", 9, QFont.Weight.Bold) @@ -187,9 +191,9 @@ def draw_headers(self, painter): y_cursor = self.TIMESCALE_HEIGHT rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect, QColor("#3a3a3a")) - painter.drawRect(rect) - self.add_video_track_btn_rect = QRect(rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) + painter.fillRect(rect.translated(h_offset, 0), QColor("#3a3a3a")) + painter.drawRect(rect.translated(h_offset, 0)) + self.add_video_track_btn_rect = QRect(h_offset + rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) painter.setFont(button_font) painter.fillRect(self.add_video_track_btn_rect, QColor("#454")) painter.drawText(self.add_video_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "Add Track (+)") @@ -199,13 +203,13 @@ def draw_headers(self, painter): for i in range(self.timeline.num_video_tracks): track_number = self.timeline.num_video_tracks - i rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect, QColor("#444")) - painter.drawRect(rect) + painter.fillRect(rect.translated(h_offset, 0), QColor("#444")) + painter.drawRect(rect.translated(h_offset, 0)) painter.setFont(header_font) - painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, f"Video {track_number}") + painter.drawText(rect.translated(h_offset, 0), Qt.AlignmentFlag.AlignCenter, f"Video {track_number}") if track_number == self.timeline.num_video_tracks and self.timeline.num_video_tracks > 1: - self.remove_video_track_btn_rect = QRect(rect.right() - 25, rect.top() + 5, 20, 20) + self.remove_video_track_btn_rect = QRect(h_offset + rect.right() - 25, rect.top() + 5, 20, 20) painter.setFont(button_font) painter.fillRect(self.remove_video_track_btn_rect, QColor("#833")) painter.drawText(self.remove_video_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "-") @@ -217,22 +221,22 @@ def draw_headers(self, painter): for i in range(self.timeline.num_audio_tracks): track_number = i + 1 rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect, QColor("#444")) - painter.drawRect(rect) + painter.fillRect(rect.translated(h_offset, 0), QColor("#444")) + painter.drawRect(rect.translated(h_offset, 0)) painter.setFont(header_font) - painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, f"Audio {track_number}") + painter.drawText(rect.translated(h_offset, 0), Qt.AlignmentFlag.AlignCenter, f"Audio {track_number}") if track_number == self.timeline.num_audio_tracks and self.timeline.num_audio_tracks > 1: - self.remove_audio_track_btn_rect = QRect(rect.right() - 25, rect.top() + 5, 20, 20) + self.remove_audio_track_btn_rect = QRect(h_offset + rect.right() - 25, rect.top() + 5, 20, 20) painter.setFont(button_font) painter.fillRect(self.remove_audio_track_btn_rect, QColor("#833")) painter.drawText(self.remove_audio_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "-") y_cursor += self.TRACK_HEIGHT rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect, QColor("#3a3a3a")) - painter.drawRect(rect) - self.add_audio_track_btn_rect = QRect(rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) + painter.fillRect(rect.translated(h_offset, 0), QColor("#3a3a3a")) + painter.drawRect(rect.translated(h_offset, 0)) + self.add_audio_track_btn_rect = QRect(h_offset + rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) painter.setFont(button_font) painter.fillRect(self.add_audio_track_btn_rect, QColor("#454")) painter.drawText(self.add_audio_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "Add Track (+)") @@ -477,15 +481,14 @@ def wheelEvent(self, event: QMouseEvent): event.accept() def mousePressEvent(self, event: QMouseEvent): - if event.button() == Qt.MouseButton.LeftButton: - if event.pos().x() < self.HEADER_WIDTH: - # Click in headers area - if self.add_video_track_btn_rect.contains(event.pos()): self.add_track.emit('video') - elif self.remove_video_track_btn_rect.contains(event.pos()): self.remove_track.emit('video') - elif self.add_audio_track_btn_rect.contains(event.pos()): self.add_track.emit('audio') - elif self.remove_audio_track_btn_rect.contains(event.pos()): self.remove_track.emit('audio') - return + if event.pos().x() < self.HEADER_WIDTH + self.scroll_area.horizontalScrollBar().value(): + if self.add_video_track_btn_rect.contains(event.pos()): self.add_track.emit('video') + elif self.remove_video_track_btn_rect.contains(event.pos()): self.remove_track.emit('video') + elif self.add_audio_track_btn_rect.contains(event.pos()): self.add_track.emit('audio') + elif self.remove_audio_track_btn_rect.contains(event.pos()): self.remove_track.emit('audio') + return + if event.button() == Qt.MouseButton.LeftButton: self.dragging_clip = None self.dragging_linked_clip = None self.dragging_playhead = False diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index 56b94efe6..a7c7cd648 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -94,7 +94,6 @@ def set_previews(self, start_pixmap, end_pixmap): def start_polling(self): self.poll_timer.start() - self.check_server_status() def stop_polling(self): self.poll_timer.stop() @@ -111,9 +110,11 @@ def check_server_status(self): try: requests.get(f"{API_BASE_URL}/api/latest_output", timeout=1) self.status_label.setText("Status: Connected to WanGP server.") + return True except requests.exceptions.ConnectionError: self.status_label.setText("Status: Error - Cannot connect to WanGP server.") QMessageBox.critical(self, "Connection Error", f"Could not connect to the WanGP API at {API_BASE_URL}.\n\nPlease ensure wgptool.py is running.") + return False def generate(self): @@ -139,9 +140,9 @@ def generate(self): if response.status_code == 200: if payload['start_generation']: self.status_label.setText("Status: Parameters set. Generation sent. Polling...") - self.start_polling() else: - self.status_label.setText("Status: Parameters set. Waiting for manual start.") + self.status_label.setText("Status: Parameters set. Polling for manually started generation...") + self.start_polling() else: self.handle_api_error(response, "setting parameters") except requests.exceptions.RequestException as e: @@ -216,9 +217,8 @@ def setup_generator_for_region(self, region): self._reset_state() self.active_region = region start_sec, end_sec = region - - self.client_widget.check_server_status() - + if not self.client_widget.check_server_status(): + return start_data, w, h = self.app.get_frame_data_at_time(start_sec) end_data, _, _ = self.app.get_frame_data_at_time(end_sec) From 14fbfaf37b1d221407219aab14c75c0e1110d1c9 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 00:10:08 +1100 Subject: [PATCH 11/67] fix FPS conversions --- main.py | 24 +++++---------------- videoeditor/plugins/ai_frame_joiner/main.py | 8 ++++--- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/main.py b/main.py index 06292b315..0dff699e6 100644 --- a/main.py +++ b/main.py @@ -355,7 +355,6 @@ def _api_generate(self, start_frame, end_frame, duration_sec, model_type, start_ if model_type: self._api_set_model(model_type) - # 2. Set frame inputs if start_frame: self.widgets['mode_s'].setChecked(True) self.widgets['image_start'].setText(start_frame) @@ -363,37 +362,24 @@ def _api_generate(self, start_frame, end_frame, duration_sec, model_type, start_ if end_frame: self.widgets['image_end_checkbox'].setChecked(True) self.widgets['image_end'].setText(end_frame) - - # 3. Calculate video length in frames based on duration and model FPS + if duration_sec is not None: try: duration = float(duration_sec) - - # Get base FPS by parsing the "Force FPS" dropdown's default text (e.g., "Model Default (16 fps)") - base_fps = 16 # Fallback + base_fps = 16 fps_text = self.widgets['force_fps'].itemText(0) match = re.search(r'\((\d+)\s*fps\)', fps_text) if match: base_fps = int(match.group(1)) - # Temporal upsampling creates more frames in post-processing, so we must account for it here. - upsample_setting = self.widgets['temporal_upsampling'].currentData() - multiplier = 1.0 - if upsample_setting == "rife2": - multiplier = 2.0 - elif upsample_setting == "rife4": - multiplier = 4.0 - - # The number of frames the model needs to generate - video_length_frames = int(duration * base_fps * multiplier) + video_length_frames = int(duration * base_fps) self.widgets['video_length'].setValue(video_length_frames) - print(f"API: Calculated video length: {video_length_frames} frames for {duration:.2f}s @ {base_fps*multiplier:.0f} effective FPS.") + print(f"API: Calculated video length: {video_length_frames} frames for {duration:.2f}s @ {base_fps} effective FPS.") except (ValueError, TypeError) as e: print(f"API Error: Invalid duration_sec '{duration_sec}': {e}") - - # 4. Conditionally start generation + if start_generation: self.generate_btn.click() print("API: Generation started.") diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index a7c7cd648..1b20a3b36 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -272,10 +272,12 @@ def insert_generated_clip(self, video_path): return start_sec, end_sec = self.active_region - duration = end_sec - start_sec self.app.status_label.setText(f"Inserting AI clip: {os.path.basename(video_path)}") try: + probe = ffmpeg.probe(video_path) + actual_duration = float(probe['format']['duration']) + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) @@ -291,7 +293,7 @@ def insert_generated_clip(self, video_path): source_path=video_path, timeline_start_sec=start_sec, clip_start_sec=0, - duration_sec=duration, + duration_sec=actual_duration, video_track_index=1, audio_track_index=None ) @@ -301,7 +303,7 @@ def insert_generated_clip(self, video_path): self.app.prune_empty_tracks() except Exception as e: - error_message = f"Error during clip insertion: {e}" + error_message = f"Error during clip insertion/probing: {e}" self.app.status_label.setText(error_message) self.client_widget.status_label.setText("Status: Failed to insert clip.") print(error_message) From e776c35a7b97b7a6ac93eda633f59c31f2eb2c4e Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 00:15:46 +1100 Subject: [PATCH 12/67] fix missing import --- videoeditor/plugins/ai_frame_joiner/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index 1b20a3b36..c81b98fa3 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -4,6 +4,7 @@ import shutil import requests from pathlib import Path +import ffmpeg from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, From 77ca09d3eec6efe522244e0876626c1aa1f13a2f Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 00:50:18 +1100 Subject: [PATCH 13/67] add ability to generate into new track, move statuses to main editor --- videoeditor/plugins/ai_frame_joiner/main.py | 117 +++++++++++--------- 1 file changed, 66 insertions(+), 51 deletions(-) diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index c81b98fa3..3c8ef6157 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -21,6 +21,7 @@ class WgpClientWidget(QWidget): generation_complete = pyqtSignal(str) + status_updated = pyqtSignal(str) def __init__(self): super().__init__() @@ -60,10 +61,6 @@ def __init__(self): self.autostart_checkbox.setChecked(True) self.generate_button = QPushButton("Generate") - - self.status_label = QLabel("Status: Idle") - self.output_label = QLabel("Latest Output File: None") - self.output_label.setWordWrap(True) layout.addLayout(previews_layout) form_layout.addRow("Model Type:", self.model_input) @@ -71,8 +68,7 @@ def __init__(self): form_layout.addRow(self.generate_button) layout.addLayout(form_layout) - layout.addWidget(self.status_label) - layout.addWidget(self.output_label) + layout.addStretch() self.generate_button.clicked.connect(self.generate) @@ -104,16 +100,16 @@ def handle_api_error(self, response, action="performing action"): error_msg = response.json().get("error", "Unknown error") except requests.exceptions.JSONDecodeError: error_msg = response.text - self.status_label.setText(f"Status: Error {action}: {error_msg}") + self.status_updated.emit(f"AI Joiner Error: {error_msg}") QMessageBox.warning(self, "API Error", f"Failed while {action}.\n\nServer response:\n{error_msg}") def check_server_status(self): try: requests.get(f"{API_BASE_URL}/api/latest_output", timeout=1) - self.status_label.setText("Status: Connected to WanGP server.") + self.status_updated.emit("AI Joiner: Connected to WanGP server.") return True except requests.exceptions.ConnectionError: - self.status_label.setText("Status: Error - Cannot connect to WanGP server.") + self.status_updated.emit("AI Joiner Error: Cannot connect to WanGP server.") QMessageBox.critical(self, "Connection Error", f"Could not connect to the WanGP API at {API_BASE_URL}.\n\nPlease ensure wgptool.py is running.") return False @@ -135,19 +131,19 @@ def generate(self): payload['start_generation'] = self.autostart_checkbox.isChecked() - self.status_label.setText("Status: Sending parameters...") + self.status_updated.emit("AI Joiner: Sending parameters...") try: response = requests.post(f"{API_BASE_URL}/api/generate", json=payload) if response.status_code == 200: if payload['start_generation']: - self.status_label.setText("Status: Parameters set. Generation sent. Polling...") + self.status_updated.emit("AI Joiner: Generation sent to server. Polling for output...") else: - self.status_label.setText("Status: Parameters set. Polling for manually started generation...") + self.status_updated.emit("AI Joiner: Parameters set. Polling for manually started generation...") self.start_polling() else: self.handle_api_error(response, "setting parameters") except requests.exceptions.RequestException as e: - self.status_label.setText(f"Status: Connection error: {e}") + self.status_updated.emit(f"AI Joiner Error: Connection error: {e}") def poll_for_output(self): try: @@ -159,15 +155,12 @@ def poll_for_output(self): if latest_path and latest_path != self.last_known_output: self.stop_polling() self.last_known_output = latest_path - self.output_label.setText(f"Latest Output File:\n{latest_path}") - self.status_label.setText("Status: New output received! Inserting clip...") + self.status_updated.emit(f"AI Joiner: New output received! Inserting clip...") self.generation_complete.emit(latest_path) else: - if "Error" not in self.status_label.text() and "waiting" not in self.status_label.text().lower(): - self.status_label.setText("Status: Polling for output...") + self.status_updated.emit("AI Joiner: Polling for output...") except requests.exceptions.RequestException: - if "Error" not in self.status_label.text(): - self.status_label.setText("Status: Polling... (Connection issue)") + self.status_updated.emit("AI Joiner: Polling... (Connection issue)") class Plugin(VideoEditorPlugin): def initialize(self): @@ -177,7 +170,9 @@ def initialize(self): self.dock_widget = None self.active_region = None self.temp_dir = None + self.insert_on_new_track = False self.client_widget.generation_complete.connect(self.insert_generated_clip) + self.client_widget.status_updated.connect(self.update_main_status) def enable(self): if not self.dock_widget: @@ -195,6 +190,9 @@ def disable(self): self._cleanup_temp_dir() self.client_widget.stop_polling() + def update_main_status(self, message): + self.app.status_label.setText(message) + def _cleanup_temp_dir(self): if self.temp_dir and os.path.exists(self.temp_dir): shutil.rmtree(self.temp_dir) @@ -202,9 +200,9 @@ def _cleanup_temp_dir(self): def _reset_state(self): self.active_region = None + self.insert_on_new_track = False self._cleanup_temp_dir() - self.client_widget.status_label.setText("Status: Idle") - self.client_widget.output_label.setText("Latest Output File: None") + self.update_main_status("AI Joiner: Idle") self.client_widget.set_previews(None, None) def on_timeline_context_menu(self, menu, event): @@ -212,14 +210,20 @@ def on_timeline_context_menu(self, menu, event): if region: menu.addSeparator() action = menu.addAction("Join Frames With AI") - action.triggered.connect(lambda: self.setup_generator_for_region(region)) + action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) + + action_new_track = menu.addAction("Join Frames With AI (New Track)") + action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) - def setup_generator_for_region(self, region): + def setup_generator_for_region(self, region, on_new_track=False): self._reset_state() self.active_region = region + self.insert_on_new_track = on_new_track + start_sec, end_sec = region if not self.client_widget.check_server_status(): return + start_data, w, h = self.app.get_frame_data_at_time(start_sec) end_data, _, _ = self.app.get_frame_data_at_time(end_sec) @@ -258,55 +262,66 @@ def setup_generator_for_region(self, region): self.client_widget.start_frame_input.setText(start_img_path) self.client_widget.end_frame_input.setText(end_img_path) - self.client_widget.status_label.setText(f"Status: Ready for region {start_sec:.2f}s - {end_sec:.2f}s") + self.update_main_status(f"AI Joiner: Ready for region {start_sec:.2f}s - {end_sec:.2f}s") self.dock_widget.show() self.dock_widget.raise_() def insert_generated_clip(self, video_path): if not self.active_region: - self.client_widget.status_label.setText("Status: Error - No active region to insert into.") + self.update_main_status("AI Joiner Error: No active region to insert into.") return if not os.path.exists(video_path): - self.client_widget.status_label.setText(f"Status: Error - Output file not found: {video_path}") + self.update_main_status(f"AI Joiner Error: Output file not found: {video_path}") return start_sec, end_sec = self.active_region - self.app.status_label.setText(f"Inserting AI clip: {os.path.basename(video_path)}") + self.update_main_status(f"AI Joiner: Inserting clip {os.path.basename(video_path)}") try: probe = ffmpeg.probe(video_path) actual_duration = float(probe['format']['duration']) - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) + if self.insert_on_new_track: + self.app.add_track('video') + new_track_index = self.app.timeline.num_video_tracks + self.app._add_clip_to_timeline( + source_path=video_path, + timeline_start_sec=start_sec, + clip_start_sec=0, + duration_sec=actual_duration, + video_track_index=new_track_index, + audio_track_index=None + ) + self.update_main_status("AI clip inserted successfully on new track.") + else: + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) + + clips_to_remove = [ + c for c in self.app.timeline.clips + if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec + ] + for clip in clips_to_remove: + if clip in self.app.timeline.clips: + self.app.timeline.clips.remove(clip) + + self.app._add_clip_to_timeline( + source_path=video_path, + timeline_start_sec=start_sec, + clip_start_sec=0, + duration_sec=actual_duration, + video_track_index=1, + audio_track_index=None + ) + self.update_main_status("AI clip inserted successfully.") - clips_to_remove = [ - c for c in self.app.timeline.clips - if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec - ] - for clip in clips_to_remove: - if clip in self.app.timeline.clips: - self.app.timeline.clips.remove(clip) - - self.app._add_clip_to_timeline( - source_path=video_path, - timeline_start_sec=start_sec, - clip_start_sec=0, - duration_sec=actual_duration, - video_track_index=1, - audio_track_index=None - ) - - self.app.status_label.setText("AI clip inserted successfully.") - self.client_widget.status_label.setText("Status: Success! Clip inserted.") self.app.prune_empty_tracks() except Exception as e: - error_message = f"Error during clip insertion/probing: {e}" - self.app.status_label.setText(error_message) - self.client_widget.status_label.setText("Status: Failed to insert clip.") + error_message = f"AI Joiner Error during clip insertion/probing: {e}" + self.update_main_status(error_message) print(error_message) finally: From 3438e8fd833392c0b6e13770685ac511f1a45b94 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 01:30:07 +1100 Subject: [PATCH 14/67] add image and audio media types --- videoeditor/main.py | 291 ++++++++++++++------ videoeditor/plugins/ai_frame_joiner/main.py | 2 + 2 files changed, 209 insertions(+), 84 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 0c08e7b9a..49ac27b13 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -19,7 +19,7 @@ from undo import UndoStack, TimelineStateChangeCommand, MoveClipsCommand class TimelineClip: - def __init__(self, source_path, timeline_start_sec, clip_start_sec, duration_sec, track_index, track_type, group_id): + def __init__(self, source_path, timeline_start_sec, clip_start_sec, duration_sec, track_index, track_type, media_type, group_id): self.id = str(uuid.uuid4()) self.source_path = source_path self.timeline_start_sec = timeline_start_sec @@ -27,6 +27,7 @@ def __init__(self, source_path, timeline_start_sec, clip_start_sec, duration_sec self.duration_sec = duration_sec self.track_index = track_index self.track_type = track_type + self.media_type = media_type self.group_id = group_id @property @@ -136,7 +137,6 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.drag_over_active = False self.drag_over_rect = QRectF() self.drag_over_audio_rect = QRectF() - self.drag_has_audio = False def set_project_fps(self, fps): self.project_fps = fps if fps > 0 else 25.0 @@ -163,12 +163,12 @@ def paintEvent(self, event): self.draw_selections(painter) if self.drag_over_active: - painter.fillRect(self.drag_over_rect, QColor(0, 255, 0, 80)) - if self.drag_has_audio: - painter.fillRect(self.drag_over_audio_rect, QColor(0, 255, 0, 80)) painter.setPen(QColor(0, 255, 0, 150)) - painter.drawRect(self.drag_over_rect) - if self.drag_has_audio: + if not self.drag_over_rect.isNull(): + painter.fillRect(self.drag_over_rect, QColor(0, 255, 0, 80)) + painter.drawRect(self.drag_over_rect) + if not self.drag_over_audio_rect.isNull(): + painter.fillRect(self.drag_over_audio_rect, QColor(0, 255, 0, 80)) painter.drawRect(self.drag_over_audio_rect) self.draw_playhead(painter) @@ -376,7 +376,12 @@ def draw_tracks_and_clips(self, painter): for clip in self.timeline.clips: clip_rect = self.get_clip_rect(clip) - base_color = QColor("#46A") if clip.track_type == 'video' else QColor("#48C") + base_color = QColor("#46A") # Default video + if clip.media_type == 'image': + base_color = QColor("#4A6") # Greenish for images + elif clip.track_type == 'audio': + base_color = QColor("#48C") # Bluish for audio + color = QColor("#5A9") if self.dragging_clip and self.dragging_clip.id == clip.id else base_color painter.fillRect(clip_rect, color) painter.setPen(QPen(QColor("#FFF"), 1)) @@ -667,47 +672,55 @@ def dragMoveEvent(self, event): json_data = json.loads(mime_data.data('application/x-vnd.video.filepath').data().decode('utf-8')) duration = json_data['duration'] - self.drag_has_audio = json_data['has_audio'] + media_type = json_data['media_type'] + has_audio = json_data['has_audio'] pos = event.position() - track_info = self.y_to_track_info(pos.y()) start_sec = self.x_to_sec(pos.x()) + track_info = self.y_to_track_info(pos.y()) + + self.drag_over_rect = QRectF() + self.drag_over_audio_rect = QRectF() + self.drag_over_active = False + self.highlighted_ghost_track_info = None + self.highlighted_track_info = None if track_info: self.drag_over_active = True track_type, track_index = track_info - + is_ghost_track = (track_type == 'video' and track_index > self.timeline.num_video_tracks) or \ (track_type == 'audio' and track_index > self.timeline.num_audio_tracks) if is_ghost_track: self.highlighted_ghost_track_info = track_info - self.highlighted_track_info = None else: - self.highlighted_ghost_track_info = None self.highlighted_track_info = track_info width = int(duration * self.pixels_per_second) x = self.sec_to_x(start_sec) - if track_type == 'video': - visual_index = self.timeline.num_video_tracks - track_index - y = self.video_tracks_y_start + visual_index * self.TRACK_HEIGHT - self.drag_over_rect = QRectF(x, y, width, self.TRACK_HEIGHT) - if self.drag_has_audio: - audio_y = self.audio_tracks_y_start # Default to track 1 - self.drag_over_audio_rect = QRectF(x, audio_y, width, self.TRACK_HEIGHT) + video_y, audio_y = -1, -1 + + if media_type in ['video', 'image']: + if track_type == 'video': + visual_index = self.timeline.num_video_tracks - track_index + video_y = self.video_tracks_y_start + visual_index * self.TRACK_HEIGHT + if has_audio: + audio_y = self.audio_tracks_y_start + elif track_type == 'audio' and has_audio: + visual_index = track_index - 1 + audio_y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT + video_y = self.video_tracks_y_start + (self.timeline.num_video_tracks - 1) * self.TRACK_HEIGHT + + elif media_type == 'audio': + if track_type == 'audio': + visual_index = track_index - 1 + audio_y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT - elif track_type == 'audio': - visual_index = track_index - 1 - y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT - self.drag_over_audio_rect = QRectF(x, y, width, self.TRACK_HEIGHT) - # For now, just show video on track 1 if dragging onto audio - video_y = self.video_tracks_y_start + (self.timeline.num_video_tracks - 1) * self.TRACK_HEIGHT + if video_y != -1: self.drag_over_rect = QRectF(x, video_y, width, self.TRACK_HEIGHT) - else: - self.drag_over_active = False - self.highlighted_ghost_track_info = None - self.highlighted_track_info = None + if audio_y != -1: + self.drag_over_audio_rect = QRectF(x, audio_y, width, self.TRACK_HEIGHT) self.update() @@ -725,6 +738,7 @@ def dropEvent(self, event): file_path = json_data['path'] duration = json_data['duration'] has_audio = json_data['has_audio'] + media_type = json_data['media_type'] pos = event.position() start_sec = self.x_to_sec(pos.x()) @@ -734,19 +748,32 @@ def dropEvent(self, event): return drop_track_type, drop_track_index = track_info - video_track_idx = 1 - audio_track_idx = 1 if has_audio else None - - if drop_track_type == 'video': - video_track_idx = drop_track_index - elif drop_track_type == 'audio': - audio_track_idx = drop_track_index + video_track_idx = None + audio_track_idx = None + + if media_type == 'image': + if drop_track_type == 'video': + video_track_idx = drop_track_index + elif media_type == 'audio': + if drop_track_type == 'audio': + audio_track_idx = drop_track_index + elif media_type == 'video': + if drop_track_type == 'video': + video_track_idx = drop_track_index + if has_audio: audio_track_idx = 1 + elif drop_track_type == 'audio' and has_audio: + audio_track_idx = drop_track_index + video_track_idx = 1 + + if video_track_idx is None and audio_track_idx is None: + return main_window = self.window() main_window._add_clip_to_timeline( source_path=file_path, timeline_start_sec=start_sec, duration_sec=duration, + media_type=media_type, clip_start_sec=0, video_track_index=video_track_idx, audio_track_index=audio_track_idx @@ -847,7 +874,8 @@ def startDrag(self, supportedActions): payload = { "path": path, "duration": media_info['duration'], - "has_audio": media_info['has_audio'] + "has_audio": media_info['has_audio'], + "media_type": media_info['media_type'] } mime_data.setData('application/x-vnd.video.filepath', QByteArray(json.dumps(payload).encode('utf-8'))) @@ -1049,7 +1077,7 @@ def _connect_signals(self): self.frame_forward_button.clicked.connect(lambda: self.step_frame(1)) self.playback_timer.timeout.connect(self.advance_playback_frame) - self.project_media_widget.add_media_requested.connect(self.add_video_clip) + self.project_media_widget.add_media_requested.connect(self.add_media_files) self.project_media_widget.media_removed.connect(self.on_media_removed_from_pool) self.project_media_widget.add_to_timeline_requested.connect(self.on_add_to_timeline_at_playhead) @@ -1095,14 +1123,19 @@ def on_add_to_timeline_at_playhead(self, file_path): playhead_pos = self.timeline_widget.playhead_pos_sec duration = media_info['duration'] has_audio = media_info['has_audio'] + media_type = media_info['media_type'] + + video_track = 1 if media_type in ['video', 'image'] else None + audio_track = 1 if has_audio else None self._add_clip_to_timeline( source_path=file_path, timeline_start_sec=playhead_pos, duration_sec=duration, + media_type=media_type, clip_start_sec=0.0, - video_track_index=1, - audio_track_index=1 if has_audio else None + video_track_index=video_track, + audio_track_index=audio_track ) def prune_empty_tracks(self): @@ -1165,13 +1198,13 @@ def _create_menu_bar(self): open_action = QAction("&Open Project...", self); open_action.triggered.connect(self.open_project) self.recent_menu = file_menu.addMenu("Recent") save_action = QAction("&Save Project As...", self); save_action.triggered.connect(self.save_project_as) - add_video_action = QAction("&Add Media to Project...", self); add_video_action.triggered.connect(self.add_video_clip) + add_media_action = QAction("&Add Media to Project...", self); add_media_action.triggered.connect(self.add_media_files) export_action = QAction("&Export Video...", self); export_action.triggered.connect(self.export_video) settings_action = QAction("Se&ttings...", self); settings_action.triggered.connect(self.open_settings_dialog) exit_action = QAction("E&xit", self); exit_action.triggered.connect(self.close) file_menu.addAction(new_action); file_menu.addAction(open_action); file_menu.addSeparator() file_menu.addAction(save_action) - file_menu.addSeparator(); file_menu.addAction(add_video_action); file_menu.addAction(export_action) + file_menu.addSeparator(); file_menu.addAction(add_media_action); file_menu.addAction(export_action) file_menu.addSeparator(); file_menu.addAction(settings_action); file_menu.addSeparator(); file_menu.addAction(exit_action) self._update_recent_files_menu() @@ -1252,13 +1285,24 @@ def get_frame_data_at_time(self, time_sec): if not clip_at_time: return (None, 0, 0) try: - clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec - out, _ = ( - ffmpeg - .input(clip_at_time.source_path, ss=clip_time) - .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') - .run(capture_stdout=True, quiet=True) - ) + w, h = self.project_width, self.project_height + if clip_at_time.media_type == 'image': + out, _ = ( + ffmpeg + .input(clip_at_time.source_path) + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') + .run(capture_stdout=True, quiet=True) + ) + else: + clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec + out, _ = ( + ffmpeg + .input(clip_at_time.source_path, ss=clip_time) + .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') + .run(capture_stdout=True, quiet=True) + ) return (out, self.project_width, self.project_height) except ffmpeg.Error as e: print(f"Error extracting frame data: {e.stderr}") @@ -1269,8 +1313,19 @@ def get_frame_at_time(self, time_sec): black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) if not clip_at_time: return black_pixmap try: - clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec - out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time).output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24').run(capture_stdout=True, quiet=True)) + w, h = self.project_width, self.project_height + if clip_at_time.media_type == 'image': + out, _ = ( + ffmpeg.input(clip_at_time.source_path) + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') + .run(capture_stdout=True, quiet=True) + ) + else: + clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec + out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time).output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24').run(capture_stdout=True, quiet=True)) + image = QImage(out, self.project_width, self.project_height, QImage.Format.Format_RGB888) return QPixmap.fromImage(image) except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return black_pixmap @@ -1303,14 +1358,32 @@ def advance_playback_frame(self): frame_duration = 1.0 / self.project_fps new_time = self.timeline_widget.playhead_pos_sec + frame_duration if new_time > self.timeline.get_total_duration(): self.stop_playback(); return - self.timeline_widget.playhead_pos_sec = new_time; self.timeline_widget.update() + + self.timeline_widget.playhead_pos_sec = new_time + self.timeline_widget.update() + clip_at_new_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= new_time < c.timeline_end_sec), None) + if not clip_at_new_time: self._stop_playback_stream() black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) scaled_pixmap = black_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - self.preview_widget.setPixmap(scaled_pixmap); return - if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: self._start_playback_stream_at(new_time) + self.preview_widget.setPixmap(scaled_pixmap) + return + + if clip_at_new_time.media_type == 'image': + if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: + self._stop_playback_stream() + self.playback_clip = clip_at_new_time + frame_pixmap = self.get_frame_at_time(new_time) + if frame_pixmap: + scaled_pixmap = frame_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.preview_widget.setPixmap(scaled_pixmap) + return + + if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: + self._start_playback_stream_at(new_time) + if self.playback_process: frame_size = self.project_width * self.project_height * 3 frame_bytes = self.playback_process.stdout.read(frame_size) @@ -1319,7 +1392,8 @@ def advance_playback_frame(self): pixmap = QPixmap.fromImage(image) scaled_pixmap = pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) self.preview_widget.setPixmap(scaled_pixmap) - else: self._stop_playback_stream() + else: + self._stop_playback_stream() def _load_settings(self): self.settings_file_was_loaded = False @@ -1386,7 +1460,7 @@ def save_project_as(self): if not path: return project_data = { "media_pool": self.media_pool, - "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "group_id": c.group_id} for c in self.timeline.clips], + "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "media_type": c.media_type, "group_id": c.group_id} for c in self.timeline.clips], "settings": {"num_video_tracks": self.timeline.num_video_tracks, "num_audio_tracks": self.timeline.num_audio_tracks} } try: @@ -1410,6 +1484,14 @@ def _load_project_from_path(self, path): for clip_data in project_data["clips"]: if not os.path.exists(clip_data["source_path"]): self.status_label.setText(f"Error: Missing media file {clip_data['source_path']}"); self.new_project(); return + + if 'media_type' not in clip_data: + ext = os.path.splitext(clip_data['source_path'])[1].lower() + if ext in ['.mp3', '.wav', '.m4a', '.aac']: + clip_data['media_type'] = 'audio' + else: + clip_data['media_type'] = 'video' + self.timeline.add_clip(TimelineClip(**clip_data)) project_settings = project_data.get("settings", {}) @@ -1417,7 +1499,9 @@ def _load_project_from_path(self, path): self.timeline.num_audio_tracks = project_settings.get("num_audio_tracks", 1) self.current_project_path = path - if self.timeline.clips: self._set_project_properties_from_clip(self.timeline.clips[0].source_path) + video_clips = [c for c in self.timeline.clips if c.media_type == 'video'] + if video_clips: + self._set_project_properties_from_clip(video_clips[0].source_path) self.prune_empty_tracks() self.timeline_widget.update(); self.stop_playback() self.status_label.setText(f"Project '{os.path.basename(path)}' loaded.") @@ -1445,14 +1529,31 @@ def _add_media_to_pool(self, file_path): if file_path in self.media_pool: return True try: self.status_label.setText(f"Probing {os.path.basename(file_path)}..."); QApplication.processEvents() - probe = ffmpeg.probe(file_path) - video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) - if not video_stream: raise ValueError("No video stream found.") - duration = float(video_stream.get('duration', probe['format'].get('duration', 0))) - has_audio = any(s['codec_type'] == 'audio' for s in probe['streams']) + file_ext = os.path.splitext(file_path)[1].lower() + media_info = {} - self.media_properties[file_path] = {'duration': duration, 'has_audio': has_audio} + if file_ext in ['.png', '.jpg', '.jpeg']: + media_info['media_type'] = 'image' + media_info['duration'] = 5.0 + media_info['has_audio'] = False + else: + probe = ffmpeg.probe(file_path) + video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) + audio_stream = next((s for s in probe['streams'] if s['codec_type'] == 'audio'), None) + + if video_stream: + media_info['media_type'] = 'video' + media_info['duration'] = float(video_stream.get('duration', probe['format'].get('duration', 0))) + media_info['has_audio'] = audio_stream is not None + elif audio_stream: + media_info['media_type'] = 'audio' + media_info['duration'] = float(audio_stream.get('duration', probe['format'].get('duration', 0))) + media_info['has_audio'] = True + else: + raise ValueError("No video or audio stream found.") + + self.media_properties[file_path] = media_info self.media_pool.append(file_path) self.project_media_widget.add_media_item(file_path) @@ -1477,33 +1578,42 @@ def on_media_removed_from_pool(self, file_path): self.undo_stack.push(command) - def add_video_clip(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Open Video File", "", "Video Files (*.mp4 *.mov *.avi)") - if not file_path: return + def add_media_files(self): + file_paths, _ = QFileDialog.getOpenFileNames(self, "Open Media Files", "", "All Supported Files (*.mp4 *.mov *.avi *.png *.jpg *.jpeg *.mp3 *.wav);;Video Files (*.mp4 *.mov *.avi);;Image Files (*.png *.jpg *.jpeg);;Audio Files (*.mp3 *.wav)") + if not file_paths: return + self.media_dock.show() - if not self.timeline.clips and not self.media_pool: - if not self._set_project_properties_from_clip(file_path): - self.status_label.setText("Error: Could not determine video properties from file."); return - - if self._add_media_to_pool(file_path): - self.status_label.setText(f"Added {os.path.basename(file_path)} to project media.") - else: - self.status_label.setText(f"Failed to add {os.path.basename(file_path)}.") + first_video_added = any(c.media_type == 'video' for c in self.timeline.clips) + + for file_path in file_paths: + if not self.timeline.clips and not self.media_pool and not first_video_added: + ext = os.path.splitext(file_path)[1].lower() + if ext not in ['.png', '.jpg', '.jpeg', '.mp3', '.wav']: + if self._set_project_properties_from_clip(file_path): + first_video_added = True + else: + self.status_label.setText("Error: Could not determine video properties from file."); + continue + + if self._add_media_to_pool(file_path): + self.status_label.setText(f"Added {os.path.basename(file_path)} to project media.") + else: + self.status_label.setText(f"Failed to add {os.path.basename(file_path)}.") - def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, clip_start_sec=0.0, video_track_index=None, audio_track_index=None): + def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, media_type, clip_start_sec=0.0, video_track_index=None, audio_track_index=None): old_state = self._get_current_timeline_state() group_id = str(uuid.uuid4()) if video_track_index is not None: if video_track_index > self.timeline.num_video_tracks: self.timeline.num_video_tracks = video_track_index - video_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, video_track_index, 'video', group_id) + video_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, video_track_index, 'video', media_type, group_id) self.timeline.add_clip(video_clip) if audio_track_index is not None: if audio_track_index > self.timeline.num_audio_tracks: self.timeline.num_audio_tracks = audio_track_index - audio_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, audio_track_index, 'audio', group_id) + audio_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, audio_track_index, 'audio', media_type, group_id) self.timeline.add_clip(audio_clip) new_state = self._get_current_timeline_state() @@ -1517,7 +1627,7 @@ def _split_at_time(self, clip_to_split, time_sec, new_group_id=None): orig_dur = clip_to_split.duration_sec group_id_for_new_clip = new_group_id if new_group_id is not None else clip_to_split.group_id - new_clip = TimelineClip(clip_to_split.source_path, time_sec, clip_to_split.clip_start_sec + split_point, orig_dur - split_point, clip_to_split.track_index, clip_to_split.track_type, group_id_for_new_clip) + new_clip = TimelineClip(clip_to_split.source_path, time_sec, clip_to_split.clip_start_sec + split_point, orig_dur - split_point, clip_to_split.track_index, clip_to_split.track_type, clip_to_split.media_type, group_id_for_new_clip) clip_to_split.duration_sec = split_point self.timeline.add_clip(new_clip) return True @@ -1707,8 +1817,8 @@ def export_video(self): w, h, fr_str, total_dur = self.project_width, self.project_height, str(self.project_fps), self.timeline.get_total_duration() sample_rate, channel_layout = '44100', 'stereo' - input_files = {clip.source_path: ffmpeg.input(clip.source_path) for clip in self.timeline.clips} - + input_streams = {} + last_video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr_str}:d={total_dur}', f='lavfi') for i in range(self.timeline.num_video_tracks): track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'video' and c.track_index == i + 1], key=lambda c: c.timeline_start_sec) @@ -1722,8 +1832,18 @@ def export_video(self): if gap > 0.01: track_segments.append(ffmpeg.input(f'color=c=black@0.0:s={w}x{h}:r={fr_str}:d={gap}', f='lavfi').filter('format', pix_fmts='rgba')) - v_seg = (input_files[clip.source_path].video.trim(start=clip.clip_start_sec, duration=clip.duration_sec).setpts('PTS-STARTPTS') - .filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black').filter('format', pix_fmts='rgba')) + if clip.source_path not in input_streams: + if clip.media_type == 'image': + input_streams[clip.source_path] = ffmpeg.input(clip.source_path, loop=1, framerate=self.project_fps) + else: + input_streams[clip.source_path] = ffmpeg.input(clip.source_path) + + if clip.media_type == 'image': + v_seg = (input_streams[clip.source_path].video.trim(duration=clip.duration_sec).setpts('PTS-STARTPTS')) + else: # video + v_seg = (input_streams[clip.source_path].video.trim(start=clip.clip_start_sec, duration=clip.duration_sec).setpts('PTS-STARTPTS')) + + v_seg = (v_seg.filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black').filter('format', pix_fmts='rgba')) track_segments.append(v_seg) last_end = clip.timeline_end_sec @@ -1740,11 +1860,14 @@ def export_video(self): last_end = track_clips[0].timeline_start_sec for clip in track_clips: + if clip.source_path not in input_streams: + input_streams[clip.source_path] = ffmpeg.input(clip.source_path) + gap = clip.timeline_start_sec - last_end if gap > 0.01: track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap}', f='lavfi')) - a_seg = input_files[clip.source_path].audio.filter('atrim', start=clip.clip_start_sec, duration=clip.duration_sec).filter('asetpts', 'PTS-STARTPTS') + a_seg = input_streams[clip.source_path].audio.filter('atrim', start=clip.clip_start_sec, duration=clip.duration_sec).filter('asetpts', 'PTS-STARTPTS') track_segments.append(a_seg) last_end = clip.timeline_end_sec diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index 3c8ef6157..d66dda353 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -291,6 +291,7 @@ def insert_generated_clip(self, video_path): timeline_start_sec=start_sec, clip_start_sec=0, duration_sec=actual_duration, + media_type='video', video_track_index=new_track_index, audio_track_index=None ) @@ -312,6 +313,7 @@ def insert_generated_clip(self, video_path): timeline_start_sec=start_sec, clip_start_sec=0, duration_sec=actual_duration, + media_type='video', video_track_index=1, audio_track_index=None ) From a5b51dfb1207ebd64f04f96e61d569029aa23294 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 02:10:56 +1100 Subject: [PATCH 15/67] add ability to resize clips --- videoeditor/main.py | 123 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 49ac27b13..3ce1bccde 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -11,7 +11,7 @@ QScrollArea, QFrame, QProgressBar, QDialog, QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget, QListWidget, QListWidgetItem, QMessageBox) from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, - QPixmap, QImage, QDrag) + QPixmap, QImage, QDrag, QCursor) from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, pyqtSignal, QTimer, QByteArray, QMimeData) @@ -82,6 +82,7 @@ class TimelineWidget(QWidget): HEADER_WIDTH = 120 TRACK_HEIGHT = 50 AUDIO_TRACKS_SEPARATOR_Y = 15 + RESIZE_HANDLE_WIDTH = 8 split_requested = pyqtSignal(object) delete_clip_requested = pyqtSignal(object) @@ -124,6 +125,10 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.drag_selection_start_values = None self.drag_start_state = None + self.resizing_clip = None + self.resize_edge = None # 'left' or 'right' + self.resize_start_pos = QPoint() + self.highlighted_track_info = None self.highlighted_ghost_track_info = None self.add_video_track_btn_rect = QRect() @@ -499,8 +504,28 @@ def mousePressEvent(self, event: QMouseEvent): self.dragging_playhead = False self.dragging_selection_region = None self.creating_selection_region = False + self.resizing_clip = None + self.resize_edge = None self.drag_original_clip_states.clear() + # Check for resize handles first + for clip in reversed(self.timeline.clips): + clip_rect = self.get_clip_rect(clip) + if abs(event.pos().x() - clip_rect.left()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.left(), event.pos().y())): + self.resizing_clip = clip + self.resize_edge = 'left' + break + elif abs(event.pos().x() - clip_rect.right()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.right(), event.pos().y())): + self.resizing_clip = clip + self.resize_edge = 'right' + break + + if self.resizing_clip: + self.drag_start_state = self.window()._get_current_timeline_state() + self.resize_start_pos = event.pos() + self.update() + return + for clip in reversed(self.timeline.clips): clip_rect = self.get_clip_rect(clip) if clip_rect.contains(QPointF(event.pos())): @@ -538,6 +563,77 @@ def mousePressEvent(self, event: QMouseEvent): self.update() def mouseMoveEvent(self, event: QMouseEvent): + if self.resizing_clip: + delta_x = event.pos().x() - self.resize_start_pos.x() + time_delta = delta_x / self.pixels_per_second + min_duration = 1.0 / self.project_fps + + media_props = self.window().media_properties.get(self.resizing_clip.source_path) + source_duration = media_props['duration'] if media_props else float('inf') + + if self.resize_edge == 'left': + original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_sec + original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_sec + original_clip_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].clip_start_sec + + new_start_sec = original_start + time_delta + + # Clamp to not move past the end of the clip + if new_start_sec > original_start + original_duration - min_duration: + new_start_sec = original_start + original_duration - min_duration + + # Clamp to zero + new_start_sec = max(0, new_start_sec) + + # For video/audio, don't allow extending beyond the source start + if self.resizing_clip.media_type != 'image': + if new_start_sec < original_start - original_clip_start: + new_start_sec = original_start - original_clip_start + + new_duration = (original_start + original_duration) - new_start_sec + # FIX: Correctly calculate the new clip start by ADDING the time shift + new_clip_start = original_clip_start + (new_start_sec - original_start) + + if new_duration < min_duration: + new_duration = min_duration + new_start_sec = (original_start + original_duration) - new_duration + # FIX: Apply the same corrected logic here after clamping duration + new_clip_start = original_clip_start + (new_start_sec - original_start) + + self.resizing_clip.timeline_start_sec = new_start_sec + self.resizing_clip.duration_sec = new_duration + self.resizing_clip.clip_start_sec = new_clip_start + + elif self.resize_edge == 'right': + original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_sec + new_duration = original_duration + time_delta + + if new_duration < min_duration: + new_duration = min_duration + + # For video/audio, don't allow extending beyond the source end + if self.resizing_clip.media_type != 'image': + if self.resizing_clip.clip_start_sec + new_duration > source_duration: + new_duration = source_duration - self.resizing_clip.clip_start_sec + + self.resizing_clip.duration_sec = new_duration + + self.update() + return + + # Update cursor for resizing + if not self.dragging_clip and not self.dragging_playhead and not self.creating_selection_region: + cursor_set = False + for clip in self.timeline.clips: + clip_rect = self.get_clip_rect(clip) + if (abs(event.pos().x() - clip_rect.left()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.left(), event.pos().y()))) or \ + (abs(event.pos().x() - clip_rect.right()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.right(), event.pos().y()))): + self.setCursor(Qt.CursorShape.SizeHorCursor) + cursor_set = True + break + if not cursor_set: + self.unsetCursor() + if self.creating_selection_region: current_sec = self.x_to_sec(event.pos().x()) start = min(self.selection_drag_start_sec, current_sec) @@ -613,6 +709,17 @@ def mouseMoveEvent(self, event: QMouseEvent): def mouseReleaseEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton: + if self.resizing_clip: + new_state = self.window()._get_current_timeline_state() + command = TimelineStateChangeCommand("Resize Clip", self.timeline, *self.drag_start_state, *new_state) + command.undo() + self.window().undo_stack.push(command) + self.resizing_clip = None + self.resize_edge = None + self.drag_start_state = None + self.update() + return + if self.creating_selection_region: self.creating_selection_region = False if self.selection_regions: @@ -1249,8 +1356,12 @@ def _start_playback_stream_at(self, time_sec): if not clip: return self.playback_clip = clip clip_time = time_sec - clip.timeline_start_sec + clip.clip_start_sec + w, h = self.project_width, self.project_height try: - args = (ffmpeg.input(self.playback_clip.source_path, ss=clip_time).output('pipe:', format='rawvideo', pix_fmt='rgb24', r=self.project_fps).compile()) + args = (ffmpeg.input(self.playback_clip.source_path, ss=clip_time) + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=self.project_fps).compile()) self.playback_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) except Exception as e: print(f"Failed to start playback stream: {e}"); self._stop_playback_stream() @@ -1300,6 +1411,8 @@ def get_frame_data_at_time(self, time_sec): out, _ = ( ffmpeg .input(clip_at_time.source_path, ss=clip_time) + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') .run(capture_stdout=True, quiet=True) ) @@ -1324,7 +1437,11 @@ def get_frame_at_time(self, time_sec): ) else: clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec - out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time).output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24').run(capture_stdout=True, quiet=True)) + out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time) + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') + .run(capture_stdout=True, quiet=True)) image = QImage(out, self.project_width, self.project_height, QImage.Format.Format_RGB888) return QPixmap.fromImage(image) From 25bcc33827d44f00070ab945fac9715e550cf228 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 02:37:49 +1100 Subject: [PATCH 16/67] add playhead snapping for all functions --- videoeditor/main.py | 87 +++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 3ce1bccde..b3d3828e4 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -83,6 +83,7 @@ class TimelineWidget(QWidget): TRACK_HEIGHT = 50 AUDIO_TRACKS_SEPARATOR_Y = 15 RESIZE_HANDLE_WIDTH = 8 + SNAP_THRESHOLD_PIXELS = 8 split_requested = pyqtSignal(object) delete_clip_requested = pyqtSignal(object) @@ -553,7 +554,11 @@ def mousePressEvent(self, event: QMouseEvent): if is_in_track_area: self.creating_selection_region = True - self.selection_drag_start_sec = self.x_to_sec(event.pos().x()) + playhead_x = self.sec_to_x(self.playhead_pos_sec) + if abs(event.pos().x() - playhead_x) < self.SNAP_THRESHOLD_PIXELS: + self.selection_drag_start_sec = self.playhead_pos_sec + else: + self.selection_drag_start_sec = self.x_to_sec(event.pos().x()) self.selection_regions.append([self.selection_drag_start_sec, self.selection_drag_start_sec]) elif is_on_timescale: self.playhead_pos_sec = max(0, self.x_to_sec(event.pos().x())) @@ -567,6 +572,8 @@ def mouseMoveEvent(self, event: QMouseEvent): delta_x = event.pos().x() - self.resize_start_pos.x() time_delta = delta_x / self.pixels_per_second min_duration = 1.0 / self.project_fps + playhead_time = self.playhead_pos_sec + snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_second media_props = self.window().media_properties.get(self.resizing_clip.source_path) source_duration = media_props['duration'] if media_props else float('inf') @@ -575,29 +582,27 @@ def mouseMoveEvent(self, event: QMouseEvent): original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_sec original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_sec original_clip_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].clip_start_sec - - new_start_sec = original_start + time_delta - - # Clamp to not move past the end of the clip + true_new_start_sec = original_start + time_delta + if abs(true_new_start_sec - playhead_time) < snap_time_delta: + new_start_sec = playhead_time + else: + new_start_sec = true_new_start_sec + if new_start_sec > original_start + original_duration - min_duration: new_start_sec = original_start + original_duration - min_duration - - # Clamp to zero + new_start_sec = max(0, new_start_sec) - # For video/audio, don't allow extending beyond the source start if self.resizing_clip.media_type != 'image': if new_start_sec < original_start - original_clip_start: new_start_sec = original_start - original_clip_start new_duration = (original_start + original_duration) - new_start_sec - # FIX: Correctly calculate the new clip start by ADDING the time shift new_clip_start = original_clip_start + (new_start_sec - original_start) if new_duration < min_duration: new_duration = min_duration new_start_sec = (original_start + original_duration) - new_duration - # FIX: Apply the same corrected logic here after clamping duration new_clip_start = original_clip_start + (new_start_sec - original_start) self.resizing_clip.timeline_start_sec = new_start_sec @@ -605,13 +610,20 @@ def mouseMoveEvent(self, event: QMouseEvent): self.resizing_clip.clip_start_sec = new_clip_start elif self.resize_edge == 'right': + original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_sec original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_sec - new_duration = original_duration + time_delta + + true_new_duration = original_duration + time_delta + true_new_end_time = original_start + true_new_duration + + if abs(true_new_end_time - playhead_time) < snap_time_delta: + new_duration = playhead_time - original_start + else: + new_duration = true_new_duration if new_duration < min_duration: new_duration = min_duration - - # For video/audio, don't allow extending beyond the source end + if self.resizing_clip.media_type != 'image': if self.resizing_clip.clip_start_sec + new_duration > source_duration: new_duration = source_duration - self.resizing_clip.clip_start_sec @@ -620,17 +632,26 @@ def mouseMoveEvent(self, event: QMouseEvent): self.update() return - - # Update cursor for resizing + if not self.dragging_clip and not self.dragging_playhead and not self.creating_selection_region: cursor_set = False - for clip in self.timeline.clips: - clip_rect = self.get_clip_rect(clip) - if (abs(event.pos().x() - clip_rect.left()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.left(), event.pos().y()))) or \ - (abs(event.pos().x() - clip_rect.right()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.right(), event.pos().y()))): - self.setCursor(Qt.CursorShape.SizeHorCursor) - cursor_set = True - break + ### ADD BEGIN ### + # Check for playhead proximity for selection snapping + playhead_x = self.sec_to_x(self.playhead_pos_sec) + is_in_track_area = event.pos().y() > self.TIMESCALE_HEIGHT and event.pos().x() > self.HEADER_WIDTH + if is_in_track_area and abs(event.pos().x() - playhead_x) < self.SNAP_THRESHOLD_PIXELS: + self.setCursor(Qt.CursorShape.SizeHorCursor) + cursor_set = True + + if not cursor_set: + ### ADD END ### + for clip in self.timeline.clips: + clip_rect = self.get_clip_rect(clip) + if (abs(event.pos().x() - clip_rect.left()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.left(), event.pos().y()))) or \ + (abs(event.pos().x() - clip_rect.right()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.right(), event.pos().y()))): + self.setCursor(Qt.CursorShape.SizeHorCursor) + cursor_set = True + break if not cursor_set: self.unsetCursor() @@ -683,7 +704,18 @@ def mouseMoveEvent(self, event: QMouseEvent): delta_x = event.pos().x() - self.drag_start_pos.x() time_delta = delta_x / self.pixels_per_second - new_start_time = original_start_sec + time_delta + true_new_start_time = original_start_sec + time_delta + + playhead_time = self.playhead_pos_sec + snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_second + + new_start_time = true_new_start_time + true_new_end_time = true_new_start_time + self.dragging_clip.duration_sec + + if abs(true_new_start_time - playhead_time) < snap_time_delta: + new_start_time = playhead_time + elif abs(true_new_end_time - playhead_time) < snap_time_delta: + new_start_time = playhead_time - self.dragging_clip.duration_sec for other_clip in self.timeline.clips: if other_clip.id == self.dragging_clip.id: continue @@ -696,8 +728,11 @@ def mouseMoveEvent(self, event: QMouseEvent): new_start_time + self.dragging_clip.duration_sec > other_clip.timeline_start_sec) if is_overlapping: - if time_delta > 0: new_start_time = other_clip.timeline_start_sec - self.dragging_clip.duration_sec - else: new_start_time = other_clip.timeline_end_sec + movement_direction = true_new_start_time - original_start_sec + if movement_direction > 0: + new_start_time = other_clip.timeline_start_sec - self.dragging_clip.duration_sec + else: + new_start_time = other_clip.timeline_end_sec break final_start_time = max(0, new_start_time) @@ -725,7 +760,7 @@ def mouseReleaseEvent(self, event: QMouseEvent): if self.selection_regions: start, end = self.selection_regions[-1] if (end - start) * self.pixels_per_second < 2: - self.selection_regions.pop() + self.clear_all_regions() if self.dragging_selection_region: self.dragging_selection_region = None From dc68e8682ad27a1d4e728dee4f34d76ef9096cc5 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 03:11:58 +1100 Subject: [PATCH 17/67] added feature to unlink and relink audio tracks --- videoeditor/main.py | 88 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index b3d3828e4..8d9e27d30 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -569,6 +569,7 @@ def mousePressEvent(self, event: QMouseEvent): def mouseMoveEvent(self, event: QMouseEvent): if self.resizing_clip: + linked_clip = next((c for c in self.timeline.clips if c.group_id == self.resizing_clip.group_id and c.id != self.resizing_clip.id), None) delta_x = event.pos().x() - self.resize_start_pos.x() time_delta = delta_x / self.pixels_per_second min_duration = 1.0 / self.project_fps @@ -608,6 +609,10 @@ def mouseMoveEvent(self, event: QMouseEvent): self.resizing_clip.timeline_start_sec = new_start_sec self.resizing_clip.duration_sec = new_duration self.resizing_clip.clip_start_sec = new_clip_start + if linked_clip: + linked_clip.timeline_start_sec = new_start_sec + linked_clip.duration_sec = new_duration + linked_clip.clip_start_sec = new_clip_start elif self.resize_edge == 'right': original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_sec @@ -629,14 +634,14 @@ def mouseMoveEvent(self, event: QMouseEvent): new_duration = source_duration - self.resizing_clip.clip_start_sec self.resizing_clip.duration_sec = new_duration + if linked_clip: + linked_clip.duration_sec = new_duration self.update() return if not self.dragging_clip and not self.dragging_playhead and not self.creating_selection_region: cursor_set = False - ### ADD BEGIN ### - # Check for playhead proximity for selection snapping playhead_x = self.sec_to_x(self.playhead_pos_sec) is_in_track_area = event.pos().y() > self.TIMESCALE_HEIGHT and event.pos().x() > self.HEADER_WIDTH if is_in_track_area and abs(event.pos().x() - playhead_x) < self.SNAP_THRESHOLD_PIXELS: @@ -644,7 +649,6 @@ def mouseMoveEvent(self, event: QMouseEvent): cursor_set = True if not cursor_set: - ### ADD END ### for clip in self.timeline.clips: clip_rect = self.get_clip_rect(clip) if (abs(event.pos().x() - clip_rect.left()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.left(), event.pos().y()))) or \ @@ -952,6 +956,18 @@ def contextMenuEvent(self, event: 'QContextMenuEvent'): if clip_at_pos: if not menu.isEmpty(): menu.addSeparator() + + linked_clip = next((c for c in self.timeline.clips if c.group_id == clip_at_pos.group_id and c.id != clip_at_pos.id), None) + if linked_clip: + unlink_action = menu.addAction("Unlink Audio Track") + unlink_action.triggered.connect(lambda: self.window().unlink_clip_pair(clip_at_pos)) + else: + media_info = self.window().media_properties.get(clip_at_pos.source_path) + if (clip_at_pos.track_type == 'video' and + media_info and media_info.get('has_audio')): + relink_action = menu.addAction("Relink Audio Track") + relink_action.triggered.connect(lambda: self.window().relink_clip_audio(clip_at_pos)) + split_action = menu.addAction("Split Clip") delete_action = menu.addAction("Delete Clip") playhead_time = self.playhead_pos_sec @@ -1825,6 +1841,72 @@ def delete_clip(self, clip_to_delete): self.undo_stack.push(command) self.prune_empty_tracks() + def unlink_clip_pair(self, clip_to_unlink): + old_state = self._get_current_timeline_state() + + linked_clip = next((c for c in self.timeline.clips if c.group_id == clip_to_unlink.group_id and c.id != clip_to_unlink.id), None) + + if linked_clip: + clip_to_unlink.group_id = str(uuid.uuid4()) + linked_clip.group_id = str(uuid.uuid4()) + + new_state = self._get_current_timeline_state() + command = TimelineStateChangeCommand("Unlink Clips", self.timeline, *old_state, *new_state) + command.undo() + self.undo_stack.push(command) + self.status_label.setText("Clips unlinked.") + else: + self.status_label.setText("Could not find a clip to unlink.") + + def relink_clip_audio(self, video_clip): + def action(): + media_info = self.media_properties.get(video_clip.source_path) + if not media_info or not media_info.get('has_audio'): + self.status_label.setText("Source media has no audio to relink.") + return + + target_audio_track = 1 + new_audio_start = video_clip.timeline_start_sec + new_audio_end = video_clip.timeline_end_sec + + conflicting_clips = [ + c for c in self.timeline.clips + if c.track_type == 'audio' and c.track_index == target_audio_track and + c.timeline_start_sec < new_audio_end and c.timeline_end_sec > new_audio_start + ] + + for conflict_clip in conflicting_clips: + found_spot = False + for check_track_idx in range(target_audio_track + 1, self.timeline.num_audio_tracks + 2): + is_occupied = any( + other.timeline_start_sec < conflict_clip.timeline_end_sec and other.timeline_end_sec > conflict_clip.timeline_start_sec + for other in self.timeline.clips + if other.id != conflict_clip.id and other.track_type == 'audio' and other.track_index == check_track_idx + ) + + if not is_occupied: + if check_track_idx > self.timeline.num_audio_tracks: + self.timeline.num_audio_tracks = check_track_idx + + conflict_clip.track_index = check_track_idx + found_spot = True + break + + new_audio_clip = TimelineClip( + source_path=video_clip.source_path, + timeline_start_sec=video_clip.timeline_start_sec, + clip_start_sec=video_clip.clip_start_sec, + duration_sec=video_clip.duration_sec, + track_index=target_audio_track, + track_type='audio', + media_type=video_clip.media_type, + group_id=video_clip.group_id + ) + self.timeline.add_clip(new_audio_clip) + self.status_label.setText("Audio relinked.") + + self._perform_complex_timeline_change("Relink Audio", action) + def _perform_complex_timeline_change(self, description, change_function): old_state = self._get_current_timeline_state() change_function() From bea0f48c70276e6472ba0a57fbb3953d9854e869 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 03:43:35 +1100 Subject: [PATCH 18/67] add ability to add media directly to timeline --- videoeditor/main.py | 134 +++++++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 38 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 8d9e27d30..c4d3e9268 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -107,7 +107,7 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.scroll_area = None self.pixels_per_second = 50.0 - self.max_pixels_per_second = 1.0 # Will be updated by set_project_fps + self.max_pixels_per_second = 1.0 self.project_fps = 25.0 self.set_project_fps(project_fps) @@ -121,13 +121,13 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.creating_selection_region = False self.dragging_selection_region = None self.drag_start_pos = QPoint() - self.drag_original_clip_states = {} # Store {'clip_id': (start_sec, track_index)} + self.drag_original_clip_states = {} self.selection_drag_start_sec = 0.0 self.drag_selection_start_values = None self.drag_start_state = None self.resizing_clip = None - self.resize_edge = None # 'left' or 'right' + self.resize_edge = None self.resize_start_pos = QPoint() self.highlighted_track_info = None @@ -146,7 +146,6 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): def set_project_fps(self, fps): self.project_fps = fps if fps > 0 else 25.0 - # Set max zoom to be 10 pixels per frame self.max_pixels_per_second = self.project_fps * 20 self.pixels_per_second = min(self.pixels_per_second, self.max_pixels_per_second) self.update() @@ -263,7 +262,6 @@ def _format_timecode(self, seconds): precision = 2 if s < 1 else 1 if s < 10 else 0 val = f"{s:.{precision}f}" - # Remove trailing .0 or .00 if '.' in val: val = val.rstrip('0').rstrip('.') return f"{sign}{val}s" @@ -382,11 +380,11 @@ def draw_tracks_and_clips(self, painter): for clip in self.timeline.clips: clip_rect = self.get_clip_rect(clip) - base_color = QColor("#46A") # Default video + base_color = QColor("#46A") if clip.media_type == 'image': - base_color = QColor("#4A6") # Greenish for images + base_color = QColor("#4A6") elif clip.track_type == 'audio': - base_color = QColor("#48C") # Bluish for audio + base_color = QColor("#48C") color = QColor("#5A9") if self.dragging_clip and self.dragging_clip.id == clip.id else base_color painter.fillRect(clip_rect, color) @@ -415,25 +413,21 @@ def draw_playhead(self, painter): painter.drawLine(playhead_x, 0, playhead_x, self.height()) def y_to_track_info(self, y): - # Check for ghost video track if self.TIMESCALE_HEIGHT <= y < self.video_tracks_y_start: return ('video', self.timeline.num_video_tracks + 1) - # Check for existing video tracks video_tracks_end_y = self.video_tracks_y_start + self.timeline.num_video_tracks * self.TRACK_HEIGHT if self.video_tracks_y_start <= y < video_tracks_end_y: visual_index = (y - self.video_tracks_y_start) // self.TRACK_HEIGHT track_index = self.timeline.num_video_tracks - visual_index return ('video', track_index) - - # Check for existing audio tracks + audio_tracks_end_y = self.audio_tracks_y_start + self.timeline.num_audio_tracks * self.TRACK_HEIGHT if self.audio_tracks_y_start <= y < audio_tracks_end_y: visual_index = (y - self.audio_tracks_y_start) // self.TRACK_HEIGHT track_index = visual_index + 1 return ('audio', track_index) - - # Check for ghost audio track + add_audio_btn_y_start = self.audio_tracks_y_start + self.timeline.num_audio_tracks * self.TRACK_HEIGHT add_audio_btn_y_end = add_audio_btn_y_start + self.TRACK_HEIGHT if add_audio_btn_y_start <= y < add_audio_btn_y_end: @@ -468,27 +462,19 @@ def wheelEvent(self, event: QMouseEvent): if delta > 0: new_pps = old_pps * zoom_factor else: new_pps = old_pps / zoom_factor - min_pps = 1 / (3600 * 10) # Min zoom of 1px per 10 hours + min_pps = 1 / (3600 * 10) new_pps = max(min_pps, min(new_pps, self.max_pixels_per_second)) if abs(new_pps - old_pps) < 1e-9: return self.pixels_per_second = new_pps - # This will trigger a paintEvent, which resizes the widget. - # The scroll area will update its scrollbar ranges in response. self.update() - - # The new absolute x-coordinate for the time under the cursor + new_mouse_x_abs = self.sec_to_x(time_at_cursor) - - # The amount the content "shifted" at the cursor's location shift_amount = new_mouse_x_abs - mouse_x_abs - - # Adjust the scrollbar by this shift amount to keep the content under the cursor new_scroll_value = scrollbar.value() + shift_amount scrollbar.setValue(int(new_scroll_value)) - event.accept() def mousePressEvent(self, event: QMouseEvent): @@ -509,7 +495,6 @@ def mousePressEvent(self, event: QMouseEvent): self.resize_edge = None self.drag_original_clip_states.clear() - # Check for resize handles first for clip in reversed(self.timeline.clips): clip_rect = self.get_clip_rect(clip) if abs(event.pos().x() - clip_rect.left()) < self.RESIZE_HANDLE_WIDTH and clip_rect.contains(QPointF(clip_rect.left(), event.pos().y())): @@ -1356,13 +1341,18 @@ def _create_menu_bar(self): open_action = QAction("&Open Project...", self); open_action.triggered.connect(self.open_project) self.recent_menu = file_menu.addMenu("Recent") save_action = QAction("&Save Project As...", self); save_action.triggered.connect(self.save_project_as) + add_media_to_timeline_action = QAction("Add Media to &Timeline...", self) + add_media_to_timeline_action.triggered.connect(self.add_media_to_timeline) add_media_action = QAction("&Add Media to Project...", self); add_media_action.triggered.connect(self.add_media_files) export_action = QAction("&Export Video...", self); export_action.triggered.connect(self.export_video) settings_action = QAction("Se&ttings...", self); settings_action.triggered.connect(self.open_settings_dialog) exit_action = QAction("E&xit", self); exit_action.triggered.connect(self.close) file_menu.addAction(new_action); file_menu.addAction(open_action); file_menu.addSeparator() file_menu.addAction(save_action) - file_menu.addSeparator(); file_menu.addAction(add_media_action); file_menu.addAction(export_action) + file_menu.addSeparator() + file_menu.addAction(add_media_to_timeline_action) + file_menu.addAction(add_media_action) + file_menu.addAction(export_action) file_menu.addSeparator(); file_menu.addAction(settings_action); file_menu.addSeparator(); file_menu.addAction(exit_action) self._update_recent_files_menu() @@ -1565,7 +1555,7 @@ def advance_playback_frame(self): def _load_settings(self): self.settings_file_was_loaded = False - defaults = {"window_visibility": {"project_media": True}, "splitter_state": None, "enabled_plugins": [], "recent_files": [], "confirm_on_exit": True} + defaults = {"window_visibility": {"project_media": False}, "splitter_state": None, "enabled_plugins": [], "recent_files": [], "confirm_on_exit": True} if os.path.exists(self.settings_file): try: with open(self.settings_file, "r") as f: self.settings = json.load(f) @@ -1591,7 +1581,7 @@ def _apply_loaded_settings(self): visibility_settings = self.settings.get("window_visibility", {}) for key, data in self.managed_widgets.items(): if data.get('plugin'): continue - is_visible = visibility_settings.get(key, True) + is_visible = visibility_settings.get(key, False if key == 'project_media' else True) if data['widget'] is not self.preview_widget: data['widget'].setVisible(is_visible) if data['action']: data['action'].setChecked(is_visible) @@ -1745,12 +1735,13 @@ def on_media_removed_from_pool(self, file_path): command.undo() self.undo_stack.push(command) + def _add_media_files_to_project(self, file_paths): + if not file_paths: + return [] - def add_media_files(self): - file_paths, _ = QFileDialog.getOpenFileNames(self, "Open Media Files", "", "All Supported Files (*.mp4 *.mov *.avi *.png *.jpg *.jpeg *.mp3 *.wav);;Video Files (*.mp4 *.mov *.avi);;Image Files (*.png *.jpg *.jpeg);;Audio Files (*.mp3 *.wav)") - if not file_paths: return self.media_dock.show() + added_files = [] first_video_added = any(c.media_type == 'video' for c in self.timeline.clips) for file_path in file_paths: @@ -1760,13 +1751,81 @@ def add_media_files(self): if self._set_project_properties_from_clip(file_path): first_video_added = True else: - self.status_label.setText("Error: Could not determine video properties from file."); + self.status_label.setText("Error: Could not determine video properties from file.") continue if self._add_media_to_pool(file_path): - self.status_label.setText(f"Added {os.path.basename(file_path)} to project media.") - else: - self.status_label.setText(f"Failed to add {os.path.basename(file_path)}.") + added_files.append(file_path) + + return added_files + + def add_media_to_timeline(self): + file_paths, _ = QFileDialog.getOpenFileNames(self, "Add Media to Timeline", "", "All Supported Files (*.mp4 *.mov *.avi *.png *.jpg *.jpeg *.mp3 *.wav);;Video Files (*.mp4 *.mov *.avi);;Image Files (*.png *.jpg *.jpeg);;Audio Files (*.mp3 *.wav)") + if not file_paths: + return + + added_files = self._add_media_files_to_project(file_paths) + if not added_files: + return + + playhead_pos = self.timeline_widget.playhead_pos_sec + + def add_clips_action(): + for file_path in added_files: + media_info = self.media_properties.get(file_path) + if not media_info: continue + + duration = media_info['duration'] + media_type = media_info['media_type'] + has_audio = media_info['has_audio'] + + clip_start_time = playhead_pos + clip_end_time = playhead_pos + duration + + video_track_index = None + audio_track_index = None + + if media_type in ['video', 'image']: + for i in range(1, self.timeline.num_video_tracks + 2): + is_occupied = any( + c.timeline_start_sec < clip_end_time and c.timeline_end_sec > clip_start_time + for c in self.timeline.clips if c.track_type == 'video' and c.track_index == i + ) + if not is_occupied: + video_track_index = i + break + + if has_audio: + for i in range(1, self.timeline.num_audio_tracks + 2): + is_occupied = any( + c.timeline_start_sec < clip_end_time and c.timeline_end_sec > clip_start_time + for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == i + ) + if not is_occupied: + audio_track_index = i + break + + group_id = str(uuid.uuid4()) + if video_track_index is not None: + if video_track_index > self.timeline.num_video_tracks: + self.timeline.num_video_tracks = video_track_index + video_clip = TimelineClip(file_path, clip_start_time, 0.0, duration, video_track_index, 'video', media_type, group_id) + self.timeline.add_clip(video_clip) + + if audio_track_index is not None: + if audio_track_index > self.timeline.num_audio_tracks: + self.timeline.num_audio_tracks = audio_track_index + audio_clip = TimelineClip(file_path, clip_start_time, 0.0, duration, audio_track_index, 'audio', media_type, group_id) + self.timeline.add_clip(audio_clip) + + self.status_label.setText(f"Added {len(added_files)} file(s) to timeline.") + + self._perform_complex_timeline_change("Add Media to Timeline", add_clips_action) + + def add_media_files(self): + file_paths, _ = QFileDialog.getOpenFileNames(self, "Open Media Files", "", "All Supported Files (*.mp4 *.mov *.avi *.png *.jpg *.jpeg *.mp3 *.wav);;Video Files (*.mp4 *.mov *.avi);;Image Files (*.png *.jpg *.jpeg);;Audio Files (*.mp3 *.wav)") + if file_paths: + self._add_media_files_to_project(file_paths) def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, media_type, clip_start_sec=0.0, video_track_index=None, audio_track_index=None): old_state = self._get_current_timeline_state() @@ -2032,8 +2091,7 @@ def action(): for clip in clips_to_remove: try: self.timeline.clips.remove(clip) except ValueError: pass - - # Ripple + for clip in self.timeline.clips: if clip.timeline_start_sec >= end_sec: clip.timeline_start_sec -= duration_to_remove @@ -2074,7 +2132,7 @@ def export_video(self): if clip.media_type == 'image': v_seg = (input_streams[clip.source_path].video.trim(duration=clip.duration_sec).setpts('PTS-STARTPTS')) - else: # video + else: v_seg = (input_streams[clip.source_path].video.trim(start=clip.clip_start_sec, duration=clip.duration_sec).setpts('PTS-STARTPTS')) v_seg = (v_seg.filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black').filter('format', pix_fmts='rgba')) From afc567a09cd0efb6fdf79e6bc935339880741674 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 04:04:40 +1100 Subject: [PATCH 19/67] add ability to drag and drop from OS into timeline or project media --- videoeditor/main.py | 170 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 162 insertions(+), 8 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index c4d3e9268..0ff19f672 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -143,6 +143,7 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.drag_over_active = False self.drag_over_rect = QRectF() self.drag_over_audio_rect = QRectF() + self.drag_url_cache = {} def set_project_fps(self, fps): self.project_fps = fps if fps > 0 else 25.0 @@ -782,7 +783,9 @@ def mouseReleaseEvent(self, event: QMouseEvent): self.update() def dragEnterEvent(self, event): - if event.mimeData().hasFormat('application/x-vnd.video.filepath'): + if event.mimeData().hasFormat('application/x-vnd.video.filepath') or event.mimeData().hasUrls(): + if event.mimeData().hasUrls(): + self.drag_url_cache.clear() event.acceptProposedAction() else: event.ignore() @@ -791,20 +794,65 @@ def dragLeaveEvent(self, event): self.drag_over_active = False self.highlighted_ghost_track_info = None self.highlighted_track_info = None + self.drag_url_cache.clear() self.update() def dragMoveEvent(self, event): mime_data = event.mimeData() - if not mime_data.hasFormat('application/x-vnd.video.filepath'): - event.ignore() - return + media_props = None + if mime_data.hasUrls(): + urls = mime_data.urls() + if not urls: + event.ignore() + return + + file_path = urls[0].toLocalFile() + + if file_path in self.drag_url_cache: + media_props = self.drag_url_cache[file_path] + else: + probed_props = self.window()._probe_for_drag(file_path) + if probed_props: + self.drag_url_cache[file_path] = probed_props + media_props = probed_props + + elif mime_data.hasFormat('application/x-vnd.video.filepath'): + json_data_bytes = mime_data.data('application/x-vnd.video.filepath').data() + media_props = json.loads(json_data_bytes.decode('utf-8')) + + if not media_props: + if mime_data.hasUrls(): + event.acceptProposedAction() + pos = event.position() + track_info = self.y_to_track_info(pos.y()) + + self.drag_over_rect = QRectF() + self.drag_over_audio_rect = QRectF() + self.drag_over_active = False + self.highlighted_ghost_track_info = None + self.highlighted_track_info = None + + if track_info: + self.drag_over_active = True + track_type, track_index = track_info + is_ghost_track = (track_type == 'video' and track_index > self.timeline.num_video_tracks) or \ + (track_type == 'audio' and track_index > self.timeline.num_audio_tracks) + if is_ghost_track: + self.highlighted_ghost_track_info = track_info + else: + self.highlighted_track_info = track_info + + self.update() + else: + event.ignore() + return + event.acceptProposedAction() - json_data = json.loads(mime_data.data('application/x-vnd.video.filepath').data().decode('utf-8')) - duration = json_data['duration'] - media_type = json_data['media_type'] - has_audio = json_data['has_audio'] + duration = media_props['duration'] + media_type = media_props['media_type'] + has_audio = media_props['has_audio'] pos = event.position() start_sec = self.x_to_sec(pos.x()) @@ -859,9 +907,68 @@ def dropEvent(self, event): self.drag_over_active = False self.highlighted_ghost_track_info = None self.highlighted_track_info = None + self.drag_url_cache.clear() self.update() mime_data = event.mimeData() + if mime_data.hasUrls(): + file_paths = [url.toLocalFile() for url in mime_data.urls()] + main_window = self.window() + added_files = main_window._add_media_files_to_project(file_paths) + if not added_files: + event.ignore() + return + + pos = event.position() + start_sec = self.x_to_sec(pos.x()) + track_info = self.y_to_track_info(pos.y()) + if not track_info: + event.ignore() + return + + current_timeline_pos = start_sec + + for file_path in added_files: + media_info = main_window.media_properties.get(file_path) + if not media_info: continue + + duration = media_info['duration'] + has_audio = media_info['has_audio'] + media_type = media_info['media_type'] + + drop_track_type, drop_track_index = track_info + video_track_idx = None + audio_track_idx = None + + if media_type == 'image': + if drop_track_type == 'video': video_track_idx = drop_track_index + elif media_type == 'audio': + if drop_track_type == 'audio': audio_track_idx = drop_track_index + elif media_type == 'video': + if drop_track_type == 'video': + video_track_idx = drop_track_index + if has_audio: audio_track_idx = 1 + elif drop_track_type == 'audio' and has_audio: + audio_track_idx = drop_track_index + video_track_idx = 1 + + if video_track_idx is None and audio_track_idx is None: + continue + + main_window._add_clip_to_timeline( + source_path=file_path, + timeline_start_sec=current_timeline_pos, + duration_sec=duration, + media_type=media_type, + clip_start_sec=0, + video_track_index=video_track_idx, + audio_track_index=audio_track_idx + ) + current_timeline_pos += duration + + event.acceptProposedAction() + return + if not mime_data.hasFormat('application/x-vnd.video.filepath'): return @@ -1033,6 +1140,7 @@ class ProjectMediaWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self.main_window = parent + self.setAcceptDrops(True) layout = QVBoxLayout(self) layout.setContentsMargins(2, 2, 2, 2) @@ -1050,6 +1158,21 @@ def __init__(self, parent=None): add_button.clicked.connect(self.add_media_requested.emit) remove_button.clicked.connect(self.remove_selected_media) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + if event.mimeData().hasUrls(): + file_paths = [url.toLocalFile() for url in event.mimeData().urls()] + self.main_window._add_media_files_to_project(file_paths) + event.acceptProposedAction() + else: + event.ignore() + def show_context_menu(self, pos): item = self.media_list.itemAt(pos) if not item: @@ -1432,6 +1555,37 @@ def _set_project_properties_from_clip(self, source_path): except Exception as e: print(f"Could not probe for project properties: {e}") return False + def _probe_for_drag(self, file_path): + if file_path in self.media_properties: + return self.media_properties[file_path] + try: + file_ext = os.path.splitext(file_path)[1].lower() + media_info = {} + + if file_ext in ['.png', '.jpg', '.jpeg']: + media_info['media_type'] = 'image' + media_info['duration'] = 5.0 + media_info['has_audio'] = False + else: + probe = ffmpeg.probe(file_path) + video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) + audio_stream = next((s for s in probe['streams'] if s['codec_type'] == 'audio'), None) + + if video_stream: + media_info['media_type'] = 'video' + media_info['duration'] = float(video_stream.get('duration', probe['format'].get('duration', 0))) + media_info['has_audio'] = audio_stream is not None + elif audio_stream: + media_info['media_type'] = 'audio' + media_info['duration'] = float(audio_stream.get('duration', probe['format'].get('duration', 0))) + media_info['has_audio'] = True + else: + return None + return media_info + except Exception as e: + print(f"Failed to probe dragged file {os.path.basename(file_path)}: {e}") + return None + def get_frame_data_at_time(self, time_sec): clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) if not clip_at_time: From 17567341dc0e5b4b4c77573ccb0176a7ef00b16a Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 04:31:33 +1100 Subject: [PATCH 20/67] fix ai joiner not inserting on new track with new stream structure --- videoeditor/main.py | 82 +++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 0ff19f672..e9981dc7f 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -1539,25 +1539,11 @@ def _stop_playback_stream(self): self.playback_process = None self.playback_clip = None - def _set_project_properties_from_clip(self, source_path): - try: - probe = ffmpeg.probe(source_path) - video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) - if video_stream: - self.project_width = int(video_stream['width']); self.project_height = int(video_stream['height']) - if 'r_frame_rate' in video_stream and video_stream['r_frame_rate'] != '0/0': - num, den = map(int, video_stream['r_frame_rate'].split('/')) - if den > 0: - self.project_fps = num / den - self.timeline_widget.set_project_fps(self.project_fps) - print(f"Project properties set: {self.project_width}x{self.project_height} @ {self.project_fps:.2f} FPS") - return True - except Exception as e: print(f"Could not probe for project properties: {e}") - return False - - def _probe_for_drag(self, file_path): + def _get_media_properties(self, file_path): + """Probes a file to get its media properties. Returns a dict or None.""" if file_path in self.media_properties: return self.media_properties[file_path] + try: file_ext = os.path.splitext(file_path)[1].lower() media_info = {} @@ -1581,11 +1567,31 @@ def _probe_for_drag(self, file_path): media_info['has_audio'] = True else: return None + return media_info except Exception as e: - print(f"Failed to probe dragged file {os.path.basename(file_path)}: {e}") + print(f"Failed to probe file {os.path.basename(file_path)}: {e}") return None + def _set_project_properties_from_clip(self, source_path): + try: + probe = ffmpeg.probe(source_path) + video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) + if video_stream: + self.project_width = int(video_stream['width']); self.project_height = int(video_stream['height']) + if 'r_frame_rate' in video_stream and video_stream['r_frame_rate'] != '0/0': + num, den = map(int, video_stream['r_frame_rate'].split('/')) + if den > 0: + self.project_fps = num / den + self.timeline_widget.set_project_fps(self.project_fps) + print(f"Project properties set: {self.project_width}x{self.project_height} @ {self.project_fps:.2f} FPS") + return True + except Exception as e: print(f"Could not probe for project properties: {e}") + return False + + def _probe_for_drag(self, file_path): + return self._get_media_properties(file_path) + def get_frame_data_at_time(self, time_sec): clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) if not clip_at_time: @@ -1838,41 +1844,21 @@ def _update_recent_files_menu(self): self.recent_menu.addAction(action) def _add_media_to_pool(self, file_path): - if file_path in self.media_pool: return True - try: - self.status_label.setText(f"Probing {os.path.basename(file_path)}..."); QApplication.processEvents() - - file_ext = os.path.splitext(file_path)[1].lower() - media_info = {} - - if file_ext in ['.png', '.jpg', '.jpeg']: - media_info['media_type'] = 'image' - media_info['duration'] = 5.0 - media_info['has_audio'] = False - else: - probe = ffmpeg.probe(file_path) - video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) - audio_stream = next((s for s in probe['streams'] if s['codec_type'] == 'audio'), None) - - if video_stream: - media_info['media_type'] = 'video' - media_info['duration'] = float(video_stream.get('duration', probe['format'].get('duration', 0))) - media_info['has_audio'] = audio_stream is not None - elif audio_stream: - media_info['media_type'] = 'audio' - media_info['duration'] = float(audio_stream.get('duration', probe['format'].get('duration', 0))) - media_info['has_audio'] = True - else: - raise ValueError("No video or audio stream found.") - + if file_path in self.media_pool: + return True + + self.status_label.setText(f"Probing {os.path.basename(file_path)}..."); QApplication.processEvents() + + media_info = self._get_media_properties(file_path) + + if media_info: self.media_properties[file_path] = media_info self.media_pool.append(file_path) self.project_media_widget.add_media_item(file_path) - self.status_label.setText(f"Added {os.path.basename(file_path)} to project.") return True - except Exception as e: - self.status_label.setText(f"Error probing file: {e}") + else: + self.status_label.setText(f"Error probing file: {os.path.basename(file_path)}") return False def on_media_removed_from_pool(self, file_path): From a223c1e8df4d0104a84965db09ae789c35123da1 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 04:32:01 +1100 Subject: [PATCH 21/67] fix ai joiner not inserting on new track with new stream structure (2) --- videoeditor/plugins/ai_frame_joiner/main.py | 34 ++++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index d66dda353..85fd7a4d3 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -5,6 +5,7 @@ import requests from pathlib import Path import ffmpeg +import uuid from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, @@ -268,6 +269,8 @@ def setup_generator_for_region(self, region, on_new_track=False): self.dock_widget.raise_() def insert_generated_clip(self, video_path): + from main import TimelineClip + if not self.active_region: self.update_main_status("AI Joiner Error: No active region to insert into.") return @@ -277,25 +280,29 @@ def insert_generated_clip(self, video_path): return start_sec, end_sec = self.active_region + is_new_track_mode = self.insert_on_new_track + self.update_main_status(f"AI Joiner: Inserting clip {os.path.basename(video_path)}") - try: + def complex_insertion_action(): probe = ffmpeg.probe(video_path) actual_duration = float(probe['format']['duration']) - if self.insert_on_new_track: - self.app.add_track('video') + if is_new_track_mode: + self.app.timeline.num_video_tracks += 1 new_track_index = self.app.timeline.num_video_tracks - self.app._add_clip_to_timeline( + + new_clip = TimelineClip( source_path=video_path, timeline_start_sec=start_sec, clip_start_sec=0, duration_sec=actual_duration, + track_index=new_track_index, + track_type='video', media_type='video', - video_track_index=new_track_index, - audio_track_index=None + group_id=str(uuid.uuid4()) ) - self.update_main_status("AI clip inserted successfully on new track.") + self.app.timeline.add_clip(new_clip) else: for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) @@ -308,18 +315,23 @@ def insert_generated_clip(self, video_path): if clip in self.app.timeline.clips: self.app.timeline.clips.remove(clip) - self.app._add_clip_to_timeline( + new_clip = TimelineClip( source_path=video_path, timeline_start_sec=start_sec, clip_start_sec=0, duration_sec=actual_duration, + track_index=1, + track_type='video', media_type='video', - video_track_index=1, - audio_track_index=None + group_id=str(uuid.uuid4()) ) - self.update_main_status("AI clip inserted successfully.") + self.app.timeline.add_clip(new_clip) + try: + self.app._perform_complex_timeline_change("Insert AI Clip", complex_insertion_action) + self.app.prune_empty_tracks() + self.update_main_status("AI clip inserted successfully.") except Exception as e: error_message = f"AI Joiner Error during clip insertion/probing: {e}" From 8472a8a92a1b7cff5168f5df9f0873dca2a3703e Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 06:05:18 +1100 Subject: [PATCH 22/67] add Create mode for AI plusin --- main.py | 46 ++-- videoeditor/main.py | 17 +- videoeditor/plugins/ai_frame_joiner/main.py | 239 +++++++++++++++++--- 3 files changed, 238 insertions(+), 64 deletions(-) diff --git a/main.py b/main.py index 0dff699e6..773bcc9fa 100644 --- a/main.py +++ b/main.py @@ -312,56 +312,49 @@ def __init__(self): self.apply_initial_config() self.connect_signals() self.init_wgp_state() - - # --- CHANGE: Removed setModelSignal as it's no longer needed --- + self.api_bridge.generateSignal.connect(self._api_generate) @pyqtSlot(str) def _api_set_model(self, model_type): """This slot is executed in the main GUI thread.""" if not model_type or not wgp: return - - # 1. Check if the model is valid by looking it up in the master model definition dictionary. + if model_type not in wgp.models_def: print(f"API Error: Model type '{model_type}' is not a valid model.") return - # 2. Check if already selected to avoid unnecessary UI refreshes. if self.state.get('model_type') == model_type: print(f"API: Model is already set to {model_type}.") return - # 3. Redraw all model dropdowns to ensure the correct hierarchy is displayed - # and the target model is selected. This function handles finding the - # correct Family and Base model for the given finetune model_type. self.update_model_dropdowns(model_type) - - # 4. Manually trigger the logic that normally runs when the user selects a model. - # This is necessary because update_model_dropdowns blocks signals. + self._on_model_changed() - # 5. Final check to see if the model was actually set. if self.state.get('model_type') == model_type: print(f"API: Successfully set model to {model_type}.") else: - # This could happen if update_model_dropdowns silently fails to find the model. print(f"API Error: Failed to set model to '{model_type}'. The model might be hidden by your current configuration.") - - # --- CHANGE: Slot now accepts model_type and duration_sec, and calculates frame count --- + @pyqtSlot(object, object, object, object, bool) def _api_generate(self, start_frame, end_frame, duration_sec, model_type, start_generation): """This slot is executed in the main GUI thread.""" - # 1. Set model if a new one is provided if model_type: self._api_set_model(model_type) - if start_frame: self.widgets['mode_s'].setChecked(True) self.widgets['image_start'].setText(start_frame) + else: + self.widgets['mode_t'].setChecked(True) + self.widgets['image_start'].clear() if end_frame: self.widgets['image_end_checkbox'].setChecked(True) self.widgets['image_end'].setText(end_frame) + else: + self.widgets['image_end_checkbox'].setChecked(False) + self.widgets['image_end'].clear() if duration_sec is not None: try: @@ -1707,7 +1700,7 @@ def _add_task_to_queue_and_update_ui(self): def _add_task_to_queue(self): queue_size_before = len(self.state["gen"]["queue"]) all_inputs = self.collect_inputs() - keys_to_remove = ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality'] + keys_to_remove = ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality', 'base_model_type'] for key in keys_to_remove: all_inputs.pop(key, None) @@ -1938,7 +1931,6 @@ def run_api_server(): api_server.run(port=5100, host='127.0.0.1', debug=False) if FLASK_AVAILABLE: - # --- CHANGE: Removed /api/set_model endpoint --- @api_server.route('/api/generate', methods=['POST']) def generate(): @@ -1964,13 +1956,13 @@ def generate(): return jsonify({"message": "Parameters set without starting generation."}) - @api_server.route('/api/latest_output', methods=['GET']) - def get_latest_output(): + @api_server.route('/api/outputs', methods=['GET']) + def get_outputs(): if not main_window_instance: return jsonify({"error": "Application not ready"}), 503 - - path = main_window_instance.latest_output_path - return jsonify({"latest_output_path": path}) + + file_list = main_window_instance.state.get('gen', {}).get('file_list', []) + return jsonify({"outputs": file_list}) # ===================================================================== # --- END OF API SERVER ADDITION --- @@ -1978,13 +1970,11 @@ def get_latest_output(): if __name__ == '__main__': app = QApplication(sys.argv) - - # Create and show the main window + window = MainWindow() - main_window_instance = window # Assign to global for API access + main_window_instance = window window.show() - # Start the Flask API server in a separate thread if FLASK_AVAILABLE: api_thread = threading.Thread(target=run_api_server, daemon=True) api_thread.start() diff --git a/videoeditor/main.py b/videoeditor/main.py index e9981dc7f..d1bd5ce8d 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -139,12 +139,18 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.video_tracks_y_start = 0 self.audio_tracks_y_start = 0 - + self.hover_preview_rect = None + self.hover_preview_audio_rect = None self.drag_over_active = False self.drag_over_rect = QRectF() self.drag_over_audio_rect = QRectF() self.drag_url_cache = {} + def set_hover_preview_rects(self, video_rect, audio_rect): + self.hover_preview_rect = video_rect + self.hover_preview_audio_rect = audio_rect + self.update() + def set_project_fps(self, fps): self.project_fps = fps if fps > 0 else 25.0 self.max_pixels_per_second = self.project_fps * 20 @@ -177,6 +183,15 @@ def paintEvent(self, event): painter.fillRect(self.drag_over_audio_rect, QColor(0, 255, 0, 80)) painter.drawRect(self.drag_over_audio_rect) + if self.hover_preview_rect: + painter.setPen(QPen(QColor(0, 255, 255, 180), 2, Qt.PenStyle.DashLine)) + painter.fillRect(self.hover_preview_rect, QColor(0, 255, 255, 60)) + painter.drawRect(self.hover_preview_rect) + if self.hover_preview_audio_rect: + painter.setPen(QPen(QColor(0, 255, 255, 180), 2, Qt.PenStyle.DashLine)) + painter.fillRect(self.hover_preview_audio_rect, QColor(0, 255, 255, 60)) + painter.drawRect(self.hover_preview_audio_rect) + self.draw_playhead(painter) total_width = self.sec_to_x(self.timeline.get_total_duration()) + 200 diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py index 85fd7a4d3..410333d28 100644 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ b/videoeditor/plugins/ai_frame_joiner/main.py @@ -9,33 +9,134 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, - QLineEdit, QPushButton, QLabel, QMessageBox, QCheckBox + QLineEdit, QPushButton, QLabel, QMessageBox, QCheckBox, QListWidget, + QListWidgetItem, QGroupBox ) from PyQt6.QtGui import QImage, QPixmap -from PyQt6.QtCore import QTimer, pyqtSignal, Qt, QSize +from PyQt6.QtCore import pyqtSignal, Qt, QSize, QRectF, QUrl, QTimer +from PyQt6.QtMultimedia import QMediaPlayer +from PyQt6.QtMultimediaWidgets import QVideoWidget sys.path.append(str(Path(__file__).parent.parent.parent)) from plugins import VideoEditorPlugin - API_BASE_URL = "http://127.0.0.1:5100" +class VideoResultItemWidget(QWidget): + def __init__(self, video_path, plugin, parent=None): + super().__init__(parent) + self.video_path = video_path + self.plugin = plugin + self.app = plugin.app + self.duration = 0.0 + self.has_audio = False + + self.setMinimumSize(200, 180) + self.setMaximumHeight(190) + + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(5) + + self.media_player = QMediaPlayer() + self.video_widget = QVideoWidget() + self.video_widget.setFixedSize(160, 90) + self.media_player.setVideoOutput(self.video_widget) + self.media_player.setSource(QUrl.fromLocalFile(self.video_path)) + self.media_player.setLoops(QMediaPlayer.Loops.Infinite) + + self.info_label = QLabel(os.path.basename(video_path)) + self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.info_label.setWordWrap(True) + + self.insert_button = QPushButton("Insert into Timeline") + self.insert_button.clicked.connect(self.on_insert) + + h_layout = QHBoxLayout() + h_layout.addStretch() + h_layout.addWidget(self.video_widget) + h_layout.addStretch() + + layout.addLayout(h_layout) + layout.addWidget(self.info_label) + layout.addWidget(self.insert_button) + + self.probe_video() + + def probe_video(self): + try: + probe = ffmpeg.probe(self.video_path) + self.duration = float(probe['format']['duration']) + self.has_audio = any(s['codec_type'] == 'audio' for s in probe.get('streams', [])) + + self.info_label.setText(f"{os.path.basename(self.video_path)}\n({self.duration:.2f}s)") + + except Exception as e: + self.info_label.setText(f"Error probing:\n{os.path.basename(self.video_path)}") + print(f"Error probing video {self.video_path}: {e}") + + def enterEvent(self, event): + super().enterEvent(event) + self.media_player.play() + + if not self.plugin.active_region or self.duration == 0: + return + + start_sec, _ = self.plugin.active_region + timeline = self.app.timeline_widget + + video_rect, audio_rect = None, None + + x = timeline.sec_to_x(start_sec) + w = int(self.duration * timeline.pixels_per_second) + + if self.plugin.insert_on_new_track: + video_y = timeline.TIMESCALE_HEIGHT + video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) + if self.has_audio: + audio_y = timeline.audio_tracks_y_start + self.app.timeline.num_audio_tracks * timeline.TRACK_HEIGHT + audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) + else: + v_track_idx = 1 + visual_v_idx = self.app.timeline.num_video_tracks - v_track_idx + video_y = timeline.video_tracks_y_start + visual_v_idx * timeline.TRACK_HEIGHT + video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) + if self.has_audio: + a_track_idx = 1 + visual_a_idx = a_track_idx - 1 + audio_y = timeline.audio_tracks_y_start + visual_a_idx * timeline.TRACK_HEIGHT + audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) + + timeline.set_hover_preview_rects(video_rect, audio_rect) + + def leaveEvent(self, event): + super().leaveEvent(event) + self.media_player.pause() + self.media_player.setPosition(0) + self.app.timeline_widget.set_hover_preview_rects(None, None) + + def on_insert(self): + self.media_player.stop() + self.media_player.setSource(QUrl()) + self.media_player.setVideoOutput(None) + self.app.timeline_widget.set_hover_preview_rects(None, None) + self.plugin.insert_generated_clip(self.video_path) + class WgpClientWidget(QWidget): - generation_complete = pyqtSignal(str) status_updated = pyqtSignal(str) - def __init__(self): + def __init__(self, plugin): super().__init__() - - self.last_known_output = None + self.plugin = plugin + self.processed_files = set() layout = QVBoxLayout(self) form_layout = QFormLayout() - self.model_input = QLineEdit() - self.model_input.setPlaceholderText("Optional (default: i2v_2_2)") - - previews_layout = QHBoxLayout() + self.previews_widget = QWidget() + previews_layout = QHBoxLayout(self.previews_widget) + previews_layout.setContentsMargins(0, 0, 0, 0) + start_preview_layout = QVBoxLayout() start_preview_layout.addWidget(QLabel("Start Frame")) self.start_frame_preview = QLabel("N/A") @@ -63,12 +164,24 @@ def __init__(self): self.generate_button = QPushButton("Generate") - layout.addLayout(previews_layout) - form_layout.addRow("Model Type:", self.model_input) + layout.addWidget(self.previews_widget) form_layout.addRow(self.autostart_checkbox) form_layout.addRow(self.generate_button) layout.addLayout(form_layout) + + # --- Results Area --- + results_group = QGroupBox("Generated Clips (Hover to play, Click button to insert)") + results_layout = QVBoxLayout() + self.results_list = QListWidget() + self.results_list.setFlow(QListWidget.Flow.LeftToRight) + self.results_list.setWrapping(True) + self.results_list.setResizeMode(QListWidget.ResizeMode.Adjust) + self.results_list.setSpacing(10) + results_layout.addWidget(self.results_list) + results_group.setLayout(results_layout) + layout.addWidget(results_group) + layout.addStretch() self.generate_button.clicked.connect(self.generate) @@ -106,7 +219,7 @@ def handle_api_error(self, response, action="performing action"): def check_server_status(self): try: - requests.get(f"{API_BASE_URL}/api/latest_output", timeout=1) + requests.get(f"{API_BASE_URL}/api/outputs", timeout=1) self.status_updated.emit("AI Joiner: Connected to WanGP server.") return True except requests.exceptions.ConnectionError: @@ -114,11 +227,10 @@ def check_server_status(self): QMessageBox.critical(self, "Connection Error", f"Could not connect to the WanGP API at {API_BASE_URL}.\n\nPlease ensure wgptool.py is running.") return False - def generate(self): payload = {} - model_type = self.model_input.text().strip() + model_type = self.model_type_to_use if model_type: payload['model_type'] = model_type @@ -148,31 +260,44 @@ def generate(self): def poll_for_output(self): try: - response = requests.get(f"{API_BASE_URL}/api/latest_output") + response = requests.get(f"{API_BASE_URL}/api/outputs") if response.status_code == 200: data = response.json() - latest_path = data.get("latest_output_path") + output_files = data.get("outputs", []) - if latest_path and latest_path != self.last_known_output: - self.stop_polling() - self.last_known_output = latest_path - self.status_updated.emit(f"AI Joiner: New output received! Inserting clip...") - self.generation_complete.emit(latest_path) + new_files = set(output_files) - self.processed_files + if new_files: + self.status_updated.emit(f"AI Joiner: Received {len(new_files)} new clip(s).") + for file_path in sorted(list(new_files)): + self.add_result_item(file_path) + self.processed_files.add(file_path) + else: + self.status_updated.emit("AI Joiner: Polling for output...") else: self.status_updated.emit("AI Joiner: Polling for output...") except requests.exceptions.RequestException: self.status_updated.emit("AI Joiner: Polling... (Connection issue)") + + def add_result_item(self, video_path): + item_widget = VideoResultItemWidget(video_path, self.plugin) + list_item = QListWidgetItem(self.results_list) + list_item.setSizeHint(item_widget.sizeHint()) + self.results_list.addItem(list_item) + self.results_list.setItemWidget(list_item, item_widget) + + def clear_results(self): + self.results_list.clear() + self.processed_files.clear() class Plugin(VideoEditorPlugin): def initialize(self): self.name = "AI Frame Joiner" self.description = "Uses a local AI server to generate a video between two frames." - self.client_widget = WgpClientWidget() + self.client_widget = WgpClientWidget(self) self.dock_widget = None self.active_region = None self.temp_dir = None self.insert_on_new_track = False - self.client_widget.generation_complete.connect(self.insert_generated_clip) self.client_widget.status_updated.connect(self.update_main_status) def enable(self): @@ -202,24 +327,41 @@ def _cleanup_temp_dir(self): def _reset_state(self): self.active_region = None self.insert_on_new_track = False + self.client_widget.stop_polling() + self.client_widget.clear_results() self._cleanup_temp_dir() self.update_main_status("AI Joiner: Idle") self.client_widget.set_previews(None, None) + self.client_widget.model_type_to_use = None def on_timeline_context_menu(self, menu, event): region = self.app.timeline_widget.get_region_at_pos(event.pos()) if region: menu.addSeparator() - action = menu.addAction("Join Frames With AI") - action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) - - action_new_track = menu.addAction("Join Frames With AI (New Track)") - action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) + + start_sec, end_sec = region + start_data, _, _ = self.app.get_frame_data_at_time(start_sec) + end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + + if start_data and end_data: + join_action = menu.addAction("Join Frames With AI") + join_action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) + + join_action_new_track = menu.addAction("Join Frames With AI (New Track)") + join_action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) + + create_action = menu.addAction("Create Frames With AI") + create_action.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=False)) + + create_action_new_track = menu.addAction("Create Frames With AI (New Track)") + create_action_new_track.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=True)) def setup_generator_for_region(self, region, on_new_track=False): self._reset_state() + self.client_widget.model_type_to_use = "i2v_2_2" self.active_region = region self.insert_on_new_track = on_new_track + self.client_widget.previews_widget.setVisible(True) start_sec, end_sec = region if not self.client_widget.check_server_status(): @@ -268,6 +410,30 @@ def setup_generator_for_region(self, region, on_new_track=False): self.dock_widget.show() self.dock_widget.raise_() + def setup_creator_for_region(self, region, on_new_track=False): + self._reset_state() + self.client_widget.model_type_to_use = "t2v_2_2" + self.active_region = region + self.insert_on_new_track = on_new_track + self.client_widget.previews_widget.setVisible(False) + + start_sec, end_sec = region + if not self.client_widget.check_server_status(): + return + + self.client_widget.set_previews(None, None) + + duration_sec = end_sec - start_sec + self.client_widget.duration_input.setText(str(duration_sec)) + + self.client_widget.start_frame_input.clear() + self.client_widget.end_frame_input.clear() + + self.update_main_status(f"AI Creator: Ready for region {start_sec:.2f}s - {end_sec:.2f}s") + + self.dock_widget.show() + self.dock_widget.raise_() + def insert_generated_clip(self, video_path): from main import TimelineClip @@ -333,11 +499,14 @@ def complex_insertion_action(): self.app.prune_empty_tracks() self.update_main_status("AI clip inserted successfully.") + for i in range(self.client_widget.results_list.count()): + item = self.client_widget.results_list.item(i) + widget = self.client_widget.results_list.itemWidget(item) + if widget and widget.video_path == video_path: + self.client_widget.results_list.takeItem(i) + break + except Exception as e: error_message = f"AI Joiner Error during clip insertion/probing: {e}" self.update_main_status(error_message) - print(error_message) - - finally: - self._cleanup_temp_dir() - self.active_region = None \ No newline at end of file + print(error_message) \ No newline at end of file From deef0107e600743d060abb2a42b01f445eba2f81 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 06:38:04 +1100 Subject: [PATCH 23/67] add snapping between clips for resize --- videoeditor/main.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index d1bd5ce8d..8a5418ba8 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -574,9 +574,15 @@ def mouseMoveEvent(self, event: QMouseEvent): delta_x = event.pos().x() - self.resize_start_pos.x() time_delta = delta_x / self.pixels_per_second min_duration = 1.0 / self.project_fps - playhead_time = self.playhead_pos_sec snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_second + snap_points = [self.playhead_pos_sec] + for clip in self.timeline.clips: + if clip.id == self.resizing_clip.id: continue + if linked_clip and clip.id == linked_clip.id: continue + snap_points.append(clip.timeline_start_sec) + snap_points.append(clip.timeline_end_sec) + media_props = self.window().media_properties.get(self.resizing_clip.source_path) source_duration = media_props['duration'] if media_props else float('inf') @@ -585,10 +591,12 @@ def mouseMoveEvent(self, event: QMouseEvent): original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_sec original_clip_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].clip_start_sec true_new_start_sec = original_start + time_delta - if abs(true_new_start_sec - playhead_time) < snap_time_delta: - new_start_sec = playhead_time - else: - new_start_sec = true_new_start_sec + + new_start_sec = true_new_start_sec + for snap_point in snap_points: + if abs(true_new_start_sec - snap_point) < snap_time_delta: + new_start_sec = snap_point + break if new_start_sec > original_start + original_duration - min_duration: new_start_sec = original_start + original_duration - min_duration @@ -621,11 +629,14 @@ def mouseMoveEvent(self, event: QMouseEvent): true_new_duration = original_duration + time_delta true_new_end_time = original_start + true_new_duration - - if abs(true_new_end_time - playhead_time) < snap_time_delta: - new_duration = playhead_time - original_start - else: - new_duration = true_new_duration + + new_end_time = true_new_end_time + for snap_point in snap_points: + if abs(true_new_end_time - snap_point) < snap_time_delta: + new_end_time = snap_point + break + + new_duration = new_end_time - original_start if new_duration < min_duration: new_duration = min_duration From da346301cbdfb94ff690c7b1c4ea80abb29781ae Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 07:28:48 +1100 Subject: [PATCH 24/67] fixed minimize closing all windows --- videoeditor/main.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 8a5418ba8..90e36fad5 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -1482,6 +1482,10 @@ def remove_track(self, track_type): command.undo() self.undo_stack.push(command) + def on_dock_visibility_changed(self, action, visible): + if self.isMinimized(): + return + action.setChecked(visible) def _create_menu_bar(self): menu_bar = self.menuBar() @@ -1533,7 +1537,7 @@ def _create_menu_bar(self): action = QAction(data['name'], self, checkable=True) if hasattr(data['widget'], 'visibilityChanged'): action.toggled.connect(data['widget'].setVisible) - data['widget'].visibilityChanged.connect(action.setChecked) + data['widget'].visibilityChanged.connect(lambda visible, a=action: self.on_dock_visibility_changed(a, visible)) else: action.toggled.connect(lambda checked, k=key: self.toggle_widget_visibility(k, checked)) @@ -1766,7 +1770,6 @@ def _save_settings(self): def _apply_loaded_settings(self): visibility_settings = self.settings.get("window_visibility", {}) for key, data in self.managed_widgets.items(): - if data.get('plugin'): continue is_visible = visibility_settings.get(key, False if key == 'project_media' else True) if data['widget'] is not self.preview_widget: data['widget'].setVisible(is_visible) @@ -2373,7 +2376,7 @@ def add_dock_widget(self, plugin_instance, widget, title, area=Qt.DockWidgetArea dock.setVisible(initial_visibility) action = QAction(title, self, checkable=True) action.toggled.connect(dock.setVisible) - dock.visibilityChanged.connect(action.setChecked) + dock.visibilityChanged.connect(lambda visible, a=action: self.on_dock_visibility_changed(a, visible)) action.setChecked(dock.isVisible()) self.windows_menu.addAction(action) self.managed_widgets[widget_key] = {'widget': dock, 'name': title, 'action': action, 'plugin': plugin_instance.name} From 8b51fc48d8d26d3a2ab6c80ec7119e5a37a7e503 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 07:33:49 +1100 Subject: [PATCH 25/67] fixed export with images and blank canvas --- videoeditor/main.py | 75 ++++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 90e36fad5..d1cda9fa9 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -2277,40 +2277,51 @@ def export_video(self): w, h, fr_str, total_dur = self.project_width, self.project_height, str(self.project_fps), self.timeline.get_total_duration() sample_rate, channel_layout = '44100', 'stereo' - - input_streams = {} - last_video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr_str}:d={total_dur}', f='lavfi') - for i in range(self.timeline.num_video_tracks): - track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'video' and c.track_index == i + 1], key=lambda c: c.timeline_start_sec) - if not track_clips: continue - - track_segments = [] - last_end = track_clips[0].timeline_start_sec - - for clip in track_clips: - gap = clip.timeline_start_sec - last_end - if gap > 0.01: - track_segments.append(ffmpeg.input(f'color=c=black@0.0:s={w}x{h}:r={fr_str}:d={gap}', f='lavfi').filter('format', pix_fmts='rgba')) - - if clip.source_path not in input_streams: - if clip.media_type == 'image': - input_streams[clip.source_path] = ffmpeg.input(clip.source_path, loop=1, framerate=self.project_fps) - else: - input_streams[clip.source_path] = ffmpeg.input(clip.source_path) + video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr_str}:d={total_dur}', f='lavfi') + + all_video_clips = sorted( + [c for c in self.timeline.clips if c.track_type == 'video'], + key=lambda c: c.track_index + ) + input_nodes = {} + + for clip in all_video_clips: + if clip.source_path not in input_nodes: if clip.media_type == 'image': - v_seg = (input_streams[clip.source_path].video.trim(duration=clip.duration_sec).setpts('PTS-STARTPTS')) + input_nodes[clip.source_path] = ffmpeg.input(clip.source_path, loop=1, framerate=self.project_fps) else: - v_seg = (input_streams[clip.source_path].video.trim(start=clip.clip_start_sec, duration=clip.duration_sec).setpts('PTS-STARTPTS')) - - v_seg = (v_seg.filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black').filter('format', pix_fmts='rgba')) - track_segments.append(v_seg) - last_end = clip.timeline_end_sec + input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) - track_stream = ffmpeg.concat(*track_segments, v=1, a=0).filter('setpts', f'PTS-STARTPTS+{track_clips[0].timeline_start_sec}/TB') - last_video_stream = ffmpeg.overlay(last_video_stream, track_stream) - final_video = last_video_stream.filter('format', pix_fmts='yuv420p').filter('fps', fps=self.project_fps) + clip_source_node = input_nodes[clip.source_path] + + if clip.media_type == 'image': + segment_stream = ( + clip_source_node.video + .trim(duration=clip.duration_sec) + .setpts('PTS-STARTPTS') + ) + else: + segment_stream = ( + clip_source_node.video + .trim(start=clip.clip_start_sec, duration=clip.duration_sec) + .setpts('PTS-STARTPTS') + ) + + processed_segment = ( + segment_stream + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + ) + + video_stream = ffmpeg.overlay( + video_stream, + processed_segment, + enable=f'between(t,{clip.timeline_start_sec},{clip.timeline_end_sec})' + ) + + final_video = video_stream.filter('format', pix_fmts='yuv420p').filter('fps', fps=self.project_fps) track_audio_streams = [] for i in range(self.timeline.num_audio_tracks): @@ -2321,14 +2332,14 @@ def export_video(self): last_end = track_clips[0].timeline_start_sec for clip in track_clips: - if clip.source_path not in input_streams: - input_streams[clip.source_path] = ffmpeg.input(clip.source_path) + if clip.source_path not in input_nodes: + input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) gap = clip.timeline_start_sec - last_end if gap > 0.01: track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap}', f='lavfi')) - a_seg = input_streams[clip.source_path].audio.filter('atrim', start=clip.clip_start_sec, duration=clip.duration_sec).filter('asetpts', 'PTS-STARTPTS') + a_seg = input_nodes[clip.source_path].audio.filter('atrim', start=clip.clip_start_sec, duration=clip.duration_sec).filter('asetpts', 'PTS-STARTPTS') track_segments.append(a_seg) last_end = clip.timeline_end_sec From 836a172ff28b1397b3f42f39cf7fc0469058fb93 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 08:03:38 +1100 Subject: [PATCH 26/67] fix multi-track preview rendering --- videoeditor/main.py | 179 ++++++++++++++++++++++++++++++-------------- 1 file changed, 124 insertions(+), 55 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index d1cda9fa9..9cfca35b0 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -1258,7 +1258,6 @@ def __init__(self, project_to_load=None): self.project_height = 720 self.playback_timer = QTimer(self) self.playback_process = None - self.playback_clip = None self._setup_ui() self._connect_signals() @@ -1546,19 +1545,64 @@ def _create_menu_bar(self): def _start_playback_stream_at(self, time_sec): self._stop_playback_stream() - clip = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) - if not clip: return - self.playback_clip = clip - clip_time = time_sec - clip.timeline_start_sec + clip.clip_start_sec - w, h = self.project_width, self.project_height + if not self.timeline.clips: + return + + w, h, fr = self.project_width, self.project_height, self.project_fps + total_dur = self.timeline.get_total_duration() + + video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr}:d={total_dur}', f='lavfi') + + all_video_clips = sorted( + [c for c in self.timeline.clips if c.track_type == 'video'], + key=lambda c: c.track_index + ) + + input_nodes = {} + for clip in all_video_clips: + if clip.source_path not in input_nodes: + if clip.media_type == 'image': + input_nodes[clip.source_path] = ffmpeg.input(clip.source_path, loop=1, framerate=fr) + else: + input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) + + clip_source_node = input_nodes[clip.source_path] + + if clip.media_type == 'image': + segment_stream = ( + clip_source_node.video + .trim(duration=clip.duration_sec) + .setpts('PTS-STARTPTS') + ) + else: + segment_stream = ( + clip_source_node.video + .trim(start=clip.clip_start_sec, duration=clip.duration_sec) + .setpts('PTS-STARTPTS') + ) + + processed_segment = ( + segment_stream + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + ) + + video_stream = ffmpeg.overlay( + video_stream, + processed_segment, + enable=f'between(t,{clip.timeline_start_sec},{clip.timeline_end_sec})' + ) + try: - args = (ffmpeg.input(self.playback_clip.source_path, ss=clip_time) - .filter('scale', w, h, force_original_aspect_ratio='decrease') - .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') - .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=self.project_fps).compile()) + args = ( + video_stream + .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=fr, ss=time_sec) + .compile() + ) self.playback_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) except Exception as e: - print(f"Failed to start playback stream: {e}"); self._stop_playback_stream() + print(f"Failed to start playback stream: {e}") + self._stop_playback_stream() def _stop_playback_stream(self): if self.playback_process: @@ -1567,7 +1611,6 @@ def _stop_playback_stream(self): try: self.playback_process.wait(timeout=0.5) except subprocess.TimeoutExpired: self.playback_process.kill(); self.playback_process.wait() self.playback_process = None - self.playback_clip = None def _get_media_properties(self, file_path): """Probes a file to get its media properties. Returns a dict or None.""" @@ -1623,25 +1666,33 @@ def _probe_for_drag(self, file_path): return self._get_media_properties(file_path) def get_frame_data_at_time(self, time_sec): - clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) - if not clip_at_time: + clips_at_time = [ + c for c in self.timeline.clips + if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec + ] + + if not clips_at_time: return (None, 0, 0) + + clips_at_time.sort(key=lambda c: c.track_index, reverse=True) + clip_to_render = clips_at_time[0] + try: w, h = self.project_width, self.project_height - if clip_at_time.media_type == 'image': + if clip_to_render.media_type == 'image': out, _ = ( ffmpeg - .input(clip_at_time.source_path) + .input(clip_to_render.source_path) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') .run(capture_stdout=True, quiet=True) ) else: - clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec + clip_time = time_sec - clip_to_render.timeline_start_sec + clip_to_render.clip_start_sec out, _ = ( ffmpeg - .input(clip_at_time.source_path, ss=clip_time) + .input(clip_to_render.source_path, ss=clip_time) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') @@ -1653,22 +1704,33 @@ def get_frame_data_at_time(self, time_sec): return (None, 0, 0) def get_frame_at_time(self, time_sec): - clip_at_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec), None) - black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) - if not clip_at_time: return black_pixmap + clips_at_time = [ + c for c in self.timeline.clips + if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec + ] + + black_pixmap = QPixmap(self.project_width, self.project_height) + black_pixmap.fill(QColor("black")) + + if not clips_at_time: + return black_pixmap + + clips_at_time.sort(key=lambda c: c.track_index, reverse=True) + clip_to_render = clips_at_time[0] + try: w, h = self.project_width, self.project_height - if clip_at_time.media_type == 'image': + if clip_to_render.media_type == 'image': out, _ = ( - ffmpeg.input(clip_at_time.source_path) + ffmpeg.input(clip_to_render.source_path) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') .run(capture_stdout=True, quiet=True) ) else: - clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec - out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time) + clip_time = time_sec - clip_to_render.timeline_start_sec + clip_to_render.clip_start_sec + out, _ = (ffmpeg.input(clip_to_render.source_path, ss=clip_time) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') @@ -1676,10 +1738,16 @@ def get_frame_at_time(self, time_sec): image = QImage(out, self.project_width, self.project_height, QImage.Format.Format_RGB888) return QPixmap.fromImage(image) - except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return black_pixmap + except ffmpeg.Error as e: + print(f"Error extracting frame: {e.stderr}") + return black_pixmap def seek_preview(self, time_sec): - self._stop_playback_stream() + if self.playback_timer.isActive(): + self.playback_timer.stop() + self._stop_playback_stream() + self.play_pause_button.setText("Play") + self.timeline_widget.playhead_pos_sec = time_sec self.timeline_widget.update() frame_pixmap = self.get_frame_at_time(time_sec) @@ -1688,16 +1756,35 @@ def seek_preview(self, time_sec): self.preview_widget.setPixmap(scaled_pixmap) def toggle_playback(self): - if self.playback_timer.isActive(): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play") + if self.playback_timer.isActive(): + self.playback_timer.stop() + self._stop_playback_stream() + self.play_pause_button.setText("Play") else: if not self.timeline.clips: return - if self.timeline_widget.playhead_pos_sec >= self.timeline.get_total_duration(): self.timeline_widget.playhead_pos_sec = 0.0 - self.playback_timer.start(int(1000 / self.project_fps)); self.play_pause_button.setText("Pause") + + playhead_time = self.timeline_widget.playhead_pos_sec + if playhead_time >= self.timeline.get_total_duration(): + playhead_time = 0.0 + self.timeline_widget.playhead_pos_sec = 0.0 + + self._start_playback_stream_at(playhead_time) + + if self.playback_process: + self.playback_timer.start(int(1000 / self.project_fps)) + self.play_pause_button.setText("Pause") + + def stop_playback(self): + self.playback_timer.stop() + self._stop_playback_stream() + self.play_pause_button.setText("Play") + self.seek_preview(0.0) - def stop_playback(self): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play"); self.seek_preview(0.0) def step_frame(self, direction): if not self.timeline.clips: return - self.playback_timer.stop(); self.play_pause_button.setText("Play"); self._stop_playback_stream() + self.playback_timer.stop() + self._stop_playback_stream() + self.play_pause_button.setText("Play") frame_duration = 1.0 / self.project_fps new_time = self.timeline_widget.playhead_pos_sec + (direction * frame_duration) self.seek_preview(max(0, min(new_time, self.timeline.get_total_duration()))) @@ -1705,33 +1792,13 @@ def step_frame(self, direction): def advance_playback_frame(self): frame_duration = 1.0 / self.project_fps new_time = self.timeline_widget.playhead_pos_sec + frame_duration - if new_time > self.timeline.get_total_duration(): self.stop_playback(); return + if new_time > self.timeline.get_total_duration(): + self.stop_playback() + return self.timeline_widget.playhead_pos_sec = new_time self.timeline_widget.update() - clip_at_new_time = next((c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= new_time < c.timeline_end_sec), None) - - if not clip_at_new_time: - self._stop_playback_stream() - black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) - scaled_pixmap = black_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - self.preview_widget.setPixmap(scaled_pixmap) - return - - if clip_at_new_time.media_type == 'image': - if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: - self._stop_playback_stream() - self.playback_clip = clip_at_new_time - frame_pixmap = self.get_frame_at_time(new_time) - if frame_pixmap: - scaled_pixmap = frame_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - self.preview_widget.setPixmap(scaled_pixmap) - return - - if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: - self._start_playback_stream_at(new_time) - if self.playback_process: frame_size = self.project_width * self.project_height * 3 frame_bytes = self.playback_process.stdout.read(frame_size) @@ -1742,6 +1809,8 @@ def advance_playback_frame(self): self.preview_widget.setPixmap(scaled_pixmap) else: self._stop_playback_stream() + if self.playback_timer.isActive(): + self.stop_playback() def _load_settings(self): self.settings_file_was_loaded = False From 109a2aa0468f9e332bbaccb7ad3675cbddcfd10c Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 08:51:51 +1100 Subject: [PATCH 27/67] fix multi-track preview rendering (2) --- videoeditor/main.py | 178 +++++++++++++++----------------------------- 1 file changed, 59 insertions(+), 119 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 9cfca35b0..49f97c8cc 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -1258,6 +1258,7 @@ def __init__(self, project_to_load=None): self.project_height = 720 self.playback_timer = QTimer(self) self.playback_process = None + self.playback_clip = None self._setup_ui() self._connect_signals() @@ -1545,64 +1546,22 @@ def _create_menu_bar(self): def _start_playback_stream_at(self, time_sec): self._stop_playback_stream() - if not self.timeline.clips: + clips_at_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec] + if not clips_at_time: return + clip = sorted(clips_at_time, key=lambda c: c.track_index, reverse=True)[0] - w, h, fr = self.project_width, self.project_height, self.project_fps - total_dur = self.timeline.get_total_duration() - - video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr}:d={total_dur}', f='lavfi') - - all_video_clips = sorted( - [c for c in self.timeline.clips if c.track_type == 'video'], - key=lambda c: c.track_index - ) - - input_nodes = {} - for clip in all_video_clips: - if clip.source_path not in input_nodes: - if clip.media_type == 'image': - input_nodes[clip.source_path] = ffmpeg.input(clip.source_path, loop=1, framerate=fr) - else: - input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) - - clip_source_node = input_nodes[clip.source_path] - - if clip.media_type == 'image': - segment_stream = ( - clip_source_node.video - .trim(duration=clip.duration_sec) - .setpts('PTS-STARTPTS') - ) - else: - segment_stream = ( - clip_source_node.video - .trim(start=clip.clip_start_sec, duration=clip.duration_sec) - .setpts('PTS-STARTPTS') - ) - - processed_segment = ( - segment_stream - .filter('scale', w, h, force_original_aspect_ratio='decrease') - .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') - ) - - video_stream = ffmpeg.overlay( - video_stream, - processed_segment, - enable=f'between(t,{clip.timeline_start_sec},{clip.timeline_end_sec})' - ) - + self.playback_clip = clip + clip_time = time_sec - clip.timeline_start_sec + clip.clip_start_sec + w, h = self.project_width, self.project_height try: - args = ( - video_stream - .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=fr, ss=time_sec) - .compile() - ) + args = (ffmpeg.input(self.playback_clip.source_path, ss=clip_time) + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=self.project_fps).compile()) self.playback_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) except Exception as e: - print(f"Failed to start playback stream: {e}") - self._stop_playback_stream() + print(f"Failed to start playback stream: {e}"); self._stop_playback_stream() def _stop_playback_stream(self): if self.playback_process: @@ -1611,6 +1570,7 @@ def _stop_playback_stream(self): try: self.playback_process.wait(timeout=0.5) except subprocess.TimeoutExpired: self.playback_process.kill(); self.playback_process.wait() self.playback_process = None + self.playback_clip = None def _get_media_properties(self, file_path): """Probes a file to get its media properties. Returns a dict or None.""" @@ -1666,33 +1626,26 @@ def _probe_for_drag(self, file_path): return self._get_media_properties(file_path) def get_frame_data_at_time(self, time_sec): - clips_at_time = [ - c for c in self.timeline.clips - if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec - ] - + clips_at_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec] if not clips_at_time: return (None, 0, 0) - - clips_at_time.sort(key=lambda c: c.track_index, reverse=True) - clip_to_render = clips_at_time[0] - + clip_at_time = sorted(clips_at_time, key=lambda c: c.track_index, reverse=True)[0] try: w, h = self.project_width, self.project_height - if clip_to_render.media_type == 'image': + if clip_at_time.media_type == 'image': out, _ = ( ffmpeg - .input(clip_to_render.source_path) + .input(clip_at_time.source_path) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') .run(capture_stdout=True, quiet=True) ) else: - clip_time = time_sec - clip_to_render.timeline_start_sec + clip_to_render.clip_start_sec + clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec out, _ = ( ffmpeg - .input(clip_to_render.source_path, ss=clip_time) + .input(clip_at_time.source_path, ss=clip_time) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') @@ -1704,33 +1657,24 @@ def get_frame_data_at_time(self, time_sec): return (None, 0, 0) def get_frame_at_time(self, time_sec): - clips_at_time = [ - c for c in self.timeline.clips - if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec - ] - - black_pixmap = QPixmap(self.project_width, self.project_height) - black_pixmap.fill(QColor("black")) - + clips_at_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec] + black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) if not clips_at_time: return black_pixmap - - clips_at_time.sort(key=lambda c: c.track_index, reverse=True) - clip_to_render = clips_at_time[0] - + clip_at_time = sorted(clips_at_time, key=lambda c: c.track_index, reverse=True)[0] try: w, h = self.project_width, self.project_height - if clip_to_render.media_type == 'image': + if clip_at_time.media_type == 'image': out, _ = ( - ffmpeg.input(clip_to_render.source_path) + ffmpeg.input(clip_at_time.source_path) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') .run(capture_stdout=True, quiet=True) ) else: - clip_time = time_sec - clip_to_render.timeline_start_sec + clip_to_render.clip_start_sec - out, _ = (ffmpeg.input(clip_to_render.source_path, ss=clip_time) + clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec + out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time) .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') @@ -1738,16 +1682,10 @@ def get_frame_at_time(self, time_sec): image = QImage(out, self.project_width, self.project_height, QImage.Format.Format_RGB888) return QPixmap.fromImage(image) - except ffmpeg.Error as e: - print(f"Error extracting frame: {e.stderr}") - return black_pixmap + except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return black_pixmap def seek_preview(self, time_sec): - if self.playback_timer.isActive(): - self.playback_timer.stop() - self._stop_playback_stream() - self.play_pause_button.setText("Play") - + self._stop_playback_stream() self.timeline_widget.playhead_pos_sec = time_sec self.timeline_widget.update() frame_pixmap = self.get_frame_at_time(time_sec) @@ -1756,35 +1694,16 @@ def seek_preview(self, time_sec): self.preview_widget.setPixmap(scaled_pixmap) def toggle_playback(self): - if self.playback_timer.isActive(): - self.playback_timer.stop() - self._stop_playback_stream() - self.play_pause_button.setText("Play") + if self.playback_timer.isActive(): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play") else: if not self.timeline.clips: return - - playhead_time = self.timeline_widget.playhead_pos_sec - if playhead_time >= self.timeline.get_total_duration(): - playhead_time = 0.0 - self.timeline_widget.playhead_pos_sec = 0.0 - - self._start_playback_stream_at(playhead_time) - - if self.playback_process: - self.playback_timer.start(int(1000 / self.project_fps)) - self.play_pause_button.setText("Pause") - - def stop_playback(self): - self.playback_timer.stop() - self._stop_playback_stream() - self.play_pause_button.setText("Play") - self.seek_preview(0.0) + if self.timeline_widget.playhead_pos_sec >= self.timeline.get_total_duration(): self.timeline_widget.playhead_pos_sec = 0.0 + self.playback_timer.start(int(1000 / self.project_fps)); self.play_pause_button.setText("Pause") + def stop_playback(self): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play"); self.seek_preview(0.0) def step_frame(self, direction): if not self.timeline.clips: return - self.playback_timer.stop() - self._stop_playback_stream() - self.play_pause_button.setText("Play") + self.playback_timer.stop(); self.play_pause_button.setText("Play"); self._stop_playback_stream() frame_duration = 1.0 / self.project_fps new_time = self.timeline_widget.playhead_pos_sec + (direction * frame_duration) self.seek_preview(max(0, min(new_time, self.timeline.get_total_duration()))) @@ -1792,13 +1711,36 @@ def step_frame(self, direction): def advance_playback_frame(self): frame_duration = 1.0 / self.project_fps new_time = self.timeline_widget.playhead_pos_sec + frame_duration - if new_time > self.timeline.get_total_duration(): - self.stop_playback() - return + if new_time > self.timeline.get_total_duration(): self.stop_playback(); return self.timeline_widget.playhead_pos_sec = new_time self.timeline_widget.update() + clips_at_new_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= new_time < c.timeline_end_sec] + clip_at_new_time = None + if clips_at_new_time: + clip_at_new_time = sorted(clips_at_new_time, key=lambda c: c.track_index, reverse=True)[0] + + if not clip_at_new_time: + self._stop_playback_stream() + black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) + scaled_pixmap = black_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.preview_widget.setPixmap(scaled_pixmap) + return + + if clip_at_new_time.media_type == 'image': + if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: + self._stop_playback_stream() + self.playback_clip = clip_at_new_time + frame_pixmap = self.get_frame_at_time(new_time) + if frame_pixmap: + scaled_pixmap = frame_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.preview_widget.setPixmap(scaled_pixmap) + return + + if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: + self._start_playback_stream_at(new_time) + if self.playback_process: frame_size = self.project_width * self.project_height * 3 frame_bytes = self.playback_process.stdout.read(frame_size) @@ -1809,8 +1751,6 @@ def advance_playback_frame(self): self.preview_widget.setPixmap(scaled_pixmap) else: self._stop_playback_stream() - if self.playback_timer.isActive(): - self.stop_playback() def _load_settings(self): self.settings_file_was_loaded = False From de9ba22e1df8f140a0bacce13bfdcdeb5b2599da Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 08:59:41 +1100 Subject: [PATCH 28/67] speed up seeking --- videoeditor/main.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 49f97c8cc..996e8ba2a 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -1544,12 +1544,19 @@ def _create_menu_bar(self): data['action'] = action self.windows_menu.addAction(action) + def _get_topmost_video_clip_at(self, time_sec): + """Finds the video clip on the highest track at a specific time.""" + top_clip = None + for c in self.timeline.clips: + if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec: + if top_clip is None or c.track_index > top_clip.track_index: + top_clip = c + return top_clip + def _start_playback_stream_at(self, time_sec): self._stop_playback_stream() - clips_at_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec] - if not clips_at_time: - return - clip = sorted(clips_at_time, key=lambda c: c.track_index, reverse=True)[0] + clip = self._get_topmost_video_clip_at(time_sec) + if not clip: return self.playback_clip = clip clip_time = time_sec - clip.timeline_start_sec + clip.clip_start_sec @@ -1626,10 +1633,9 @@ def _probe_for_drag(self, file_path): return self._get_media_properties(file_path) def get_frame_data_at_time(self, time_sec): - clips_at_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec] - if not clips_at_time: + clip_at_time = self._get_topmost_video_clip_at(time_sec) + if not clip_at_time: return (None, 0, 0) - clip_at_time = sorted(clips_at_time, key=lambda c: c.track_index, reverse=True)[0] try: w, h = self.project_width, self.project_height if clip_at_time.media_type == 'image': @@ -1657,11 +1663,9 @@ def get_frame_data_at_time(self, time_sec): return (None, 0, 0) def get_frame_at_time(self, time_sec): - clips_at_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec] black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) - if not clips_at_time: - return black_pixmap - clip_at_time = sorted(clips_at_time, key=lambda c: c.track_index, reverse=True)[0] + clip_at_time = self._get_topmost_video_clip_at(time_sec) + if not clip_at_time: return black_pixmap try: w, h = self.project_width, self.project_height if clip_at_time.media_type == 'image': @@ -1716,10 +1720,7 @@ def advance_playback_frame(self): self.timeline_widget.playhead_pos_sec = new_time self.timeline_widget.update() - clips_at_new_time = [c for c in self.timeline.clips if c.track_type == 'video' and c.timeline_start_sec <= new_time < c.timeline_end_sec] - clip_at_new_time = None - if clips_at_new_time: - clip_at_new_time = sorted(clips_at_new_time, key=lambda c: c.track_index, reverse=True)[0] + clip_at_new_time = self._get_topmost_video_clip_at(new_time) if not clip_at_new_time: self._stop_playback_stream() From 153c858d4ff60fa61ebfb437792eda6b6c9fbc90 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 09:40:51 +1100 Subject: [PATCH 29/67] add select clips, delete key and left right arrow keys --- videoeditor/main.py | 82 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/videoeditor/main.py b/videoeditor/main.py index 996e8ba2a..4bd3662c6 100644 --- a/videoeditor/main.py +++ b/videoeditor/main.py @@ -11,7 +11,7 @@ QScrollArea, QFrame, QProgressBar, QDialog, QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget, QListWidget, QListWidgetItem, QMessageBox) from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, - QPixmap, QImage, QDrag, QCursor) + QPixmap, QImage, QDrag, QCursor, QKeyEvent) from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, pyqtSignal, QTimer, QByteArray, QMimeData) @@ -87,6 +87,7 @@ class TimelineWidget(QWidget): split_requested = pyqtSignal(object) delete_clip_requested = pyqtSignal(object) + delete_clips_requested = pyqtSignal(list) playhead_moved = pyqtSignal(float) split_region_requested = pyqtSignal(list) split_all_regions_requested = pyqtSignal(list) @@ -114,7 +115,9 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.setMinimumHeight(300) self.setMouseTracking(True) self.setAcceptDrops(True) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.selection_regions = [] + self.selected_clips = set() self.dragging_clip = None self.dragging_linked_clip = None self.dragging_playhead = False @@ -404,6 +407,12 @@ def draw_tracks_and_clips(self, painter): color = QColor("#5A9") if self.dragging_clip and self.dragging_clip.id == clip.id else base_color painter.fillRect(clip_rect, color) + + if clip.id in self.selected_clips: + pen = QPen(QColor(255, 255, 0, 220), 2) + painter.setPen(pen) + painter.drawRect(clip_rect) + painter.setPen(QPen(QColor("#FFF"), 1)) font = QFont("Arial", 10) painter.setFont(font) @@ -502,11 +511,13 @@ def mousePressEvent(self, event: QMouseEvent): return if event.button() == Qt.MouseButton.LeftButton: + self.setFocus() + self.dragging_clip = None self.dragging_linked_clip = None self.dragging_playhead = False - self.dragging_selection_region = None self.creating_selection_region = False + self.dragging_selection_region = None self.resizing_clip = None self.resize_edge = None self.drag_original_clip_states.clear() @@ -528,22 +539,36 @@ def mousePressEvent(self, event: QMouseEvent): self.update() return + clicked_clip = None for clip in reversed(self.timeline.clips): - clip_rect = self.get_clip_rect(clip) - if clip_rect.contains(QPointF(event.pos())): - self.dragging_clip = clip + if self.get_clip_rect(clip).contains(QPointF(event.pos())): + clicked_clip = clip + break + + if clicked_clip: + is_ctrl_pressed = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier) + + if clicked_clip.id in self.selected_clips: + if is_ctrl_pressed: + self.selected_clips.remove(clicked_clip.id) + else: + if not is_ctrl_pressed: + self.selected_clips.clear() + self.selected_clips.add(clicked_clip.id) + + if clicked_clip.id in self.selected_clips: + self.dragging_clip = clicked_clip self.drag_start_state = self.window()._get_current_timeline_state() - self.drag_original_clip_states[clip.id] = (clip.timeline_start_sec, clip.track_index) + self.drag_original_clip_states[clicked_clip.id] = (clicked_clip.timeline_start_sec, clicked_clip.track_index) - self.dragging_linked_clip = next((c for c in self.timeline.clips if c.group_id == clip.group_id and c.id != clip.id), None) + self.dragging_linked_clip = next((c for c in self.timeline.clips if c.group_id == clicked_clip.group_id and c.id != clicked_clip.id), None) if self.dragging_linked_clip: self.drag_original_clip_states[self.dragging_linked_clip.id] = \ (self.dragging_linked_clip.timeline_start_sec, self.dragging_linked_clip.track_index) - self.drag_start_pos = event.pos() - break - - if not self.dragging_clip: + + else: + self.selected_clips.clear() region_to_drag = self.get_region_at_pos(event.pos()) if region_to_drag: self.dragging_selection_region = region_to_drag @@ -1108,6 +1133,19 @@ def clear_all_regions(self): self.selection_regions.clear() self.update() + def keyPressEvent(self, event: QKeyEvent): + if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace: + if self.selected_clips: + clips_to_delete = [c for c in self.timeline.clips if c.id in self.selected_clips] + if clips_to_delete: + self.delete_clips_requested.emit(clips_to_delete) + elif event.key() == Qt.Key.Key_Left: + self.window().step_frame(-1) + elif event.key() == Qt.Key.Key_Right: + self.window().step_frame(1) + else: + super().keyPressEvent(event) + class SettingsDialog(QDialog): def __init__(self, parent_settings, parent=None): @@ -1352,6 +1390,7 @@ def _connect_signals(self): self.timeline_widget.split_requested.connect(self.split_clip_at_playhead) self.timeline_widget.delete_clip_requested.connect(self.delete_clip) + self.timeline_widget.delete_clips_requested.connect(self.delete_clips) self.timeline_widget.playhead_moved.connect(self.seek_preview) self.timeline_widget.split_region_requested.connect(self.on_split_region) self.timeline_widget.split_all_regions_requested.connect(self.on_split_all_regions) @@ -2066,18 +2105,29 @@ def split_clip_at_playhead(self, clip_to_split=None): def delete_clip(self, clip_to_delete): + self.delete_clips([clip_to_delete]) + + def delete_clips(self, clips_to_delete): + if not clips_to_delete: return + old_state = self._get_current_timeline_state() - linked_clip = next((c for c in self.timeline.clips if c.group_id == clip_to_delete.group_id and c.id != clip_to_delete.id), None) + ids_to_remove = set() + for clip in clips_to_delete: + ids_to_remove.add(clip.id) + linked_clips = [c for c in self.timeline.clips if c.group_id == clip.group_id and c.id != clip.id] + for lc in linked_clips: + ids_to_remove.add(lc.id) + + self.timeline.clips = [c for c in self.timeline.clips if c.id not in ids_to_remove] + self.timeline_widget.selected_clips.clear() - if clip_to_delete in self.timeline.clips: self.timeline.clips.remove(clip_to_delete) - if linked_clip and linked_clip in self.timeline.clips: self.timeline.clips.remove(linked_clip) - new_state = self._get_current_timeline_state() - command = TimelineStateChangeCommand("Delete Clip", self.timeline, *old_state, *new_state) + command = TimelineStateChangeCommand(f"Delete {len(clips_to_delete)} Clip(s)", self.timeline, *old_state, *new_state) command.undo() self.undo_stack.push(command) self.prune_empty_tracks() + self.timeline_widget.update() def unlink_clip_pair(self, clip_to_unlink): old_state = self._get_current_timeline_state() From 69bb8221f7da8edf8e8c92c43ebdcc63e6bd4e1e Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 21:13:25 +1100 Subject: [PATCH 30/67] finally moved from server/client to a native plugin --- README.md | 287 +--- videoeditor/plugins.py => plugins.py | 13 +- main.py => plugins/wan2gp/main.py | 1311 +++++++------------ videoeditor/undo.py => undo.py | 0 videoeditor/main.py => videoeditor.py | 3 +- videoeditor/plugins/ai_frame_joiner/main.py | 512 -------- 6 files changed, 518 insertions(+), 1608 deletions(-) rename videoeditor/plugins.py => plugins.py (96%) rename main.py => plugins/wan2gp/main.py (69%) rename videoeditor/undo.py => undo.py (100%) rename videoeditor/main.py => videoeditor.py (99%) delete mode 100644 videoeditor/plugins/ai_frame_joiner/main.py diff --git a/README.md b/README.md index 6464a18f0..3726bd212 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A simple, non-linear video editor built with Python, PyQt6, and FFmpeg. It provi ## Features -- **Inline AI Video Generation With WAN2GP**: Select a region to join and it will bring up a desktop port of WAN2GP for you to generate a video inline using the start and end frames in the selected region. +- **Inline AI Video Generation With WAN2GP**: Select a region to join and it will bring up a desktop port of WAN2GP for you to generate a video inline using the start and end frames in the selected region. You can also create frames in the selected region. - **Multi-Track Timeline**: Arrange video and audio clips on separate tracks. - **Project Management**: Create, save, and load projects in a `.json` format. - **Clip Operations**: @@ -12,6 +12,7 @@ A simple, non-linear video editor built with Python, PyQt6, and FFmpeg. It provi - Split clips at the playhead. - Create selection regions for advanced operations. - Join/remove content within selected regions across all tracks. + - Link/Unlink audio tracks from video - **Real-time Preview**: A video preview window with playback controls (Play, Pause, Stop, Frame-by-frame stepping). - **Dynamic Track Management**: Add or remove video and audio tracks as needed. - **FFmpeg Integration**: @@ -23,286 +24,30 @@ A simple, non-linear video editor built with Python, PyQt6, and FFmpeg. It provi ## Installation -**Follow the standard installation steps for WAN2GP below** - -**Run the server:** -```bash -python main.py -``` - -**Run the video editor:** ```bash -cd videoeditor -python main.py -``` - -## Screenshots -image -image -image -image - - - - - -# What follows is the standard WanGP Readme - ------ -

-WanGP by DeepBeepMeep : The best Open Source Video Generative Models Accessible to the GPU Poor -

- -WanGP supports the Wan (and derived models), Hunyuan Video and LTV Video models with: -- Low VRAM requirements (as low as 6 GB of VRAM is sufficient for certain models) -- Support for old GPUs (RTX 10XX, 20xx, ...) -- Very Fast on the latest GPUs -- Easy to use Full Web based interface -- Auto download of the required model adapted to your specific architecture -- Tools integrated to facilitate Video Generation : Mask Editor, Prompt Enhancer, Temporal and Spatial Generation, MMAudio, Video Browser, Pose / Depth / Flow extractor -- Loras Support to customize each model -- Queuing system : make your shopping list of videos to generate and come back later - -**Discord Server to get Help from Other Users and show your Best Videos:** https://discord.gg/g7efUW9jGV - -**Follow DeepBeepMeep on Twitter/X to get the Latest News**: https://x.com/deepbeepmeep - -## 🔥 Latest Updates : -### October 6 2025: WanGP v8.994 - A few last things before the Big Unknown ... - -This new version hasn't any new model... - -...but temptation to upgrade will be high as it contains a few Loras related features that may change your Life: -- **Ready to use Loras Accelerators Profiles** per type of model that you can apply on your current *Generation Settings*. Next time I will recommend a *Lora Accelerator*, it will be only one click away. And best of all of the required Loras will be downloaded automatically. When you apply an *Accelerator Profile*, input fields like the *Number of Denoising Steps* *Activated Loras*, *Loras Multipliers* (such as "1;0 0;1" ...) will be automatically filled. However your video specific fields will be preserved, so it will be easy to switch between Profiles to experiment. With *WanGP 8.993*, the *Accelerator Loras* are now merged with *Non Accelerator Loras". Things are getting too easy... - -- **Embedded Loras URL** : WanGP will now try to remember every Lora URLs it sees. For instance if someone sends you some settings that contain Loras URLs or you extract the Settings of Video generated by a friend with Loras URLs, these URLs will be automatically added to *WanGP URL Cache*. Conversely everything you will share (Videos, Settings, Lset files) will contain the download URLs if they are known. You can also download directly a Lora in WanGP by using the *Download Lora* button a the bottom. The Lora will be immediatly available and added to WanGP lora URL cache. This will work with *Hugging Face* as a repository. Support for CivitAi will come as soon as someone will nice enough to post a GitHub PR ... - -- **.lset file** supports embedded Loras URLs. It has never been easier to share a Lora with a friend. As a reminder a .lset file can be created directly from *WanGP Web Interface* and it contains a list of Loras and their multipliers, a Prompt and Instructions how to use these loras (like the Lora's *Trigger*). So with embedded Loras URL, you can send an .lset file by email or share it on discord: it is just a 1 KB tiny text, but with it other people will be able to use Gigabytes Loras as these will be automatically downloaded. - -I have created the new Discord Channel **share-your-settings** where you can post your *Settings* or *Lset files*. I will be pleased to add new Loras Accelerators in the list of WanGP *Accelerators Profiles if you post some good ones there. - -*With the 8.993 update*, I have added support for **Scaled FP8 format**. As a sample case, I have created finetunes for the **Wan 2.2 PalinGenesis** Finetune which is quite popular recently. You will find it in 3 flavors : *t2v*, *i2v* and *Lightning Accelerated for t2v*. - -The *Scaled FP8 format* is widely used as it the format used by ... *ComfyUI*. So I except a flood of Finetunes in the *share-your-finetune* channel. If not it means this feature was useless and I will remove it 😈😈😈 - -Not enough Space left on your SSD to download more models ? Would like to reuse Scaled FP8 files in your ComfyUI Folder without duplicating them ? Here comes *WanGP 8.994* **Multiple Checkpoints Folders** : you just need to move the files into different folders / hard drives or reuse existing folders and let know WanGP about it in the *Config Tab* and WanGP will be able to put all the parts together. - -Last but not least the Lora's documentation has been updated. - -*update 8.991*: full power of *Vace Lynx* unleashed with new combinations such as Landscape + Face / Clothes + Face / Injectd Frame (Start/End frames/...) + Face -*update 8.992*: optimized gen with Lora, should be 10% faster if many loras -*update 8.993*: Support for *Scaled FP8* format and samples *Paligenesis* finetunes, merged Loras Accelerators and Non Accelerators -*update 8.994*: Added custom checkpoints folders - -### September 30 2025: WanGP v8.9 - Combinatorics - -This new version of WanGP introduces **Wan 2.1 Lynx** the best Control Net so far to transfer *Facial Identity*. You will be amazed to recognize your friends even with a completely different hair style. Congrats to the *Byte Dance team* for this achievement. Lynx works quite with well *Fusionix t2v* 10 steps. - -*WanGP 8.9* also illustrate how existing WanGP features can be easily combined with new models. For instance with *Lynx* you will get out of the box *Video to Video* and *Image/Text to Image*. - -Another fun combination is *Vace* + *Lynx*, which works much better than *Vace StandIn*. I have added sliders to change the weight of Vace & Lynx to allow you to tune the effects. - - -### September 28 2025: WanGP v8.76 - ~~Here Are Two Three New Contenders in the Vace Arena !~~ The Never Ending Release - -So in ~~today's~~ this release you will find two Wannabe Vace that covers each only a subset of Vace features but offers some interesting advantages: -- **Wan 2.2 Animate**: this model is specialized in *Body Motion* and *Facial Motion transfers*. It does that very well. You can use this model to either *Replace* a person in an in Video or *Animate* the person of your choice using an existing *Pose Video* (remember *Animate Anyone* ?). By default it will keep the original soundtrack. *Wan 2.2 Animate* seems to be under the hood a derived i2v model and should support the corresponding Loras Accelerators (for instance *FusioniX t2v*). Also as a WanGP exclusivity, you will find support for *Outpainting*. - -In order to use Wan 2.2 Animate you will need first to stop by the *Mat Anyone* embedded tool, to extract the *Video Mask* of the person from which you want to extract the motion. - -With version WanGP 8.74, there is an extra option that allows you to apply *Relighting* when Replacing a person. Also, you can now Animate a person without providing a Video Mask to target the source of the motion (with the risk it will be less precise) - -For those of you who have a mask halo effect when Animating a character I recommend trying *SDPA attention* and to use the *FusioniX i2v* lora. If this issue persists (this will depend on the control video) you have now a choice of the two *Animate Mask Options* in *WanGP 8.76*. The old masking option which was a WanGP exclusive has been renamed *See Through Mask* because the background behind the animated character was preserved but this creates sometime visual artifacts. The new option which has the shorter name is what you may find elsewhere online. As it uses internally a much larger mask, there is no halo. However the immediate background behind the character is not preserved and may end completely different. - -- **Lucy Edit**: this one claims to be a *Nano Banana* for Videos. Give it a video and asks it to change it (it is specialized in clothes changing) and voila ! The nice thing about it is that is it based on the *Wan 2.2 5B* model and therefore is very fast especially if you the *FastWan* finetune that is also part of the package. - -Also because I wanted to spoil you: -- **Qwen Edit Plus**: also known as the *Qwen Edit 25th September Update* which is specialized in combining multiple Objects / People. There is also a new support for *Pose transfer* & *Recolorisation*. All of this made easy to use in WanGP. You will find right now only the quantized version since HF crashes when uploading the unquantized version. - -- **T2V Video 2 Video Masking**: ever wanted to apply a Lora, a process (for instance Upsampling) or a Text Prompt on only a (moving) part of a Source Video. Look no further, I have added *Masked Video 2 Video* (which works also in image2image) in the *Text 2 Video* models. As usual you just need to use *Matanyone* to creatre the mask. - - -*Update 8.71*: fixed Fast Lucy Edit that didnt contain the lora -*Update 8.72*: shadow drop of Qwen Edit Plus -*Update 8.73*: Qwen Preview & InfiniteTalk Start image -*Update 8.74*: Animate Relighting / Nomask mode , t2v Masked Video to Video -*Update 8.75*: REDACTED -*Update 8.76*: Alternate Animate masking that fixes the mask halo effect that some users have - -### September 15 2025: WanGP v8.6 - Attack of the Clones - -- The long awaited **Vace for Wan 2.2** is at last here or maybe not: it has been released by the *Fun Team* of *Alibaba* and it is not official. You can play with the vanilla version (**Vace Fun**) or with the one accelerated with Loras (**Vace Fan Cocktail**) - -- **First Frame / Last Frame for Vace** : Vace models are so powerful that they could do *First frame / Last frame* since day one using the *Injected Frames* feature. However this required to compute by hand the locations of each end frame since this feature expects frames positions. I made it easier to compute these locations by using the "L" alias : - -For a video Gen from scratch *"1 L L L"* means the 4 Injected Frames will be injected like this: frame no 1 at the first position, the next frame at the end of the first window, then the following frame at the end of the next window, and so on .... -If you *Continue a Video* , you just need *"L L L"* since the first frame is the last frame of the *Source Video*. In any case remember that numeral frames positions (like "1") are aligned by default to the beginning of the source window, so low values such as 1 will be considered in the past unless you change this behaviour in *Sliding Window Tab/ Control Video, Injected Frames aligment*. - -- **Qwen Edit Inpainting** exists now in two versions: the original version of the previous release and a Lora based version. Each version has its pros and cons. For instance the Lora version supports also **Outpainting** ! However it tends to change slightly the original image even outside the outpainted area. - -- **Better Lipsync with all the Audio to Video models**: you probably noticed that *Multitalk*, *InfiniteTalk* or *Hunyuan Avatar* had so so lipsync when the audio provided contained some background music. The problem should be solved now thanks to an automated background music removal all done by IA. Don't worry you will still hear the music as it is added back in the generated Video. - -### September 11 2025: WanGP v8.5/8.55 - Wanna be a Cropper or a Painter ? - -I have done some intensive internal refactoring of the generation pipeline to ease support of existing models or add new models. Nothing really visible but this makes WanGP is little more future proof. - -Otherwise in the news: -- **Cropped Input Image Prompts**: as quite often most *Image Prompts* provided (*Start Image, Input Video, Reference Image, Control Video, ...*) rarely matched your requested *Output Resolution*. In that case I used the resolution you gave either as a *Pixels Budget* or as an *Outer Canvas* for the Generated Video. However in some occasion you really want the requested Output Resolution and nothing else. Besides some models deliver much better Generations if you stick to one of their supported resolutions. In order to address this need I have added a new Output Resolution choice in the *Configuration Tab*: **Dimensions Correspond to the Ouput Weight & Height as the Prompt Images will be Cropped to fit Exactly these dimensins**. In short if needed the *Input Prompt Images* will be cropped (centered cropped for the moment). You will see this can make quite a difference for some models - -- *Qwen Edit* has now a new sub Tab called **Inpainting**, that lets you target with a brush which part of the *Image Prompt* you want to modify. This is quite convenient if you find that Qwen Edit modifies usually too many things. Of course, as there are more constraints for Qwen Edit don't be surprised if sometime it will return the original image unchanged. A piece of advise: describe in your *Text Prompt* where (for instance *left to the man*, *top*, ...) the parts that you want to modify are located. - -The mask inpainting is fully compatible with *Matanyone Mask generator*: generate first an *Image Mask* with Matanyone, transfer it to the current Image Generator and modify the mask with the *Paint Brush*. Talking about matanyone I have fixed a bug that caused a mask degradation with long videos (now WanGP Matanyone is as good as the original app and still requires 3 times less VRAM) - -- This **Inpainting Mask Editor** has been added also to *Vace Image Mode*. Vace is probably still one of best Image Editor today. Here is a very simple & efficient workflow that do marvels with Vace: -Select *Vace Cocktail > Control Image Process = Perform Inpainting & Area Processed = Masked Area > Upload a Control Image, then draw your mask directly on top of the image & enter a text Prompt that describes the expected change > Generate > Below the Video Gallery click 'To Control Image' > Keep on doing more changes*. - -Doing more sophisticated thing Vace Image Editor works very well too: try Image Outpainting, Pose transfer, ... - -For the best quality I recommend to set in *Quality Tab* the option: "*Generate a 9 Frames Long video...*" - -**update 8.55**: Flux Festival -- **Inpainting Mode** also added for *Flux Kontext* -- **Flux SRPO** : new finetune with x3 better quality vs Flux Dev according to its authors. I have also created a *Flux SRPO USO* finetune which is certainly the best open source *Style Transfer* tool available -- **Flux UMO**: model specialized in combining multiple reference objects / people together. Works quite well at 768x768 - -Good luck with finding your way through all the Flux models names ! - -### September 5 2025: WanGP v8.4 - Take me to Outer Space -You have probably seen these short AI generated movies created using *Nano Banana* and the *First Frame - Last Frame* feature of *Kling 2.0*. The idea is to generate an image, modify a part of it with Nano Banana and give the these two images to Kling that will generate the Video between these two images, use now the previous Last Frame as the new First Frame, rinse and repeat and you get a full movie. - -I have made it easier to do just that with *Qwen Edit* and *Wan*: -- **End Frames can now be combined with Continue a Video** (and not just a Start Frame) -- **Multiple End Frames can be inputed**, each End Frame will be used for a different Sliding Window - -You can plan in advance all your shots (one shot = one Sliding Window) : I recommend using Wan 2.2 Image to Image with multiple End Frames (one for each shot / Sliding Window), and a different Text Prompt for each shot / Sliding Winow (remember to enable *Sliding Windows/Text Prompts Will be used for a new Sliding Window of the same Video Generation*) - -The results can quite be impressive. However, Wan 2.1 & 2.2 Image 2 Image are restricted to a single overlap frame when using Slide Windows, which means only one frame is reeused for the motion. This may be unsufficient if you are trying to connect two shots with fast movement. - -This is where *InfinitTalk* comes into play. Beside being one best models to generate animated audio driven avatars, InfiniteTalk uses internally more one than motion frames. It is quite good to maintain the motions between two shots. I have tweaked InfinitTalk so that **its motion engine can be used even if no audio is provided**. -So here is how to use InfiniteTalk: enable *Sliding Windows/Text Prompts Will be used for a new Sliding Window of the same Video Generation*), and if you continue an existing Video *Misc/Override Frames per Second" should be set to "Source Video*. Each Reference Frame inputed will play the same role as the End Frame except it wont be exactly an End Frame (it will correspond more to a middle frame, the actual End Frame will differ but will be close) - - -You will find below a 33s movie I have created using these two methods. Quality could be much better as I havent tuned at all the settings (I couldn't bother, I used 10 steps generation without Loras Accelerators for most of the gens). - -### September 2 2025: WanGP v8.31 - At last the pain stops - -- This single new feature should give you the strength to face all the potential bugs of this new release: -**Images Management (multiple additions or deletions, reordering) for Start Images / End Images / Images References.** - -- Unofficial **Video to Video (Non Sparse this time) for InfinitTalk**. Use the Strength Noise slider to decide how much motion of the original window you want to keep. I have also *greatly reduced the VRAM requirements for Multitalk / Infinitalk* (especially the multispeakers version & when generating at 1080p). - -- **Experimental Sage 3 Attention support**: you will need to deserve this one, first you need a Blackwell GPU (RTX50xx) and request an access to Sage 3 Github repo, then you will have to compile Sage 3, install it and cross your fingers ... - - -*update 8.31: one shouldnt talk about bugs if one doesn't want to attract bugs* - - -See full changelog: **[Changelog](docs/CHANGELOG.md)** - -## 📋 Table of Contents - -- [🚀 Quick Start](#-quick-start) -- [📦 Installation](#-installation) -- [🎯 Usage](#-usage) -- [📚 Documentation](#-documentation) -- [🔗 Related Projects](#-related-projects) - -## 🚀 Quick Start - -**One-click installation:** -- Get started instantly with [Pinokio App](https://pinokio.computer/) -- Use Redtash1 [One Click Install with Sage](https://github.com/Redtash1/Wan2GP-Windows-One-Click-Install-With-Sage) - -**Manual installation:** -```bash -git clone https://github.com/deepbeepmeep/Wan2GP.git +git clone https://github.com/Tophness/Wan2GP.git cd Wan2GP +git checkout video_editor conda create -n wan2gp python=3.10.9 conda activate wan2gp pip install torch==2.7.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/test/cu128 pip install -r requirements.txt ``` -**Run the application:** -```bash -python wgp.py -``` - -**Update the application:** -If using Pinokio use Pinokio to update otherwise: -Get in the directory where WanGP is installed and: -```bash -git pull -pip install -r requirements.txt -``` - -## 🐳 Docker: - -**For Debian-based systems (Ubuntu, Debian, etc.):** +## Usage +**Run the video editor:** ```bash -./run-docker-cuda-deb.sh +python videoeditor.py ``` -This automated script will: - -- Detect your GPU model and VRAM automatically -- Select optimal CUDA architecture for your GPU -- Install NVIDIA Docker runtime if needed -- Build a Docker image with all dependencies -- Run WanGP with optimal settings for your hardware - -**Docker environment includes:** - -- NVIDIA CUDA 12.4.1 with cuDNN support -- PyTorch 2.6.0 with CUDA 12.4 support -- SageAttention compiled for your specific GPU architecture -- Optimized environment variables for performance (TF32, threading, etc.) -- Automatic cache directory mounting for faster subsequent runs -- Current directory mounted in container - all downloaded models, loras, generated videos and files are saved locally - -**Supported GPUs:** RTX 40XX, RTX 30XX, RTX 20XX, GTX 16XX, GTX 10XX, Tesla V100, A100, H100, and more. - -## 📦 Installation - -For detailed installation instructions for different GPU generations: -- **[Installation Guide](docs/INSTALLATION.md)** - Complete setup instructions for RTX 10XX to RTX 50XX - -## 🎯 Usage - -### Basic Usage -- **[Getting Started Guide](docs/GETTING_STARTED.md)** - First steps and basic usage -- **[Models Overview](docs/MODELS.md)** - Available models and their capabilities - -### Advanced Features -- **[Loras Guide](docs/LORAS.md)** - Using and managing Loras for customization -- **[Finetunes](docs/FINETUNES.md)** - Add manually new models to WanGP -- **[VACE ControlNet](docs/VACE.md)** - Advanced video control and manipulation -- **[Command Line Reference](docs/CLI.md)** - All available command line options - -## 📚 Documentation - -- **[Changelog](docs/CHANGELOG.md)** - Latest updates and version history -- **[Troubleshooting](docs/TROUBLESHOOTING.md)** - Common issues and solutions - -## 📚 Video Guides -- Nice Video that explain how to use Vace:\ -https://www.youtube.com/watch?v=FMo9oN2EAvE -- Another Vace guide:\ -https://www.youtube.com/watch?v=T5jNiEhf9xk - -## 🔗 Related Projects - -### Other Models for the GPU Poor -- **[HuanyuanVideoGP](https://github.com/deepbeepmeep/HunyuanVideoGP)** - One of the best open source Text to Video generators -- **[Hunyuan3D-2GP](https://github.com/deepbeepmeep/Hunyuan3D-2GP)** - Image to 3D and text to 3D tool -- **[FluxFillGP](https://github.com/deepbeepmeep/FluxFillGP)** - Inpainting/outpainting tools based on Flux -- **[Cosmos1GP](https://github.com/deepbeepmeep/Cosmos1GP)** - Text to world generator and image/video to world -- **[OminiControlGP](https://github.com/deepbeepmeep/OminiControlGP)** - Flux-derived application for object transfer -- **[YuE GP](https://github.com/deepbeepmeep/YuEGP)** - Song generator with instruments and singer's voice - ---- +## Screenshots +image +image +image +image -

-Made with ❤️ by DeepBeepMeep -

+## Credits +The AI Video Generator plugin is built from a desktop port of WAN2GP by DeepBeepMeep. +See WAN2GP for more details. +https://github.com/deepbeepmeep/Wan2GP \ No newline at end of file diff --git a/videoeditor/plugins.py b/plugins.py similarity index 96% rename from videoeditor/plugins.py rename to plugins.py index 6e50f0963..f40f6adb1 100644 --- a/videoeditor/plugins.py +++ b/plugins.py @@ -16,8 +16,9 @@ class VideoEditorPlugin: Plugins should inherit from this class and be located in 'plugins/plugin_name/main.py'. The main class in main.py must be named 'Plugin'. """ - def __init__(self, app_instance): + def __init__(self, app_instance, wgp_module=None): self.app = app_instance + self.wgp = wgp_module self.name = "Unnamed Plugin" self.description = "No description provided." @@ -35,8 +36,9 @@ def disable(self): class PluginManager: """Manages the discovery, loading, and lifecycle of plugins.""" - def __init__(self, main_app): + def __init__(self, main_app, wgp_module=None): self.app = main_app + self.wgp = wgp_module self.plugins_dir = "plugins" self.plugins = {} # { 'plugin_name': {'instance': plugin_instance, 'enabled': False, 'module_path': path} } if not os.path.exists(self.plugins_dir): @@ -58,7 +60,7 @@ def discover_and_load_plugins(self): if hasattr(module, 'Plugin'): plugin_class = getattr(module, 'Plugin') - instance = plugin_class(self.app) + instance = plugin_class(self.app, self.wgp) instance.initialize() self.plugins[instance.name] = { 'instance': instance, @@ -70,6 +72,8 @@ def discover_and_load_plugins(self): print(f"Warning: {main_py_path} does not have a 'Plugin' class.") except Exception as e: print(f"Error loading plugin {plugin_name}: {e}") + import traceback + traceback.print_exc() def load_enabled_plugins_from_settings(self, enabled_plugins_list): """Enables plugins based on the loaded settings.""" @@ -263,4 +267,5 @@ def on_install_finished(self, message, success): self.status_label.setText(message) self.install_btn.setEnabled(True) if success: - self.url_input.clear() \ No newline at end of file + self.url_input.clear() + self.populate_list() \ No newline at end of file diff --git a/main.py b/plugins/wan2gp/main.py similarity index 69% rename from main.py rename to plugins/wan2gp/main.py index 773bcc9fa..ca201dcda 100644 --- a/main.py +++ b/plugins/wan2gp/main.py @@ -3,8 +3,11 @@ import threading import time import json -import re +import tempfile +import shutil +import uuid from unittest.mock import MagicMock +from pathlib import Path # --- Start of Gradio Hijacking --- # This block creates a mock Gradio module. When wgp.py is imported, @@ -16,12 +19,10 @@ class MockGradioComponent(MagicMock): """A smarter mock that captures constructor arguments.""" def __init__(self, *args, **kwargs): super().__init__(name=f"gr.{kwargs.get('elem_id', 'component')}") - # Store the kwargs so we can inspect them later self.kwargs = kwargs self.value = kwargs.get('value') self.choices = kwargs.get('choices') - # Mock chaining methods like .click(), .then(), etc. for method in ['then', 'change', 'click', 'input', 'select', 'upload', 'mount', 'launch', 'on', 'release']: setattr(self, method, lambda *a, **kw: self) @@ -34,153 +35,134 @@ def __exit__(self, exc_type, exc_val, exc_tb): class MockGradioError(Exception): pass -class MockGradioModule: # No longer inherits from MagicMock +class MockGradioModule: def __getattr__(self, name): if name == 'Error': return lambda *args, **kwargs: MockGradioError(*args) - # Nullify functions that show pop-ups if name in ['Info', 'Warning']: return lambda *args, **kwargs: print(f"Intercepted gr.{name}:", *args) return lambda *args, **kwargs: MockGradioComponent(*args, **kwargs) sys.modules['gradio'] = MockGradioModule() -sys.modules['gradio.gallery'] = MockGradioModule() # Also mock any submodules used +sys.modules['gradio.gallery'] = MockGradioModule() sys.modules['shared.gradio.gallery'] = MockGradioModule() # --- End of Gradio Hijacking --- -# Global placeholder for the wgp module. Will be None if import fails. -wgp = None - -# Load configuration and attempt to import wgp -MAIN_CONFIG_FILE = 'main_config.json' -main_config = {} - -def load_main_config(): - global main_config - try: - with open(MAIN_CONFIG_FILE, 'r') as f: - main_config = json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - current_folder = os.path.dirname(os.path.abspath(sys.argv[0])) - main_config = {'wgp_path': current_folder} - -def save_main_config(): - global main_config - try: - with open(MAIN_CONFIG_FILE, 'w') as f: - json.dump(main_config, f, indent=4) - except Exception as e: - print(f"Error saving main_config.json: {e}") - -def setup_and_import_wgp(): - """Adds configured path to sys.path and tries to import wgp.""" - global wgp - wgp_path = main_config.get('wgp_path') - if wgp_path and os.path.isdir(wgp_path) and os.path.isfile(os.path.join(wgp_path, 'wgp.py')): - if wgp_path not in sys.path: - sys.path.insert(0, wgp_path) - try: - import wgp as wgp_module - wgp = wgp_module - return True - except ImportError as e: - print(f"Error: Failed to import wgp.py from the configured path '{wgp_path}'.\nDetails: {e}") - wgp = None - return False - else: - print("Info: WAN2GP folder path not set or invalid. Please configure it in File > Settings.") - wgp = None - return False - -# Load config and attempt import at script start -load_main_config() -wgp_loaded = setup_and_import_wgp() - -# Now import PyQt6 components +import ffmpeg + from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, + QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QPushButton, QLabel, QLineEdit, QTextEdit, QSlider, QCheckBox, QComboBox, QFileDialog, QGroupBox, QFormLayout, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QScrollArea, QListWidget, QListWidgetItem, - QMessageBox, QRadioButton, QDialog + QMessageBox, QRadioButton, QSizePolicy ) -from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QTimer, pyqtSlot -from PyQt6.QtGui import QPixmap, QDropEvent, QAction +from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QUrl, QSize, QRectF +from PyQt6.QtGui import QPixmap, QImage, QDropEvent +from PyQt6.QtMultimedia import QMediaPlayer +from PyQt6.QtMultimediaWidgets import QVideoWidget from PIL.ImageQt import ImageQt +# Import the base plugin class from the main application's path +sys.path.append(str(Path(__file__).parent.parent.parent)) +from plugins import VideoEditorPlugin -class SettingsDialog(QDialog): - """Dialog to configure application settings like the wgp.py path.""" - def __init__(self, config, parent=None): +class VideoResultItemWidget(QWidget): + """A widget to display a generated video with a hover-to-play preview and insert button.""" + def __init__(self, video_path, plugin, parent=None): super().__init__(parent) - self.config = config - self.setWindowTitle("Settings") - self.setMinimumWidth(500) + self.video_path = video_path + self.plugin = plugin + self.app = plugin.app + self.duration = 0.0 + self.has_audio = False + self.setMinimumSize(200, 180) + self.setMaximumHeight(190) + layout = QVBoxLayout(self) - form_layout = QFormLayout() - - path_layout = QHBoxLayout() - self.wgp_path_edit = QLineEdit(self.config.get('wgp_path', '')) - self.wgp_path_edit.setPlaceholderText("Path to the folder containing wgp.py") - browse_btn = QPushButton("Browse...") - browse_btn.clicked.connect(self.browse_for_wgp_folder) - path_layout.addWidget(self.wgp_path_edit) - path_layout.addWidget(browse_btn) - - form_layout.addRow("WAN2GP Folder Path:", path_layout) - layout.addLayout(form_layout) - - button_layout = QHBoxLayout() - save_btn = QPushButton("Save") - save_restart_btn = QPushButton("Save and Restart") - cancel_btn = QPushButton("Cancel") - button_layout.addStretch() - button_layout.addWidget(save_btn) - button_layout.addWidget(save_restart_btn) - button_layout.addWidget(cancel_btn) - layout.addLayout(button_layout) - - save_btn.clicked.connect(self.save_and_close) - save_restart_btn.clicked.connect(self.save_and_restart) - cancel_btn.clicked.connect(self.reject) - - def browse_for_wgp_folder(self): - directory = QFileDialog.getExistingDirectory(self, "Select WAN2GP Folder") - if directory: - self.wgp_path_edit.setText(directory) - - def validate_path(self, path): - if not path or not os.path.isdir(path) or not os.path.isfile(os.path.join(path, 'wgp.py')): - QMessageBox.warning(self, "Invalid Path", "The selected folder does not contain 'wgp.py'. Please select the correct WAN2GP folder.") - return False - return True - - def _save_config(self): - path = self.wgp_path_edit.text() - if not self.validate_path(path): - return False - self.config['wgp_path'] = path - save_main_config() - return True - - def save_and_close(self): - if self._save_config(): - QMessageBox.information(self, "Settings Saved", "Settings have been saved. Please restart the application for changes to take effect.") - self.accept() - - def save_and_restart(self): - if self._save_config(): - self.parent().close() # Close the main window before restarting - os.execv(sys.executable, [sys.executable] + sys.argv) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(5) + + self.media_player = QMediaPlayer() + self.video_widget = QVideoWidget() + self.video_widget.setFixedSize(160, 90) + self.media_player.setVideoOutput(self.video_widget) + self.media_player.setSource(QUrl.fromLocalFile(self.video_path)) + self.media_player.setLoops(QMediaPlayer.Loops.Infinite) + + self.info_label = QLabel(os.path.basename(video_path)) + self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.info_label.setWordWrap(True) + + self.insert_button = QPushButton("Insert into Timeline") + self.insert_button.clicked.connect(self.on_insert) + + h_layout = QHBoxLayout() + h_layout.addStretch() + h_layout.addWidget(self.video_widget) + h_layout.addStretch() + + layout.addLayout(h_layout) + layout.addWidget(self.info_label) + layout.addWidget(self.insert_button) + self.probe_video() + + def probe_video(self): + try: + probe = ffmpeg.probe(self.video_path) + self.duration = float(probe['format']['duration']) + self.has_audio = any(s['codec_type'] == 'audio' for s in probe.get('streams', [])) + self.info_label.setText(f"{os.path.basename(self.video_path)}\n({self.duration:.2f}s)") + except Exception as e: + self.info_label.setText(f"Error probing:\n{os.path.basename(self.video_path)}") + print(f"Error probing video {self.video_path}: {e}") + + def enterEvent(self, event): + super().enterEvent(event) + self.media_player.play() + if not self.plugin.active_region or self.duration == 0: return + start_sec, _ = self.plugin.active_region + timeline = self.app.timeline_widget + video_rect, audio_rect = None, None + x = timeline.sec_to_x(start_sec) + w = int(self.duration * timeline.pixels_per_second) + if self.plugin.insert_on_new_track: + video_y = timeline.TIMESCALE_HEIGHT + video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) + if self.has_audio: + audio_y = timeline.audio_tracks_y_start + self.app.timeline.num_audio_tracks * timeline.TRACK_HEIGHT + audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) + else: + v_track_idx = 1 + visual_v_idx = self.app.timeline.num_video_tracks - v_track_idx + video_y = timeline.video_tracks_y_start + visual_v_idx * timeline.TRACK_HEIGHT + video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) + if self.has_audio: + a_track_idx = 1 + visual_a_idx = a_track_idx - 1 + audio_y = timeline.audio_tracks_y_start + visual_a_idx * timeline.TRACK_HEIGHT + audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) + timeline.set_hover_preview_rects(video_rect, audio_rect) + + def leaveEvent(self, event): + super().leaveEvent(event) + self.media_player.pause() + self.media_player.setPosition(0) + self.app.timeline_widget.set_hover_preview_rects(None, None) + + def on_insert(self): + self.media_player.stop() + self.media_player.setSource(QUrl()) + self.media_player.setVideoOutput(None) + self.app.timeline_widget.set_hover_preview_rects(None, None) + self.plugin.insert_generated_clip(self.video_path) class QueueTableWidget(QTableWidget): - """A QTableWidget with drag-and-drop reordering for rows.""" rowsMoved = pyqtSignal(int, int) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setDragEnabled(True) @@ -195,14 +177,8 @@ def dropEvent(self, event: QDropEvent): source_row = self.currentRow() target_item = self.itemAt(event.position().toPoint()) dest_row = target_item.row() if target_item else self.rowCount() - - # Adjust destination row if moving down - if source_row < dest_row: - dest_row -=1 - - if source_row != dest_row: - self.rowsMoved.emit(source_row, dest_row) - + if source_row < dest_row: dest_row -=1 + if source_row != dest_row: self.rowsMoved.emit(source_row, dest_row) event.acceptProposedAction() else: super().dropEvent(event) @@ -215,210 +191,78 @@ class Worker(QObject): output = pyqtSignal() finished = pyqtSignal() error = pyqtSignal(str) - - def __init__(self, state): + def __init__(self, plugin, state): super().__init__() + self.plugin = plugin + self.wgp = plugin.wgp self.state = state self._is_running = True self._last_progress_phase = None self._last_preview = None - def send_cmd(self, cmd, data=None): - if not self._is_running: - return - def run(self): def generation_target(): try: - for _ in wgp.process_tasks(self.state): - if self._is_running: - self.output.emit() - else: - break + for _ in self.wgp.process_tasks(self.state): + if self._is_running: self.output.emit() + else: break except Exception as e: import traceback print("Error in generation thread:") traceback.print_exc() - if "gradio.Error" in str(type(e)): - self.error.emit(str(e)) - else: - self.error.emit(f"An unexpected error occurred: {e}") + if "gradio.Error" in str(type(e)): self.error.emit(str(e)) + else: self.error.emit(f"An unexpected error occurred: {e}") finally: self._is_running = False - gen_thread = threading.Thread(target=generation_target, daemon=True) gen_thread.start() - while self._is_running: gen = self.state.get('gen', {}) - current_phase = gen.get("progress_phase") if current_phase and current_phase != self._last_progress_phase: self._last_progress_phase = current_phase - phase_name, step = current_phase total_steps = gen.get("num_inference_steps", 1) high_level_status = gen.get("progress_status", "") - - status_msg = wgp.merge_status_context(high_level_status, phase_name) - + status_msg = self.wgp.merge_status_context(high_level_status, phase_name) progress_args = [(step, total_steps), status_msg] self.progress.emit(progress_args) - preview_img = gen.get('preview') if preview_img is not None and preview_img is not self._last_preview: self._last_preview = preview_img self.preview.emit(preview_img) gen['preview'] = None - time.sleep(0.1) - gen_thread.join() self.finished.emit() -class ApiBridge(QObject): - """ - An object that lives in the main thread to receive signals from the API thread - and forward them to the MainWindow's slots. - """ - # --- CHANGE: Signal now includes model_type and duration_sec --- - generateSignal = pyqtSignal(object, object, object, object, bool) -class MainWindow(QMainWindow): - def __init__(self): +class WgpDesktopPluginWidget(QWidget): + def __init__(self, plugin): super().__init__() - - self.api_bridge = ApiBridge() - + self.plugin = plugin + self.wgp = plugin.wgp self.widgets = {} self.state = {} self.worker = None self.thread = None self.lora_map = {} self.full_resolution_choices = [] - self.latest_output_path = None + self.main_config = {} + self.processed_files = set() - self.setup_menu() - - if not wgp: - self.setWindowTitle("WanGP - Setup Required") - self.setGeometry(100, 100, 600, 200) - self.setup_placeholder_ui() - QTimer.singleShot(100, lambda: self.show_settings_dialog(first_time=True)) - else: - self.setWindowTitle(f"WanGP v{wgp.WanGP_version} - Qt Interface") - self.setGeometry(100, 100, 1400, 950) - self.setup_full_ui() - self.apply_initial_config() - self.connect_signals() - self.init_wgp_state() - - self.api_bridge.generateSignal.connect(self._api_generate) - - @pyqtSlot(str) - def _api_set_model(self, model_type): - """This slot is executed in the main GUI thread.""" - if not model_type or not wgp: return - - if model_type not in wgp.models_def: - print(f"API Error: Model type '{model_type}' is not a valid model.") - return - - if self.state.get('model_type') == model_type: - print(f"API: Model is already set to {model_type}.") - return - - self.update_model_dropdowns(model_type) - - self._on_model_changed() - - if self.state.get('model_type') == model_type: - print(f"API: Successfully set model to {model_type}.") - else: - print(f"API Error: Failed to set model to '{model_type}'. The model might be hidden by your current configuration.") - - @pyqtSlot(object, object, object, object, bool) - def _api_generate(self, start_frame, end_frame, duration_sec, model_type, start_generation): - """This slot is executed in the main GUI thread.""" - if model_type: - self._api_set_model(model_type) - if start_frame: - self.widgets['mode_s'].setChecked(True) - self.widgets['image_start'].setText(start_frame) - else: - self.widgets['mode_t'].setChecked(True) - self.widgets['image_start'].clear() - - if end_frame: - self.widgets['image_end_checkbox'].setChecked(True) - self.widgets['image_end'].setText(end_frame) - else: - self.widgets['image_end_checkbox'].setChecked(False) - self.widgets['image_end'].clear() - - if duration_sec is not None: - try: - duration = float(duration_sec) - base_fps = 16 - fps_text = self.widgets['force_fps'].itemText(0) - match = re.search(r'\((\d+)\s*fps\)', fps_text) - if match: - base_fps = int(match.group(1)) - - video_length_frames = int(duration * base_fps) - - self.widgets['video_length'].setValue(video_length_frames) - print(f"API: Calculated video length: {video_length_frames} frames for {duration:.2f}s @ {base_fps} effective FPS.") - - except (ValueError, TypeError) as e: - print(f"API Error: Invalid duration_sec '{duration_sec}': {e}") - - if start_generation: - self.generate_btn.click() - print("API: Generation started.") - else: - print("API: Parameters set without starting generation.") - - - def setup_menu(self): - menu_bar = self.menuBar() - file_menu = menu_bar.addMenu("&File") - - settings_action = QAction("&Settings", self) - settings_action.triggered.connect(self.show_settings_dialog) - file_menu.addAction(settings_action) - - def show_settings_dialog(self, first_time=False): - dialog = SettingsDialog(main_config, self) - if first_time: - dialog.setWindowTitle("Initial Setup: Configure WAN2GP Path") - dialog.exec() - - def setup_placeholder_ui(self): - main_widget = QWidget() - self.setCentralWidget(main_widget) - layout = QVBoxLayout(main_widget) - - placeholder_label = QLabel( - "

Welcome to WanGP

" - "

The path to your WAN2GP installation (the folder containing wgp.py) is not set.

" - "

Please go to File > Settings to configure the path.

" - ) - placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - placeholder_label.setWordWrap(True) - layout.addWidget(placeholder_label) - - def setup_full_ui(self): - main_widget = QWidget() - self.setCentralWidget(main_widget) - main_layout = QVBoxLayout(main_widget) + self.load_main_config() + self.setup_ui() + self.apply_initial_config() + self.connect_signals() + self.init_wgp_state() + def setup_ui(self): + main_layout = QVBoxLayout(self) self.header_info = QLabel("Header Info") main_layout.addWidget(self.header_info) - self.tabs = QTabWidget() main_layout.addWidget(self.tabs) - self.setup_generator_tab() self.setup_config_tab() @@ -431,18 +275,12 @@ def _create_slider_with_label(self, name, min_val, max_val, initial_val, scale=1 container = QWidget() hbox = QHBoxLayout(container) hbox.setContentsMargins(0, 0, 0, 0) - slider = self.create_widget(QSlider, name, Qt.Orientation.Horizontal) slider.setRange(min_val, max_val) slider.setValue(int(initial_val * scale)) - value_label = self.create_widget(QLabel, f"{name}_label", f"{initial_val:.{precision}f}") value_label.setMinimumWidth(50) - - slider.valueChanged.connect( - lambda v, lbl=value_label, s=scale, p=precision: lbl.setText(f"{v/s:.{p}f}") - ) - + slider.valueChanged.connect(lambda v, lbl=value_label, s=scale, p=precision: lbl.setText(f"{v/s:.{p}f}")) hbox.addWidget(slider) hbox.addWidget(value_label) return container @@ -451,30 +289,20 @@ def _create_file_input(self, name, label_text): container = self.create_widget(QWidget, f"{name}_container") hbox = QHBoxLayout(container) hbox.setContentsMargins(0, 0, 0, 0) - line_edit = self.create_widget(QLineEdit, name) - line_edit.setReadOnly(False) # Allow user to paste paths line_edit.setPlaceholderText("No file selected or path pasted") - button = QPushButton("Browse...") - def open_dialog(): - # Allow selecting multiple files for reference images if "refs" in name: filenames, _ = QFileDialog.getOpenFileNames(self, f"Select {label_text}") - if filenames: - line_edit.setText(";".join(filenames)) + if filenames: line_edit.setText(";".join(filenames)) else: filename, _ = QFileDialog.getOpenFileName(self, f"Select {label_text}") - if filename: - line_edit.setText(filename) - + if filename: line_edit.setText(filename) button.clicked.connect(open_dialog) - clear_button = QPushButton("X") clear_button.setFixedWidth(30) clear_button.clicked.connect(lambda: line_edit.clear()) - hbox.addWidget(QLabel(f"{label_text}:")) hbox.addWidget(line_edit, 1) hbox.addWidget(button) @@ -485,25 +313,18 @@ def setup_generator_tab(self): gen_tab = QWidget() self.tabs.addTab(gen_tab, "Video Generator") gen_layout = QHBoxLayout(gen_tab) - left_panel = QWidget() left_layout = QVBoxLayout(left_panel) gen_layout.addWidget(left_panel, 1) - right_panel = QWidget() right_layout = QVBoxLayout(right_panel) gen_layout.addWidget(right_panel, 1) - - # Left Panel (Inputs) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) left_layout.addWidget(scroll_area) - options_widget = QWidget() scroll_area.setWidget(options_widget) options_layout = QVBoxLayout(options_widget) - - # Model Selection model_layout = QHBoxLayout() self.widgets['model_family'] = QComboBox() self.widgets['model_base_type_choice'] = QComboBox() @@ -513,36 +334,26 @@ def setup_generator_tab(self): model_layout.addWidget(self.widgets['model_base_type_choice'], 3) model_layout.addWidget(self.widgets['model_choice'], 3) options_layout.addLayout(model_layout) - - # Prompt options_layout.addWidget(QLabel("Prompt:")) self.create_widget(QTextEdit, 'prompt').setMinimumHeight(100) options_layout.addWidget(self.widgets['prompt']) - options_layout.addWidget(QLabel("Negative Prompt:")) self.create_widget(QTextEdit, 'negative_prompt').setMinimumHeight(60) options_layout.addWidget(self.widgets['negative_prompt']) - - # Basic controls basic_group = QGroupBox("Basic Options") basic_layout = QFormLayout(basic_group) - res_container = QWidget() res_hbox = QHBoxLayout(res_container) res_hbox.setContentsMargins(0, 0, 0, 0) res_hbox.addWidget(self.create_widget(QComboBox, 'resolution_group'), 2) res_hbox.addWidget(self.create_widget(QComboBox, 'resolution'), 3) basic_layout.addRow("Resolution:", res_container) - basic_layout.addRow("Video Length:", self._create_slider_with_label('video_length', 1, 737, 81, 1.0, 0)) basic_layout.addRow("Inference Steps:", self._create_slider_with_label('num_inference_steps', 1, 100, 30, 1.0, 0)) basic_layout.addRow("Seed:", self.create_widget(QLineEdit, 'seed', '-1')) options_layout.addWidget(basic_group) - - # Generation Mode and Input Options mode_options_group = QGroupBox("Generation Mode & Input Options") mode_options_layout = QVBoxLayout(mode_options_group) - mode_hbox = QHBoxLayout() mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_t', "Text Prompt Only")) mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_s', "Start with Image")) @@ -550,15 +361,12 @@ def setup_generator_tab(self): mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_l', "Continue Last Video")) self.widgets['mode_t'].setChecked(True) mode_options_layout.addLayout(mode_hbox) - options_hbox = QHBoxLayout() options_hbox.addWidget(self.create_widget(QCheckBox, 'image_end_checkbox', "Use End Image")) options_hbox.addWidget(self.create_widget(QCheckBox, 'control_video_checkbox', "Use Control Video")) options_hbox.addWidget(self.create_widget(QCheckBox, 'ref_image_checkbox', "Use Reference Image(s)")) mode_options_layout.addLayout(options_hbox) options_layout.addWidget(mode_options_group) - - # Dynamic Inputs inputs_group = QGroupBox("Inputs") inputs_layout = QVBoxLayout(inputs_group) inputs_layout.addWidget(self._create_file_input('image_start', "Start Image")) @@ -571,16 +379,12 @@ def setup_generator_tab(self): denoising_row.addRow("Denoising Strength:", self._create_slider_with_label('denoising_strength', 0, 100, 50, 100.0, 2)) inputs_layout.addLayout(denoising_row) options_layout.addWidget(inputs_group) - - # Advanced controls self.advanced_group = self.create_widget(QGroupBox, 'advanced_group', "Advanced Options") self.advanced_group.setCheckable(True) self.advanced_group.setChecked(False) advanced_layout = QVBoxLayout(self.advanced_group) - advanced_tabs = self.create_widget(QTabWidget, 'advanced_tabs') advanced_layout.addWidget(advanced_tabs) - self._setup_adv_tab_general(advanced_tabs) self._setup_adv_tab_loras(advanced_tabs) self._setup_adv_tab_speed(advanced_tabs) @@ -589,10 +393,8 @@ def setup_generator_tab(self): self._setup_adv_tab_quality(advanced_tabs) self._setup_adv_tab_sliding_window(advanced_tabs) self._setup_adv_tab_misc(advanced_tabs) - options_layout.addWidget(self.advanced_group) - # Right Panel (Output & Queue) btn_layout = QHBoxLayout() self.generate_btn = self.create_widget(QPushButton, 'generate_btn', "Generate") self.add_to_queue_btn = self.create_widget(QPushButton, 'add_to_queue_btn', "Add to Queue") @@ -601,32 +403,34 @@ def setup_generator_tab(self): btn_layout.addWidget(self.generate_btn) btn_layout.addWidget(self.add_to_queue_btn) right_layout.addLayout(btn_layout) - self.status_label = self.create_widget(QLabel, 'status_label', "Idle") right_layout.addWidget(self.status_label) self.progress_bar = self.create_widget(QProgressBar, 'progress_bar') right_layout.addWidget(self.progress_bar) - preview_group = self.create_widget(QGroupBox, 'preview_group', "Preview") preview_group.setCheckable(True) preview_group.setStyleSheet("QGroupBox { border: 1px solid #cccccc; }") preview_group_layout = QVBoxLayout(preview_group) - self.preview_image = self.create_widget(QLabel, 'preview_image', "") self.preview_image.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_image.setMinimumSize(200, 200) - preview_group_layout.addWidget(self.preview_image) right_layout.addWidget(preview_group) - right_layout.addWidget(QLabel("Output:")) - self.output_gallery = self.create_widget(QListWidget, 'output_gallery') - right_layout.addWidget(self.output_gallery) + results_group = QGroupBox("Generated Clips") + results_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + results_layout = QVBoxLayout(results_group) + self.results_list = self.create_widget(QListWidget, 'results_list') + self.results_list.setFlow(QListWidget.Flow.LeftToRight) + self.results_list.setWrapping(True) + self.results_list.setResizeMode(QListWidget.ResizeMode.Adjust) + self.results_list.setSpacing(10) + results_layout.addWidget(self.results_list) + right_layout.addWidget(results_group) right_layout.addWidget(QLabel("Queue:")) self.queue_table = self.create_widget(QueueTableWidget, 'queue_table') right_layout.addWidget(self.queue_table) - queue_btn_layout = QHBoxLayout() self.remove_queue_btn = self.create_widget(QPushButton, 'remove_queue_btn', "Remove Selected") self.clear_queue_btn = self.create_widget(QPushButton, 'clear_queue_btn', "Clear Queue") @@ -641,14 +445,11 @@ def _setup_adv_tab_general(self, tabs): tabs.addTab(tab, "General") layout = QFormLayout(tab) self.widgets['adv_general_layout'] = layout - guidance_group = QGroupBox("Guidance") guidance_layout = self.create_widget(QFormLayout, 'guidance_layout', guidance_group) guidance_layout.addRow("Guidance (CFG):", self._create_slider_with_label('guidance_scale', 10, 200, 5.0, 10.0, 1)) - self.widgets['guidance_phases_row_index'] = guidance_layout.rowCount() guidance_layout.addRow("Guidance Phases:", self.create_widget(QComboBox, 'guidance_phases')) - self.widgets['guidance2_row_index'] = guidance_layout.rowCount() guidance_layout.addRow("Guidance 2:", self._create_slider_with_label('guidance2_scale', 10, 200, 5.0, 10.0, 1)) self.widgets['guidance3_row_index'] = guidance_layout.rowCount() @@ -656,30 +457,24 @@ def _setup_adv_tab_general(self, tabs): self.widgets['switch_thresh_row_index'] = guidance_layout.rowCount() guidance_layout.addRow("Switch Threshold:", self._create_slider_with_label('switch_threshold', 0, 1000, 0, 1.0, 0)) layout.addRow(guidance_group) - nag_group = self.create_widget(QGroupBox, 'nag_group', "NAG (Negative Adversarial Guidance)") nag_layout = QFormLayout(nag_group) nag_layout.addRow("NAG Scale:", self._create_slider_with_label('NAG_scale', 10, 200, 1.0, 10.0, 1)) nag_layout.addRow("NAG Tau:", self._create_slider_with_label('NAG_tau', 10, 50, 3.5, 10.0, 1)) nag_layout.addRow("NAG Alpha:", self._create_slider_with_label('NAG_alpha', 0, 20, 0.5, 10.0, 1)) layout.addRow(nag_group) - self.widgets['solver_row_container'] = QWidget() solver_hbox = QHBoxLayout(self.widgets['solver_row_container']) solver_hbox.setContentsMargins(0,0,0,0) solver_hbox.addWidget(QLabel("Sampler Solver:")) solver_hbox.addWidget(self.create_widget(QComboBox, 'sample_solver')) layout.addRow(self.widgets['solver_row_container']) - self.widgets['flow_shift_row_index'] = layout.rowCount() layout.addRow("Shift Scale:", self._create_slider_with_label('flow_shift', 10, 250, 3.0, 10.0, 1)) - self.widgets['audio_guidance_row_index'] = layout.rowCount() layout.addRow("Audio Guidance:", self._create_slider_with_label('audio_guidance_scale', 10, 200, 4.0, 10.0, 1)) - self.widgets['repeat_generation_row_index'] = layout.rowCount() layout.addRow("Repeat Generations:", self._create_slider_with_label('repeat_generation', 1, 25, 1, 1.0, 0)) - combo = self.create_widget(QComboBox, 'multi_images_gen_type') combo.addItem("Generate all combinations", 0) combo.addItem("Match images and texts", 1) @@ -706,7 +501,6 @@ def _setup_adv_tab_speed(self, tabs): combo.addItem("Tea Cache", "tea") combo.addItem("Mag Cache", "mag") layout.addRow("Cache Type:", combo) - combo = self.create_widget(QComboBox, 'skip_steps_multiplier') combo.addItem("x1.5 speed up", 1.5) combo.addItem("x1.75 speed up", 1.75) @@ -725,13 +519,11 @@ def _setup_adv_tab_postproc(self, tabs): combo.addItem("Rife x2 frames/s", "rife2") combo.addItem("Rife x4 frames/s", "rife4") layout.addRow("Temporal Upsampling:", combo) - combo = self.create_widget(QComboBox, 'spatial_upsampling') combo.addItem("Disabled", "") combo.addItem("Lanczos x1.5", "lanczos1.5") combo.addItem("Lanczos x2.0", "lanczos2") layout.addRow("Spatial Upsampling:", combo) - layout.addRow("Film Grain Intensity:", self._create_slider_with_label('film_grain_intensity', 0, 100, 0, 100.0, 2)) layout.addRow("Film Grain Saturation:", self._create_slider_with_label('film_grain_saturation', 0, 100, 0.5, 100.0, 2)) @@ -751,7 +543,6 @@ def _setup_adv_tab_quality(self, tabs): tab = QWidget() tabs.addTab(tab, "Quality") layout = QVBoxLayout(tab) - slg_group = self.create_widget(QGroupBox, 'slg_group', "Skip Layer Guidance") slg_layout = QFormLayout(slg_group) slg_combo = self.create_widget(QComboBox, 'slg_switch') @@ -761,25 +552,20 @@ def _setup_adv_tab_quality(self, tabs): slg_layout.addRow("Start %:", self._create_slider_with_label('slg_start_perc', 0, 100, 10, 1.0, 0)) slg_layout.addRow("End %:", self._create_slider_with_label('slg_end_perc', 0, 100, 90, 1.0, 0)) layout.addWidget(slg_group) - quality_form = QFormLayout() self.widgets['quality_form_layout'] = quality_form - apg_combo = self.create_widget(QComboBox, 'apg_switch') apg_combo.addItem("OFF", 0) apg_combo.addItem("ON", 1) self.widgets['apg_switch_row_index'] = quality_form.rowCount() quality_form.addRow("Adaptive Projected Guidance:", apg_combo) - cfg_star_combo = self.create_widget(QComboBox, 'cfg_star_switch') cfg_star_combo.addItem("OFF", 0) cfg_star_combo.addItem("ON", 1) self.widgets['cfg_star_switch_row_index'] = quality_form.rowCount() quality_form.addRow("Classifier-Free Guidance Star:", cfg_star_combo) - self.widgets['cfg_zero_step_row_index'] = quality_form.rowCount() quality_form.addRow("CFG Zero below Layer:", self._create_slider_with_label('cfg_zero_step', -1, 39, -1, 1.0, 0)) - combo = self.create_widget(QComboBox, 'min_frames_if_references') combo.addItem("Disabled (1 frame)", 1) combo.addItem("Generate 5 frames", 5) @@ -795,7 +581,6 @@ def _setup_adv_tab_sliding_window(self, tabs): self.widgets['sliding_window_tab_index'] = tabs.count() tabs.addTab(tab, "Sliding Window") layout = QFormLayout(tab) - layout.addRow("Window Size:", self._create_slider_with_label('sliding_window_size', 5, 257, 129, 1.0, 0)) layout.addRow("Overlap:", self._create_slider_with_label('sliding_window_overlap', 1, 97, 5, 1.0, 0)) layout.addRow("Color Correction:", self._create_slider_with_label('sliding_window_color_correction_strength', 0, 100, 0, 100.0, 2)) @@ -807,23 +592,18 @@ def _setup_adv_tab_misc(self, tabs): tabs.addTab(tab, "Misc") layout = QFormLayout(tab) self.widgets['misc_layout'] = layout - riflex_combo = self.create_widget(QComboBox, 'RIFLEx_setting') riflex_combo.addItem("Auto", 0) riflex_combo.addItem("Always ON", 1) riflex_combo.addItem("Always OFF", 2) self.widgets['riflex_row_index'] = layout.rowCount() layout.addRow("RIFLEx Setting:", riflex_combo) - fps_combo = self.create_widget(QComboBox, 'force_fps') layout.addRow("Force FPS:", fps_combo) - profile_combo = self.create_widget(QComboBox, 'override_profile') profile_combo.addItem("Default Profile", -1) - for text, val in wgp.memory_profile_choices: - profile_combo.addItem(text.split(':')[0], val) + for text, val in self.wgp.memory_profile_choices: profile_combo.addItem(text.split(':')[0], val) layout.addRow("Override Memory Profile:", profile_combo) - combo = self.create_widget(QComboBox, 'multi_prompts_gen_type') combo.addItem("Generate new Video per line", 0) combo.addItem("Use line for new Sliding Window", 1) @@ -833,19 +613,15 @@ def setup_config_tab(self): config_tab = QWidget() self.tabs.addTab(config_tab, "Configuration") main_layout = QVBoxLayout(config_tab) - self.config_status_label = QLabel("Apply changes for them to take effect. Some may require a restart.") main_layout.addWidget(self.config_status_label) - config_tabs = QTabWidget() main_layout.addWidget(config_tabs) - config_tabs.addTab(self._create_general_config_tab(), "General") config_tabs.addTab(self._create_performance_config_tab(), "Performance") config_tabs.addTab(self._create_extensions_config_tab(), "Extensions") config_tabs.addTab(self._create_outputs_config_tab(), "Outputs") config_tabs.addTab(self._create_notifications_config_tab(), "Notifications") - self.apply_config_btn = QPushButton("Apply Changes") self.apply_config_btn.clicked.connect(self._on_apply_config_changes) main_layout.addWidget(self.apply_config_btn) @@ -856,18 +632,15 @@ def _create_scrollable_form_tab(self): scroll_area.setWidgetResizable(True) layout = QVBoxLayout(tab_widget) layout.addWidget(scroll_area) - content_widget = QWidget() form_layout = QFormLayout(content_widget) scroll_area.setWidget(content_widget) - return tab_widget, form_layout def _create_config_combo(self, form_layout, label, key, choices, default_value): combo = QComboBox() - for text, data in choices: - combo.addItem(text, data) - index = combo.findData(wgp.server_config.get(key, default_value)) + for text, data in choices: combo.addItem(text, data) + index = combo.findData(self.wgp.server_config.get(key, default_value)) if index != -1: combo.setCurrentIndex(index) self.widgets[f'config_{key}'] = combo form_layout.addRow(label, combo) @@ -876,34 +649,27 @@ def _create_config_slider(self, form_layout, label, key, min_val, max_val, defau container = QWidget() hbox = QHBoxLayout(container) hbox.setContentsMargins(0,0,0,0) - slider = QSlider(Qt.Orientation.Horizontal) slider.setRange(min_val, max_val) slider.setSingleStep(step) - slider.setValue(wgp.server_config.get(key, default_value)) - + slider.setValue(self.wgp.server_config.get(key, default_value)) value_label = QLabel(str(slider.value())) value_label.setMinimumWidth(40) slider.valueChanged.connect(lambda v, lbl=value_label: lbl.setText(str(v))) - hbox.addWidget(slider) hbox.addWidget(value_label) - self.widgets[f'config_{key}'] = slider form_layout.addRow(label, container) def _create_config_checklist(self, form_layout, label, key, choices, default_value): list_widget = QListWidget() list_widget.setMinimumHeight(100) - current_values = wgp.server_config.get(key, default_value) + current_values = self.wgp.server_config.get(key, default_value) for text, data in choices: item = QListWidgetItem(text) item.setData(Qt.ItemDataRole.UserRole, data) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - if data in current_values: - item.setCheckState(Qt.CheckState.Checked) - else: - item.setCheckState(Qt.CheckState.Unchecked) + item.setCheckState(Qt.CheckState.Checked if data in current_values else Qt.CheckState.Unchecked) list_widget.addItem(item) self.widgets[f'config_{key}'] = list_widget form_layout.addRow(label, list_widget) @@ -919,48 +685,24 @@ def _create_config_textbox(self, form_layout, label, key, default_value, multi_l def _create_general_config_tab(self): tab, form = self._create_scrollable_form_tab() - - _, _, dropdown_choices = wgp.get_sorted_dropdown(wgp.displayed_model_types, None, None, False) - self._create_config_checklist(form, "Selectable Models:", "transformer_types", dropdown_choices, wgp.transformer_types) - - self._create_config_combo(form, "Model Hierarchy:", "model_hierarchy_type", [ - ("Two Levels (Family > Model)", 0), - ("Three Levels (Family > Base > Finetune)", 1) - ], 1) - - self._create_config_combo(form, "Video Dimensions:", "fit_canvas", [ - ("Dimensions are Pixels Budget", 0), ("Dimensions are Max Width/Height", 1), - ("Dimensions are Output Width/Height (Cropped)", 2)], 0) - - self._create_config_combo(form, "Attention Type:", "attention_mode", [ - ("Auto (Recommended)", "auto"), ("SDPA", "sdpa"), ("Flash", "flash"), - ("Xformers", "xformers"), ("Sage", "sage"), ("Sage2/2++", "sage2")], "auto") - - self._create_config_combo(form, "Metadata Handling:", "metadata_type", [ - ("Embed in file (Exif/Comment)", "metadata"), ("Export separate JSON", "json"), ("None", "none")], "metadata") - - self._create_config_checklist(form, "RAM Loading Policy:", "preload_model_policy", [ - ("Preload on App Launch", "P"), ("Preload on Model Switch", "S"), ("Unload when Queue is Done", "U")], []) - - self._create_config_combo(form, "Keep Previous Videos:", "clear_file_list", [ - ("None", 0), ("Keep last video", 1), ("Keep last 5", 5), ("Keep last 10", 10), - ("Keep last 20", 20), ("Keep last 30", 30)], 5) - + _, _, dropdown_choices = self.wgp.get_sorted_dropdown(self.wgp.displayed_model_types, None, None, False) + self._create_config_checklist(form, "Selectable Models:", "transformer_types", dropdown_choices, self.wgp.transformer_types) + self._create_config_combo(form, "Model Hierarchy:", "model_hierarchy_type", [("Two Levels (Family > Model)", 0), ("Three Levels (Family > Base > Finetune)", 1)], 1) + self._create_config_combo(form, "Video Dimensions:", "fit_canvas", [("Dimensions are Pixels Budget", 0), ("Dimensions are Max Width/Height", 1), ("Dimensions are Output Width/Height (Cropped)", 2)], 0) + self._create_config_combo(form, "Attention Type:", "attention_mode", [("Auto (Recommended)", "auto"), ("SDPA", "sdpa"), ("Flash", "flash"), ("Xformers", "xformers"), ("Sage", "sage"), ("Sage2/2++", "sage2")], "auto") + self._create_config_combo(form, "Metadata Handling:", "metadata_type", [("Embed in file (Exif/Comment)", "metadata"), ("Export separate JSON", "json"), ("None", "none")], "metadata") + self._create_config_checklist(form, "RAM Loading Policy:", "preload_model_policy", [("Preload on App Launch", "P"), ("Preload on Model Switch", "S"), ("Unload when Queue is Done", "U")], []) + self._create_config_combo(form, "Keep Previous Videos:", "clear_file_list", [("None", 0), ("Keep last video", 1), ("Keep last 5", 5), ("Keep last 10", 10), ("Keep last 20", 20), ("Keep last 30", 30)], 5) self._create_config_combo(form, "Display RAM/VRAM Stats:", "display_stats", [("Disabled", 0), ("Enabled", 1)], 0) - - self._create_config_combo(form, "Max Frames Multiplier:", "max_frames_multiplier", - [(f"x{i}", i) for i in range(1, 8)], 1) - - checkpoints_paths_text = "\n".join(wgp.server_config.get("checkpoints_paths", wgp.fl.default_checkpoints_paths)) + self._create_config_combo(form, "Max Frames Multiplier:", "max_frames_multiplier", [(f"x{i}", i) for i in range(1, 8)], 1) + checkpoints_paths_text = "\n".join(self.wgp.server_config.get("checkpoints_paths", self.wgp.fl.default_checkpoints_paths)) checkpoints_textbox = QTextEdit() checkpoints_textbox.setPlainText(checkpoints_paths_text) checkpoints_textbox.setAcceptRichText(False) checkpoints_textbox.setMinimumHeight(60) self.widgets['config_checkpoints_paths'] = checkpoints_textbox form.addRow("Checkpoints Paths:", checkpoints_textbox) - self._create_config_combo(form, "UI Theme (requires restart):", "UI_theme", [("Blue Sky", "default"), ("Classic Gradio", "gradio")], "default") - return tab def _create_performance_config_tab(self): @@ -974,13 +716,11 @@ def _create_performance_config_tab(self): self._create_config_combo(form, "DepthAnything v2 Variant:", "depth_anything_v2_variant", [("Large (more precise)", "vitl"), ("Big (faster)", "vitb")], "vitl") self._create_config_combo(form, "VAE Tiling:", "vae_config", [("Auto", 0), ("Disabled", 1), ("256x256 (~8GB VRAM)", 2), ("128x128 (~6GB VRAM)", 3)], 0) self._create_config_combo(form, "Boost:", "boost", [("On", 1), ("Off", 2)], 1) - self._create_config_combo(form, "Memory Profile:", "profile", wgp.memory_profile_choices, wgp.profile_type.LowRAM_LowVRAM) + self._create_config_combo(form, "Memory Profile:", "profile", self.wgp.memory_profile_choices, self.wgp.profile_type.LowRAM_LowVRAM) self._create_config_slider(form, "Preload in VRAM (MB):", "preload_in_VRAM", 0, 40000, 0, 100) - release_ram_btn = QPushButton("Force Release Models from RAM") release_ram_btn.clicked.connect(self._on_release_ram) form.addRow(release_ram_btn) - return tab def _create_extensions_config_tab(self): @@ -1005,12 +745,12 @@ def _create_notifications_config_tab(self): return tab def init_wgp_state(self): + wgp = self.wgp initial_model = wgp.server_config.get("last_model_type", wgp.transformer_type) - all_models, _, _ = wgp.get_sorted_dropdown(wgp.displayed_model_types, None, None, False) + dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types + _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) all_model_ids = [m[1] for m in all_models] - if initial_model not in all_model_ids: - initial_model = wgp.transformer_type - + if initial_model not in all_model_ids: initial_model = wgp.transformer_type state_dict = {} state_dict["model_filename"] = wgp.get_model_filename(initial_model, wgp.transformer_quantization, wgp.transformer_dtype_policy) state_dict["model_type"] = initial_model @@ -1019,49 +759,36 @@ def init_wgp_state(self): state_dict["last_model_per_type"] = wgp.server_config.get("last_model_per_type", {}) state_dict["last_resolution_per_group"] = wgp.server_config.get("last_resolution_per_group", {}) state_dict["gen"] = {"queue": []} - self.state = state_dict self.advanced_group.setChecked(wgp.advanced) - self.update_model_dropdowns(initial_model) self.refresh_ui_from_model_change(initial_model) - self._update_input_visibility() # Set initial visibility + self._update_input_visibility() def update_model_dropdowns(self, current_model_type): + wgp = self.wgp family_mock, base_type_mock, choice_mock = wgp.generate_dropdown_model_list(current_model_type) - - self.widgets['model_family'].blockSignals(True) - self.widgets['model_base_type_choice'].blockSignals(True) - self.widgets['model_choice'].blockSignals(True) - - self.widgets['model_family'].clear() - if family_mock.choices: - for display_name, internal_key in family_mock.choices: - self.widgets['model_family'].addItem(display_name, internal_key) - index = self.widgets['model_family'].findData(family_mock.value) - if index != -1: self.widgets['model_family'].setCurrentIndex(index) - - self.widgets['model_base_type_choice'].clear() - if base_type_mock.choices: - for label, value in base_type_mock.choices: - self.widgets['model_base_type_choice'].addItem(label, value) - index = self.widgets['model_base_type_choice'].findData(base_type_mock.value) - if index != -1: self.widgets['model_base_type_choice'].setCurrentIndex(index) - self.widgets['model_base_type_choice'].setVisible(base_type_mock.kwargs.get('visible', True)) - - self.widgets['model_choice'].clear() - if choice_mock.choices: - for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) - index = self.widgets['model_choice'].findData(choice_mock.value) - if index != -1: self.widgets['model_choice'].setCurrentIndex(index) - self.widgets['model_choice'].setVisible(choice_mock.kwargs.get('visible', True)) - - self.widgets['model_family'].blockSignals(False) - self.widgets['model_base_type_choice'].blockSignals(False) - self.widgets['model_choice'].blockSignals(False) + for combo_name, mock in [('model_family', family_mock), ('model_base_type_choice', base_type_mock), ('model_choice', choice_mock)]: + combo = self.widgets[combo_name] + combo.blockSignals(True) + combo.clear() + if mock.choices: + for display_name, internal_key in mock.choices: combo.addItem(display_name, internal_key) + index = combo.findData(mock.value) + if index != -1: combo.setCurrentIndex(index) + + is_visible = True + if hasattr(mock, 'kwargs') and isinstance(mock.kwargs, dict): + is_visible = mock.kwargs.get('visible', True) + elif hasattr(mock, 'visible'): + is_visible = mock.visible + combo.setVisible(is_visible) + + combo.blockSignals(False) def refresh_ui_from_model_change(self, model_type): """Update UI controls with default settings when the model is changed.""" + wgp = self.wgp self.header_info.setText(wgp.generate_header(model_type, wgp.compile, wgp.attention_mode)) ui_defaults = wgp.get_default_settings(model_type) wgp.set_model_settings(self.state, model_type, ui_defaults) @@ -1128,10 +855,8 @@ def refresh_ui_from_model_change(self, model_type): self.widgets['resolution_group'].blockSignals(False) self.widgets['resolution'].blockSignals(False) - - for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'audio_source']: - if name in self.widgets: - self.widgets[name].clear() + for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'image_refs', 'audio_source']: + if name in self.widgets: self.widgets[name].clear() guidance_layout = self.widgets['guidance_layout'] guidance_max = model_def.get("guidance_max_phases", 1) @@ -1155,7 +880,6 @@ def refresh_ui_from_model_change(self, model_type): misc_layout = self.widgets['misc_layout'] misc_layout.setRowVisible(self.widgets['riflex_row_index'], not (recammaster or ltxv or diffusion_forcing)) - index = self.widgets['multi_images_gen_type'].findData(ui_defaults.get('multi_images_gen_type', 0)) if index != -1: self.widgets['multi_images_gen_type'].setCurrentIndex(index) @@ -1170,12 +894,11 @@ def refresh_ui_from_model_change(self, model_type): guidance3_val = ui_defaults.get("guidance3_scale", 5.0) self.widgets['guidance3_scale'].setValue(int(guidance3_val * 10)) self.widgets['guidance3_scale_label'].setText(f"{guidance3_val:.1f}") - self.widgets['guidance_phases'].clear() + self.widgets['guidance_phases'].clear() if guidance_max >= 1: self.widgets['guidance_phases'].addItem("One Phase", 1) if guidance_max >= 2: self.widgets['guidance_phases'].addItem("Two Phases", 2) if guidance_max >= 3: self.widgets['guidance_phases'].addItem("Three Phases", 3) - index = self.widgets['guidance_phases'].findData(ui_defaults.get("guidance_phases", 1)) if index != -1: self.widgets['guidance_phases'].setCurrentIndex(index) @@ -1227,9 +950,7 @@ def refresh_ui_from_model_change(self, model_type): selected_loras = ui_defaults.get('activated_loras', []) for i in range(lora_list_widget.count()): item = lora_list_widget.item(i) - is_selected = any(item.text() == os.path.basename(p) for p in selected_loras) - if is_selected: - item.setSelected(True) + if any(item.text() == os.path.basename(p) for p in selected_loras): item.setSelected(True) self.widgets['loras_multipliers'].setText(ui_defaults.get('loras_multipliers', '')) skip_cache_val = ui_defaults.get('skip_steps_cache_type', "") @@ -1329,11 +1050,11 @@ def refresh_ui_from_model_change(self, model_type): for widget in self.widgets.values(): if hasattr(widget, 'blockSignals'): widget.blockSignals(False) + self._update_dynamic_ui() self._update_input_visibility() def _update_dynamic_ui(self): - """Update UI visibility based on current selections.""" phases = self.widgets['guidance_phases'].currentData() or 1 guidance_layout = self.widgets['guidance_layout'] guidance_layout.setRowVisible(self.widgets['guidance2_row_index'], phases >= 2) @@ -1341,83 +1062,45 @@ def _update_dynamic_ui(self): guidance_layout.setRowVisible(self.widgets['switch_thresh_row_index'], phases >= 2) def _update_generation_mode_visibility(self, model_def): - """Shows/hides the main generation mode options based on the selected model.""" allowed = model_def.get("image_prompt_types_allowed", "") - choices = [] - if "T" in allowed or not allowed: - choices.append(("Text Prompt Only" if "S" in allowed else "New Video", "T")) - if "S" in allowed: - choices.append(("Start Video with Image", "S")) - if "V" in allowed: - choices.append(("Continue Video", "V")) - if "L" in allowed: - choices.append(("Continue Last Video", "L")) - - button_map = { - "T": self.widgets['mode_t'], - "S": self.widgets['mode_s'], - "V": self.widgets['mode_v'], - "L": self.widgets['mode_l'], - } - - for btn in button_map.values(): - btn.setVisible(False) - + if "T" in allowed or not allowed: choices.append(("Text Prompt Only" if "S" in allowed else "New Video", "T")) + if "S" in allowed: choices.append(("Start Video with Image", "S")) + if "V" in allowed: choices.append(("Continue Video", "V")) + if "L" in allowed: choices.append(("Continue Last Video", "L")) + button_map = { "T": self.widgets['mode_t'], "S": self.widgets['mode_s'], "V": self.widgets['mode_v'], "L": self.widgets['mode_l'] } + for btn in button_map.values(): btn.setVisible(False) allowed_values = [c[1] for c in choices] for label, value in choices: if value in button_map: btn = button_map[value] btn.setText(label) btn.setVisible(True) - - current_checked_value = None - for value, btn in button_map.items(): - if btn.isChecked(): - current_checked_value = value - break - - # If the currently selected mode is now hidden, reset to a visible default + current_checked_value = next((value for value, btn in button_map.items() if btn.isChecked()), None) if current_checked_value is None or not button_map[current_checked_value].isVisible(): - if allowed_values: - button_map[allowed_values[0]].setChecked(True) - - + if allowed_values: button_map[allowed_values[0]].setChecked(True) end_image_visible = "E" in allowed self.widgets['image_end_checkbox'].setVisible(end_image_visible) - if not end_image_visible: - self.widgets['image_end_checkbox'].setChecked(False) - - # Control Video Checkbox (Based on model_def.get("guide_preprocessing")) + if not end_image_visible: self.widgets['image_end_checkbox'].setChecked(False) control_video_visible = model_def.get("guide_preprocessing") is not None self.widgets['control_video_checkbox'].setVisible(control_video_visible) - if not control_video_visible: - self.widgets['control_video_checkbox'].setChecked(False) - - # Reference Image Checkbox (Based on model_def.get("image_ref_choices")) + if not control_video_visible: self.widgets['control_video_checkbox'].setChecked(False) ref_image_visible = model_def.get("image_ref_choices") is not None self.widgets['ref_image_checkbox'].setVisible(ref_image_visible) - if not ref_image_visible: - self.widgets['ref_image_checkbox'].setChecked(False) - + if not ref_image_visible: self.widgets['ref_image_checkbox'].setChecked(False) def _update_input_visibility(self): - """Shows/hides input fields based on the selected generation mode.""" is_s_mode = self.widgets['mode_s'].isChecked() is_v_mode = self.widgets['mode_v'].isChecked() is_l_mode = self.widgets['mode_l'].isChecked() - use_end = self.widgets['image_end_checkbox'].isChecked() and self.widgets['image_end_checkbox'].isVisible() use_control = self.widgets['control_video_checkbox'].isChecked() and self.widgets['control_video_checkbox'].isVisible() use_ref = self.widgets['ref_image_checkbox'].isChecked() and self.widgets['ref_image_checkbox'].isVisible() - self.widgets['image_start_container'].setVisible(is_s_mode) self.widgets['video_source_container'].setVisible(is_v_mode) - end_checkbox_enabled = is_s_mode or is_v_mode or is_l_mode self.widgets['image_end_checkbox'].setEnabled(end_checkbox_enabled) self.widgets['image_end_container'].setVisible(use_end and end_checkbox_enabled) - self.widgets['video_guide_container'].setVisible(use_control) self.widgets['video_mask_container'].setVisible(use_control) self.widgets['image_refs_container'].setVisible(use_ref) @@ -1428,17 +1111,14 @@ def connect_signals(self): self.widgets['model_choice'].currentIndexChanged.connect(self._on_model_changed) self.widgets['resolution_group'].currentIndexChanged.connect(self._on_resolution_group_changed) self.widgets['guidance_phases'].currentIndexChanged.connect(self._update_dynamic_ui) - self.widgets['mode_t'].toggled.connect(self._update_input_visibility) self.widgets['mode_s'].toggled.connect(self._update_input_visibility) self.widgets['mode_v'].toggled.connect(self._update_input_visibility) self.widgets['mode_l'].toggled.connect(self._update_input_visibility) - self.widgets['image_end_checkbox'].toggled.connect(self._update_input_visibility) self.widgets['control_video_checkbox'].toggled.connect(self._update_input_visibility) self.widgets['ref_image_checkbox'].toggled.connect(self._update_input_visibility) self.widgets['preview_group'].toggled.connect(self._on_preview_toggled) - self.generate_btn.clicked.connect(self._on_generate) self.add_to_queue_btn.clicked.connect(self._on_add_to_queue) self.remove_queue_btn.clicked.connect(self._on_remove_selected_from_queue) @@ -1446,214 +1126,169 @@ def connect_signals(self): self.abort_btn.clicked.connect(self._on_abort) self.queue_table.rowsMoved.connect(self._on_queue_rows_moved) + def load_main_config(self): + try: + with open('main_config.json', 'r') as f: self.main_config = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): self.main_config = {'preview_visible': False} + + def save_main_config(self): + try: + with open('main_config.json', 'w') as f: json.dump(self.main_config, f, indent=4) + except Exception as e: print(f"Error saving main_config.json: {e}") + def apply_initial_config(self): - is_visible = main_config.get('preview_visible', True) + is_visible = self.main_config.get('preview_visible', True) self.widgets['preview_group'].setChecked(is_visible) self.widgets['preview_image'].setVisible(is_visible) def _on_preview_toggled(self, checked): self.widgets['preview_image'].setVisible(checked) - main_config['preview_visible'] = checked - save_main_config() + self.main_config['preview_visible'] = checked + self.save_main_config() - def _on_family_changed(self, index): + def _on_family_changed(self): family = self.widgets['model_family'].currentData() if not family or not self.state: return - - base_type_mock, choice_mock = wgp.change_model_family(self.state, family) + base_type_mock, choice_mock = self.wgp.change_model_family(self.state, family) + if hasattr(base_type_mock, 'kwargs') and isinstance(base_type_mock.kwargs, dict): + is_visible_base = base_type_mock.kwargs.get('visible', True) + elif hasattr(base_type_mock, 'visible'): + is_visible_base = base_type_mock.visible + else: + is_visible_base = True + self.widgets['model_base_type_choice'].blockSignals(True) self.widgets['model_base_type_choice'].clear() if base_type_mock.choices: - for label, value in base_type_mock.choices: - self.widgets['model_base_type_choice'].addItem(label, value) - index = self.widgets['model_base_type_choice'].findData(base_type_mock.value) - if index != -1: - self.widgets['model_base_type_choice'].setCurrentIndex(index) - self.widgets['model_base_type_choice'].setVisible(base_type_mock.kwargs.get('visible', True)) + for label, value in base_type_mock.choices: self.widgets['model_base_type_choice'].addItem(label, value) + self.widgets['model_base_type_choice'].setCurrentIndex(self.widgets['model_base_type_choice'].findData(base_type_mock.value)) + self.widgets['model_base_type_choice'].setVisible(is_visible_base) self.widgets['model_base_type_choice'].blockSignals(False) - + + if hasattr(choice_mock, 'kwargs') and isinstance(choice_mock.kwargs, dict): + is_visible_choice = choice_mock.kwargs.get('visible', True) + elif hasattr(choice_mock, 'visible'): + is_visible_choice = choice_mock.visible + else: + is_visible_choice = True + self.widgets['model_choice'].blockSignals(True) self.widgets['model_choice'].clear() if choice_mock.choices: - for label, value in choice_mock.choices: - self.widgets['model_choice'].addItem(label, value) - index = self.widgets['model_choice'].findData(choice_mock.value) - if index != -1: - self.widgets['model_choice'].setCurrentIndex(index) - self.widgets['model_choice'].setVisible(choice_mock.kwargs.get('visible', True)) + for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) + self.widgets['model_choice'].setCurrentIndex(self.widgets['model_choice'].findData(choice_mock.value)) + self.widgets['model_choice'].setVisible(is_visible_choice) self.widgets['model_choice'].blockSignals(False) - + self._on_model_changed() - def _on_base_type_changed(self, index): + def _on_base_type_changed(self): family = self.widgets['model_family'].currentData() base_type = self.widgets['model_base_type_choice'].currentData() if not family or not base_type or not self.state: return - - base_type_mock, choice_mock = wgp.change_model_base_types(self.state, family, base_type) - + base_type_mock, choice_mock = self.wgp.change_model_base_types(self.state, family, base_type) + + if hasattr(choice_mock, 'kwargs') and isinstance(choice_mock.kwargs, dict): + is_visible_choice = choice_mock.kwargs.get('visible', True) + elif hasattr(choice_mock, 'visible'): + is_visible_choice = choice_mock.visible + else: + is_visible_choice = True + self.widgets['model_choice'].blockSignals(True) self.widgets['model_choice'].clear() if choice_mock.choices: - for label, value in choice_mock.choices: - self.widgets['model_choice'].addItem(label, value) - index = self.widgets['model_choice'].findData(choice_mock.value) - if index != -1: - self.widgets['model_choice'].setCurrentIndex(index) - self.widgets['model_choice'].setVisible(choice_mock.kwargs.get('visible', True)) + for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) + self.widgets['model_choice'].setCurrentIndex(self.widgets['model_choice'].findData(choice_mock.value)) + self.widgets['model_choice'].setVisible(is_visible_choice) self.widgets['model_choice'].blockSignals(False) - self._on_model_changed() def _on_model_changed(self): model_type = self.widgets['model_choice'].currentData() - if not model_type or model_type == self.state['model_type']: return - wgp.change_model(self.state, model_type) + if not model_type or model_type == self.state.get('model_type'): return + self.wgp.change_model(self.state, model_type) self.refresh_ui_from_model_change(model_type) def _on_resolution_group_changed(self): selected_group = self.widgets['resolution_group'].currentText() - if not selected_group or not hasattr(self, 'full_resolution_choices'): - return - + if not selected_group or not hasattr(self, 'full_resolution_choices'): return model_type = self.state['model_type'] - model_def = wgp.get_model_def(model_type) + model_def = self.wgp.get_model_def(model_type) model_resolutions = model_def.get("resolutions", None) - group_resolution_choices = [] if model_resolutions is None: - group_resolution_choices = [res for res in self.full_resolution_choices if wgp.categorize_resolution(res[1]) == selected_group] - else: - return - - last_resolution_per_group = self.state.get("last_resolution_per_group", {}) - last_resolution = last_resolution_per_group.get(selected_group, "") - - is_last_res_valid = any(last_resolution == res[1] for res in group_resolution_choices) - if not is_last_res_valid and group_resolution_choices: + group_resolution_choices = [res for res in self.full_resolution_choices if self.wgp.categorize_resolution(res[1]) == selected_group] + else: return + last_resolution = self.state.get("last_resolution_per_group", {}).get(selected_group, "") + if not any(last_resolution == res[1] for res in group_resolution_choices) and group_resolution_choices: last_resolution = group_resolution_choices[0][1] - self.widgets['resolution'].blockSignals(True) self.widgets['resolution'].clear() - for label, value in group_resolution_choices: - self.widgets['resolution'].addItem(label, value) - - index = self.widgets['resolution'].findData(last_resolution) - if index != -1: - self.widgets['resolution'].setCurrentIndex(index) + for label, value in group_resolution_choices: self.widgets['resolution'].addItem(label, value) + self.widgets['resolution'].setCurrentIndex(self.widgets['resolution'].findData(last_resolution)) self.widgets['resolution'].blockSignals(False) def collect_inputs(self): - """Gather all settings from UI widgets into a dictionary.""" - # Start with all possible defaults. This dictionary will be modified and returned. - full_inputs = wgp.get_current_model_settings(self.state).copy() - - # Add dummy/default values for UI elements present in Gradio but not yet in PyQt. - # These are expected by the backend logic. + full_inputs = self.wgp.get_current_model_settings(self.state).copy() full_inputs['lset_name'] = "" - # The PyQt UI is focused on generating videos, so image_mode is 0. full_inputs['image_mode'] = 0 - - # Defensively initialize keys that are accessed directly in wgp.py but may not - # be in the saved model settings or fully implemented in the UI yet. - # This prevents both KeyErrors and TypeErrors for missing arguments. - expected_keys = { - "audio_guide": None, "audio_guide2": None, "image_guide": None, - "image_mask": None, "speakers_locations": "", "frames_positions": "", - "keep_frames_video_guide": "", "keep_frames_video_source": "", - "video_guide_outpainting": "", "switch_threshold2": 0, - "model_switch_phase": 1, "batch_size": 1, - "control_net_weight_alt": 1.0, - "image_refs_relative_size": 50, - } + expected_keys = { "audio_guide": None, "audio_guide2": None, "image_guide": None, "image_mask": None, "speakers_locations": "", "frames_positions": "", "keep_frames_video_guide": "", "keep_frames_video_source": "", "video_guide_outpainting": "", "switch_threshold2": 0, "model_switch_phase": 1, "batch_size": 1, "control_net_weight_alt": 1.0, "image_refs_relative_size": 50, } for key, default_value in expected_keys.items(): - if key not in full_inputs: - full_inputs[key] = default_value - - # Overwrite defaults with values from the PyQt UI widgets + if key not in full_inputs: full_inputs[key] = default_value full_inputs['prompt'] = self.widgets['prompt'].toPlainText() full_inputs['negative_prompt'] = self.widgets['negative_prompt'].toPlainText() full_inputs['resolution'] = self.widgets['resolution'].currentData() full_inputs['video_length'] = self.widgets['video_length'].value() full_inputs['num_inference_steps'] = self.widgets['num_inference_steps'].value() full_inputs['seed'] = int(self.widgets['seed'].text()) - - # Build prompt_type strings based on mode selections image_prompt_type = "" video_prompt_type = "" - - if self.widgets['mode_s'].isChecked(): - image_prompt_type = 'S' - elif self.widgets['mode_v'].isChecked(): - image_prompt_type = 'V' - elif self.widgets['mode_l'].isChecked(): - image_prompt_type = 'L' - else: # mode_t is checked - image_prompt_type = '' - - if self.widgets['image_end_checkbox'].isVisible() and self.widgets['image_end_checkbox'].isChecked(): - image_prompt_type += 'E' - - if self.widgets['control_video_checkbox'].isVisible() and self.widgets['control_video_checkbox'].isChecked(): - video_prompt_type += 'V' # This 'V' is for Control Video (V2V) - if self.widgets['ref_image_checkbox'].isVisible() and self.widgets['ref_image_checkbox'].isChecked(): - video_prompt_type += 'I' # 'I' for Reference Image - + if self.widgets['mode_s'].isChecked(): image_prompt_type = 'S' + elif self.widgets['mode_v'].isChecked(): image_prompt_type = 'V' + elif self.widgets['mode_l'].isChecked(): image_prompt_type = 'L' + if self.widgets['image_end_checkbox'].isVisible() and self.widgets['image_end_checkbox'].isChecked(): image_prompt_type += 'E' + if self.widgets['control_video_checkbox'].isVisible() and self.widgets['control_video_checkbox'].isChecked(): video_prompt_type += 'V' + if self.widgets['ref_image_checkbox'].isVisible() and self.widgets['ref_image_checkbox'].isChecked(): video_prompt_type += 'I' full_inputs['image_prompt_type'] = image_prompt_type full_inputs['video_prompt_type'] = video_prompt_type - - # File Inputs for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'audio_source']: - if name in self.widgets: - path = self.widgets[name].text() - full_inputs[name] = path if path else None - + if name in self.widgets: full_inputs[name] = self.widgets[name].text() or None paths = self.widgets['image_refs'].text().split(';') full_inputs['image_refs'] = [p.strip() for p in paths if p.strip()] if paths and paths[0] else None - full_inputs['denoising_strength'] = self.widgets['denoising_strength'].value() / 100.0 - if self.advanced_group.isChecked(): full_inputs['guidance_scale'] = self.widgets['guidance_scale'].value() / 10.0 full_inputs['guidance_phases'] = self.widgets['guidance_phases'].currentData() full_inputs['guidance2_scale'] = self.widgets['guidance2_scale'].value() / 10.0 full_inputs['guidance3_scale'] = self.widgets['guidance3_scale'].value() / 10.0 full_inputs['switch_threshold'] = self.widgets['switch_threshold'].value() - full_inputs['NAG_scale'] = self.widgets['NAG_scale'].value() / 10.0 full_inputs['NAG_tau'] = self.widgets['NAG_tau'].value() / 10.0 full_inputs['NAG_alpha'] = self.widgets['NAG_alpha'].value() / 10.0 - full_inputs['sample_solver'] = self.widgets['sample_solver'].currentData() full_inputs['flow_shift'] = self.widgets['flow_shift'].value() / 10.0 full_inputs['audio_guidance_scale'] = self.widgets['audio_guidance_scale'].value() / 10.0 full_inputs['repeat_generation'] = self.widgets['repeat_generation'].value() full_inputs['multi_images_gen_type'] = self.widgets['multi_images_gen_type'].currentData() - - lora_list_widget = self.widgets['activated_loras'] - selected_items = lora_list_widget.selectedItems() + selected_items = self.widgets['activated_loras'].selectedItems() full_inputs['activated_loras'] = [self.lora_map[item.text()] for item in selected_items if item.text() in self.lora_map] full_inputs['loras_multipliers'] = self.widgets['loras_multipliers'].toPlainText() - full_inputs['skip_steps_cache_type'] = self.widgets['skip_steps_cache_type'].currentData() full_inputs['skip_steps_multiplier'] = self.widgets['skip_steps_multiplier'].currentData() full_inputs['skip_steps_start_step_perc'] = self.widgets['skip_steps_start_step_perc'].value() - full_inputs['temporal_upsampling'] = self.widgets['temporal_upsampling'].currentData() full_inputs['spatial_upsampling'] = self.widgets['spatial_upsampling'].currentData() full_inputs['film_grain_intensity'] = self.widgets['film_grain_intensity'].value() / 100.0 full_inputs['film_grain_saturation'] = self.widgets['film_grain_saturation'].value() / 100.0 - full_inputs['MMAudio_setting'] = self.widgets['MMAudio_setting'].currentData() full_inputs['MMAudio_prompt'] = self.widgets['MMAudio_prompt'].text() full_inputs['MMAudio_neg_prompt'] = self.widgets['MMAudio_neg_prompt'].text() - full_inputs['RIFLEx_setting'] = self.widgets['RIFLEx_setting'].currentData() full_inputs['force_fps'] = self.widgets['force_fps'].currentData() full_inputs['override_profile'] = self.widgets['override_profile'].currentData() full_inputs['multi_prompts_gen_type'] = self.widgets['multi_prompts_gen_type'].currentData() - full_inputs['slg_switch'] = self.widgets['slg_switch'].currentData() full_inputs['slg_start_perc'] = self.widgets['slg_start_perc'].value() full_inputs['slg_end_perc'] = self.widgets['slg_end_perc'].value() @@ -1661,13 +1296,11 @@ def collect_inputs(self): full_inputs['cfg_star_switch'] = self.widgets['cfg_star_switch'].currentData() full_inputs['cfg_zero_step'] = self.widgets['cfg_zero_step'].value() full_inputs['min_frames_if_references'] = self.widgets['min_frames_if_references'].currentData() - full_inputs['sliding_window_size'] = self.widgets['sliding_window_size'].value() full_inputs['sliding_window_overlap'] = self.widgets['sliding_window_overlap'].value() full_inputs['sliding_window_color_correction_strength'] = self.widgets['sliding_window_color_correction_strength'].value() / 100.0 full_inputs['sliding_window_overlap_noise'] = self.widgets['sliding_window_overlap_noise'].value() full_inputs['sliding_window_discard_last_frames'] = self.widgets['sliding_window_discard_last_frames'].value() - return full_inputs def _prepare_state_for_generation(self): @@ -1678,62 +1311,44 @@ def _prepare_state_for_generation(self): def _on_generate(self): try: is_running = self.thread and self.thread.isRunning() - self._add_task_to_queue_and_update_ui() - if not is_running: - self.start_generation() - except Exception: - import traceback - traceback.print_exc() - + self._add_task_to_queue() + if not is_running: self.start_generation() + except Exception as e: + import traceback; traceback.print_exc() def _on_add_to_queue(self): try: - self._add_task_to_queue_and_update_ui() - except Exception: - import traceback - traceback.print_exc() - - def _add_task_to_queue_and_update_ui(self): - self._add_task_to_queue() - self.update_queue_table() - + self._add_task_to_queue() + except Exception as e: + import traceback; traceback.print_exc() + def _add_task_to_queue(self): - queue_size_before = len(self.state["gen"]["queue"]) all_inputs = self.collect_inputs() - keys_to_remove = ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality', 'base_model_type'] - for key in keys_to_remove: - all_inputs.pop(key, None) - + for key in ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality', 'base_model_type']: all_inputs.pop(key, None) all_inputs['state'] = self.state - wgp.set_model_settings(self.state, self.state['model_type'], all_inputs) - + self.wgp.set_model_settings(self.state, self.state['model_type'], all_inputs) self.state["validate_success"] = 1 - wgp.process_prompt_and_add_tasks(self.state, self.state['model_type']) - + self.wgp.process_prompt_and_add_tasks(self.state, self.state['model_type']) + self.update_queue_table() def start_generation(self): - if not self.state['gen']['queue']: - return + if not self.state['gen']['queue']: return self._prepare_state_for_generation() self.generate_btn.setEnabled(False) self.add_to_queue_btn.setEnabled(True) - self.thread = QThread() - self.worker = Worker(self.state) + self.worker = Worker(self.plugin, self.state) self.worker.moveToThread(self.thread) - self.thread.started.connect(self.worker.run) self.worker.finished.connect(self.thread.quit) self.worker.finished.connect(self.worker.deleteLater) self.thread.finished.connect(self.thread.deleteLater) self.thread.finished.connect(self.on_generation_finished) - self.worker.status.connect(self.status_label.setText) self.worker.progress.connect(self.update_progress) self.worker.preview.connect(self.update_preview) - self.worker.output.connect(self.update_queue_and_gallery) + self.worker.output.connect(self.update_queue_and_results) self.worker.error.connect(self.on_generation_error) - self.thread.start() self.update_queue_table() @@ -1743,17 +1358,11 @@ def on_generation_finished(self): self.progress_bar.setValue(0) self.generate_btn.setEnabled(True) self.add_to_queue_btn.setEnabled(False) - self.thread = None - self.worker = None + self.thread = None; self.worker = None self.update_queue_table() def on_generation_error(self, err_msg): - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Icon.Critical) - msg_box.setText("Generation Error") - msg_box.setInformativeText(str(err_msg)) - msg_box.setWindowTitle("Error") - msg_box.exec() + QMessageBox.critical(self, "Generation Error", str(err_msg)) self.on_generation_finished() def update_progress(self, data): @@ -1762,221 +1371,283 @@ def update_progress(self, data): self.progress_bar.setMaximum(total) self.progress_bar.setValue(step) self.status_label.setText(str(data[1])) - if step <= 1: - self.update_queue_table() - elif len(data) > 1: - self.status_label.setText(str(data[1])) + if step <= 1: self.update_queue_table() + elif len(data) > 1: self.status_label.setText(str(data[1])) def update_preview(self, pil_image): - if pil_image: + if pil_image and self.widgets['preview_group'].isChecked(): q_image = ImageQt(pil_image) pixmap = QPixmap.fromImage(q_image) - self.preview_image.setPixmap(pixmap.scaled( - self.preview_image.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - )) + self.preview_image.setPixmap(pixmap.scaled(self.preview_image.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) - def update_queue_and_gallery(self): + def update_queue_and_results(self): self.update_queue_table() - file_list = self.state.get('gen', {}).get('file_list', []) - self.output_gallery.clear() - self.output_gallery.addItems(file_list) - if file_list: - self.output_gallery.setCurrentRow(len(file_list) - 1) - self.latest_output_path = file_list[-1] # <-- ADDED for API access + for file_path in file_list: + if file_path not in self.processed_files: + self.add_result_item(file_path) + self.processed_files.add(file_path) + + def add_result_item(self, video_path): + item_widget = VideoResultItemWidget(video_path, self.plugin) + list_item = QListWidgetItem(self.results_list) + list_item.setSizeHint(item_widget.sizeHint()) + self.results_list.addItem(list_item) + self.results_list.setItemWidget(list_item, item_widget) def update_queue_table(self): - with wgp.lock: + with self.wgp.lock: queue = self.state.get('gen', {}).get('queue', []) is_running = self.thread and self.thread.isRunning() queue_to_display = queue if is_running else [None] + queue - - table_data = wgp.get_queue_table(queue_to_display) - + table_data = self.wgp.get_queue_table(queue_to_display) self.queue_table.setRowCount(0) self.queue_table.setRowCount(len(table_data)) self.queue_table.setColumnCount(4) self.queue_table.setHorizontalHeaderLabels(["Qty", "Prompt", "Length", "Steps"]) - for row_idx, row_data in enumerate(table_data): - prompt_html = row_data[1] - try: - prompt_text = prompt_html.split('>')[1].split('<')[0] - except IndexError: - prompt_text = str(row_data[1]) - - self.queue_table.setItem(row_idx, 0, QTableWidgetItem(str(row_data[0]))) - self.queue_table.setItem(row_idx, 1, QTableWidgetItem(prompt_text)) - self.queue_table.setItem(row_idx, 2, QTableWidgetItem(str(row_data[2]))) - self.queue_table.setItem(row_idx, 3, QTableWidgetItem(str(row_data[3]))) - + prompt_text = str(row_data[1]).split('>')[1].split('<')[0] if '>' in str(row_data[1]) else str(row_data[1]) + for col_idx, cell_data in enumerate([row_data[0], prompt_text, row_data[2], row_data[3]]): + self.queue_table.setItem(row_idx, col_idx, QTableWidgetItem(str(cell_data))) self.queue_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.queue_table.resizeColumnsToContents() def _on_remove_selected_from_queue(self): selected_row = self.queue_table.currentRow() - if selected_row < 0: - return - - with wgp.lock: + if selected_row < 0: return + with self.wgp.lock: is_running = self.thread and self.thread.isRunning() offset = 1 if is_running else 0 - queue = self.state.get('gen', {}).get('queue', []) - if len(queue) > selected_row + offset: - queue.pop(selected_row + offset) + if len(queue) > selected_row + offset: queue.pop(selected_row + offset) self.update_queue_table() def _on_queue_rows_moved(self, source_row, dest_row): - with wgp.lock: + with self.wgp.lock: queue = self.state.get('gen', {}).get('queue', []) is_running = self.thread and self.thread.isRunning() offset = 1 if is_running else 0 - real_source_idx = source_row + offset real_dest_idx = dest_row + offset - moved_item = queue.pop(real_source_idx) queue.insert(real_dest_idx, moved_item) self.update_queue_table() def _on_clear_queue(self): - wgp.clear_queue_action(self.state) + self.wgp.clear_queue_action(self.state) self.update_queue_table() def _on_abort(self): if self.worker: - wgp.abort_generation(self.state) + self.wgp.abort_generation(self.state) self.status_label.setText("Aborting...") self.worker._is_running = False def _on_release_ram(self): - wgp.release_RAM() + self.wgp.release_RAM() QMessageBox.information(self, "RAM Released", "Models stored in RAM have been released.") def _on_apply_config_changes(self): changes = {} - list_widget = self.widgets['config_transformer_types'] - checked_items = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] - changes['transformer_types_choices'] = checked_items - + changes['transformer_types_choices'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] list_widget = self.widgets['config_preload_model_policy'] - checked_items = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] - changes['preload_model_policy_choice'] = checked_items - + changes['preload_model_policy_choice'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] changes['model_hierarchy_type_choice'] = self.widgets['config_model_hierarchy_type'].currentData() changes['checkpoints_paths'] = self.widgets['config_checkpoints_paths'].toPlainText() - for key in ["fit_canvas", "attention_mode", "metadata_type", "clear_file_list", "display_stats", "max_frames_multiplier", "UI_theme"]: changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - for key in ["transformer_quantization", "transformer_dtype_policy", "mixed_precision", "text_encoder_quantization", "vae_precision", "compile", "depth_anything_v2_variant", "vae_config", "boost", "profile"]: changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() changes['preload_in_VRAM_choice'] = self.widgets['config_preload_in_VRAM'].value() - for key in ["enhancer_enabled", "enhancer_mode", "mmaudio_enabled"]: changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - for key in ["video_output_codec", "image_output_codec", "save_path", "image_save_path"]: widget = self.widgets[f'config_{key}'] changes[f'{key}_choice'] = widget.currentData() if isinstance(widget, QComboBox) else widget.text() - changes['notification_sound_enabled_choice'] = self.widgets['config_notification_sound_enabled'].currentData() changes['notification_sound_volume_choice'] = self.widgets['config_notification_sound_volume'].value() - changes['last_resolution_choice'] = self.widgets['resolution'].currentData() - try: - msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = wgp.apply_changes(self.state, **changes) + msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = self.wgp.apply_changes(self.state, **changes) self.config_status_label.setText("Changes applied successfully. Some settings may require a restart.") - - self.header_info.setText(wgp.generate_header(self.state['model_type'], wgp.compile, wgp.attention_mode)) - + self.header_info.setText(self.wgp.generate_header(self.state['model_type'], self.wgp.compile, self.wgp.attention_mode)) if family_mock.choices is not None or choice_mock.choices is not None: - self.update_model_dropdowns(wgp.transformer_type) - self.refresh_ui_from_model_change(wgp.transformer_type) - + self.update_model_dropdowns(self.wgp.transformer_type) + self.refresh_ui_from_model_change(self.wgp.transformer_type) except Exception as e: self.config_status_label.setText(f"Error applying changes: {e}") - import traceback - traceback.print_exc() - - def closeEvent(self, event): - if wgp: - wgp.autosave_queue() - save_main_config() - event.accept() - -# ===================================================================== -# --- START OF API SERVER ADDITION --- -# ===================================================================== -try: - from flask import Flask, request, jsonify - FLASK_AVAILABLE = True -except ImportError: - FLASK_AVAILABLE = False - print("Flask not installed. API server will not be available. Please run: pip install Flask") - -# Global reference to the main window, to be populated after instantiation. -main_window_instance = None -api_server = Flask(__name__) if FLASK_AVAILABLE else None - -def run_api_server(): - """Function to run the Flask server.""" - if api_server: - print("Starting API server on http://127.0.0.1:5100") - api_server.run(port=5100, host='127.0.0.1', debug=False) - -if FLASK_AVAILABLE: - - @api_server.route('/api/generate', methods=['POST']) - def generate(): - if not main_window_instance: - return jsonify({"error": "Application not ready"}), 503 - - data = request.json - start_frame = data.get('start_frame') - end_frame = data.get('end_frame') - # --- CHANGE: Get duration_sec and model_type --- - duration_sec = data.get('duration_sec') - model_type = data.get('model_type') - start_generation = data.get('start_generation', False) - - # --- CHANGE: Emit the signal with the new parameters --- - main_window_instance.api_bridge.generateSignal.emit( - start_frame, end_frame, duration_sec, model_type, start_generation - ) - - if start_generation: - return jsonify({"message": "Parameters set and generation request sent."}) + import traceback; traceback.print_exc() + +class Plugin(VideoEditorPlugin): + def initialize(self): + self.name = "WanGP AI Generator" + self.description = "Uses the integrated WanGP library to generate video clips." + self.client_widget = WgpDesktopPluginWidget(self) + self.dock_widget = None + self.active_region = None; self.temp_dir = None + self.insert_on_new_track = False; self.start_frame_path = None; self.end_frame_path = None + + def enable(self): + if not self.dock_widget: self.dock_widget = self.app.add_dock_widget(self, self.client_widget, self.name) + self.app.timeline_widget.context_menu_requested.connect(self.on_timeline_context_menu) + self.app.status_label.setText(f"{self.name}: Enabled.") + + def disable(self): + try: self.app.timeline_widget.context_menu_requested.disconnect(self.on_timeline_context_menu) + except TypeError: pass + self._cleanup_temp_dir() + if self.client_widget.worker: self.client_widget._on_abort() + self.app.status_label.setText(f"{self.name}: Disabled.") + + def _cleanup_temp_dir(self): + if self.temp_dir and os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + self.temp_dir = None + + def _reset_state(self): + self.active_region = None; self.insert_on_new_track = False + self.start_frame_path = None; self.end_frame_path = None + self.client_widget.processed_files.clear() + self.client_widget.results_list.clear() + self.client_widget.widgets['image_start'].clear() + self.client_widget.widgets['image_end'].clear() + self.client_widget.widgets['video_source'].clear() + self._cleanup_temp_dir() + self.app.status_label.setText(f"{self.name}: Ready.") + + def on_timeline_context_menu(self, menu, event): + region = self.app.timeline_widget.get_region_at_pos(event.pos()) + if region: + menu.addSeparator() + start_sec, end_sec = region + start_data, _, _ = self.app.get_frame_data_at_time(start_sec) + end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + if start_data and end_data: + join_action = menu.addAction("Join Frames With WanGP") + join_action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) + join_action_new_track = menu.addAction("Join Frames With WanGP (New Track)") + join_action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) + create_action = menu.addAction("Create Video With WanGP") + create_action.triggered.connect(lambda: self.setup_creator_for_region(region)) + + def setup_generator_for_region(self, region, on_new_track=False): + self._reset_state() + self.active_region = region + self.insert_on_new_track = on_new_track + + model_to_set = 'i2v_2_2' + dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types + _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + if any(model_to_set == m[1] for m in all_models): + if self.client_widget.state.get('model_type') != model_to_set: + self.client_widget.update_model_dropdowns(model_to_set) + self.client_widget._on_model_changed() else: - return jsonify({"message": "Parameters set without starting generation."}) - - - @api_server.route('/api/outputs', methods=['GET']) - def get_outputs(): - if not main_window_instance: - return jsonify({"error": "Application not ready"}), 503 - - file_list = main_window_instance.state.get('gen', {}).get('file_list', []) - return jsonify({"outputs": file_list}) - -# ===================================================================== -# --- END OF API SERVER ADDITION --- -# ===================================================================== - -if __name__ == '__main__': - app = QApplication(sys.argv) - - window = MainWindow() - main_window_instance = window - window.show() + print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") + + start_sec, end_sec = region + start_data, w, h = self.app.get_frame_data_at_time(start_sec) + end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + if not start_data or not end_data: + QMessageBox.warning(self.app, "Frame Error", "Could not extract start and/or end frames.") + return + try: + self.temp_dir = tempfile.mkdtemp(prefix="wgp_plugin_") + self.start_frame_path = os.path.join(self.temp_dir, "start_frame.png") + self.end_frame_path = os.path.join(self.temp_dir, "end_frame.png") + QImage(start_data, w, h, QImage.Format.Format_RGB888).save(self.start_frame_path) + QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) + + duration_sec = end_sec - start_sec + wgp = self.client_widget.wgp + model_type = self.client_widget.state['model_type'] + fps = wgp.get_model_fps(model_type) + video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + + self.client_widget.widgets['video_length'].setValue(video_length_frames) + self.client_widget.widgets['mode_s'].setChecked(True) + self.client_widget.widgets['image_end_checkbox'].setChecked(True) + self.client_widget.widgets['image_start'].setText(self.start_frame_path) + self.client_widget.widgets['image_end'].setText(self.end_frame_path) + + self.client_widget._update_input_visibility() - if FLASK_AVAILABLE: - api_thread = threading.Thread(target=run_api_server, daemon=True) - api_thread.start() + except Exception as e: + QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame images: {e}") + self._cleanup_temp_dir() + return + self.app.status_label.setText(f"WanGP: Ready to join frames from {start_sec:.2f}s to {end_sec:.2f}s.") + self.dock_widget.show() + self.dock_widget.raise_() + + def setup_creator_for_region(self, region): + self._reset_state() + self.active_region = region + self.insert_on_new_track = True + + model_to_set = 't2v_2_2' + dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types + _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + if any(model_to_set == m[1] for m in all_models): + if self.client_widget.state.get('model_type') != model_to_set: + self.client_widget.update_model_dropdowns(model_to_set) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Creator. Using current model.") - sys.exit(app.exec()) \ No newline at end of file + start_sec, end_sec = region + duration_sec = end_sec - start_sec + wgp = self.client_widget.wgp + model_type = self.client_widget.state['model_type'] + fps = wgp.get_model_fps(model_type) + video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + + self.client_widget.widgets['video_length'].setValue(video_length_frames) + self.client_widget.widgets['mode_t'].setChecked(True) + + self.app.status_label.setText(f"WanGP: Ready to create video from {start_sec:.2f}s to {end_sec:.2f}s.") + self.dock_widget.show() + self.dock_widget.raise_() + + def insert_generated_clip(self, video_path): + from videoeditor import TimelineClip + if not self.active_region: + self.app.status_label.setText("WanGP Error: No active region to insert into."); return + if not os.path.exists(video_path): + self.app.status_label.setText(f"WanGP Error: Output file not found: {video_path}"); return + start_sec, end_sec = self.active_region + def complex_insertion_action(): + self.app._add_media_files_to_project([video_path]) + media_info = self.app.media_properties.get(video_path) + if not media_info: raise ValueError("Could not probe inserted clip.") + actual_duration, has_audio = media_info['duration'], media_info['has_audio'] + if self.insert_on_new_track: + self.app.timeline.num_video_tracks += 1 + video_track_index = self.app.timeline.num_video_tracks + audio_track_index = self.app.timeline.num_audio_tracks + 1 if has_audio else None + else: + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) + clips_to_remove = [c for c in self.app.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec] + for clip in clips_to_remove: + if clip in self.app.timeline.clips: self.app.timeline.clips.remove(clip) + video_track_index, audio_track_index = 1, 1 if has_audio else None + group_id = str(uuid.uuid4()) + new_clip = TimelineClip(video_path, start_sec, 0, actual_duration, video_track_index, 'video', 'video', group_id) + self.app.timeline.add_clip(new_clip) + if audio_track_index: + if audio_track_index > self.app.timeline.num_audio_tracks: self.app.timeline.num_audio_tracks = audio_track_index + audio_clip = TimelineClip(video_path, start_sec, 0, actual_duration, audio_track_index, 'audio', 'video', group_id) + self.app.timeline.add_clip(audio_clip) + try: + self.app._perform_complex_timeline_change("Insert AI Clip", complex_insertion_action) + self.app.prune_empty_tracks() + self.app.status_label.setText("AI clip inserted successfully.") + for i in range(self.client_widget.results_list.count()): + widget = self.client_widget.results_list.itemWidget(self.client_widget.results_list.item(i)) + if widget and widget.video_path == video_path: + self.client_widget.results_list.takeItem(i); break + except Exception as e: + import traceback; traceback.print_exc() + self.app.status_label.setText(f"WanGP Error during clip insertion: {e}") \ No newline at end of file diff --git a/videoeditor/undo.py b/undo.py similarity index 100% rename from videoeditor/undo.py rename to undo.py diff --git a/videoeditor/main.py b/videoeditor.py similarity index 99% rename from videoeditor/main.py rename to videoeditor.py index 4bd3662c6..1c7466957 100644 --- a/videoeditor/main.py +++ b/videoeditor.py @@ -1,3 +1,4 @@ +import wgp import sys import os import uuid @@ -1288,7 +1289,7 @@ def __init__(self, project_to_load=None): self.is_shutting_down = False self._load_settings() - self.plugin_manager = PluginManager(self) + self.plugin_manager = PluginManager(self, wgp) self.plugin_manager.discover_and_load_plugins() self.project_fps = 25.0 diff --git a/videoeditor/plugins/ai_frame_joiner/main.py b/videoeditor/plugins/ai_frame_joiner/main.py deleted file mode 100644 index 410333d28..000000000 --- a/videoeditor/plugins/ai_frame_joiner/main.py +++ /dev/null @@ -1,512 +0,0 @@ -import sys -import os -import tempfile -import shutil -import requests -from pathlib import Path -import ffmpeg -import uuid - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, - QLineEdit, QPushButton, QLabel, QMessageBox, QCheckBox, QListWidget, - QListWidgetItem, QGroupBox -) -from PyQt6.QtGui import QImage, QPixmap -from PyQt6.QtCore import pyqtSignal, Qt, QSize, QRectF, QUrl, QTimer -from PyQt6.QtMultimedia import QMediaPlayer -from PyQt6.QtMultimediaWidgets import QVideoWidget - -sys.path.append(str(Path(__file__).parent.parent.parent)) -from plugins import VideoEditorPlugin - -API_BASE_URL = "http://127.0.0.1:5100" - -class VideoResultItemWidget(QWidget): - def __init__(self, video_path, plugin, parent=None): - super().__init__(parent) - self.video_path = video_path - self.plugin = plugin - self.app = plugin.app - self.duration = 0.0 - self.has_audio = False - - self.setMinimumSize(200, 180) - self.setMaximumHeight(190) - - layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(5) - - self.media_player = QMediaPlayer() - self.video_widget = QVideoWidget() - self.video_widget.setFixedSize(160, 90) - self.media_player.setVideoOutput(self.video_widget) - self.media_player.setSource(QUrl.fromLocalFile(self.video_path)) - self.media_player.setLoops(QMediaPlayer.Loops.Infinite) - - self.info_label = QLabel(os.path.basename(video_path)) - self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.info_label.setWordWrap(True) - - self.insert_button = QPushButton("Insert into Timeline") - self.insert_button.clicked.connect(self.on_insert) - - h_layout = QHBoxLayout() - h_layout.addStretch() - h_layout.addWidget(self.video_widget) - h_layout.addStretch() - - layout.addLayout(h_layout) - layout.addWidget(self.info_label) - layout.addWidget(self.insert_button) - - self.probe_video() - - def probe_video(self): - try: - probe = ffmpeg.probe(self.video_path) - self.duration = float(probe['format']['duration']) - self.has_audio = any(s['codec_type'] == 'audio' for s in probe.get('streams', [])) - - self.info_label.setText(f"{os.path.basename(self.video_path)}\n({self.duration:.2f}s)") - - except Exception as e: - self.info_label.setText(f"Error probing:\n{os.path.basename(self.video_path)}") - print(f"Error probing video {self.video_path}: {e}") - - def enterEvent(self, event): - super().enterEvent(event) - self.media_player.play() - - if not self.plugin.active_region or self.duration == 0: - return - - start_sec, _ = self.plugin.active_region - timeline = self.app.timeline_widget - - video_rect, audio_rect = None, None - - x = timeline.sec_to_x(start_sec) - w = int(self.duration * timeline.pixels_per_second) - - if self.plugin.insert_on_new_track: - video_y = timeline.TIMESCALE_HEIGHT - video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) - if self.has_audio: - audio_y = timeline.audio_tracks_y_start + self.app.timeline.num_audio_tracks * timeline.TRACK_HEIGHT - audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) - else: - v_track_idx = 1 - visual_v_idx = self.app.timeline.num_video_tracks - v_track_idx - video_y = timeline.video_tracks_y_start + visual_v_idx * timeline.TRACK_HEIGHT - video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) - if self.has_audio: - a_track_idx = 1 - visual_a_idx = a_track_idx - 1 - audio_y = timeline.audio_tracks_y_start + visual_a_idx * timeline.TRACK_HEIGHT - audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) - - timeline.set_hover_preview_rects(video_rect, audio_rect) - - def leaveEvent(self, event): - super().leaveEvent(event) - self.media_player.pause() - self.media_player.setPosition(0) - self.app.timeline_widget.set_hover_preview_rects(None, None) - - def on_insert(self): - self.media_player.stop() - self.media_player.setSource(QUrl()) - self.media_player.setVideoOutput(None) - self.app.timeline_widget.set_hover_preview_rects(None, None) - self.plugin.insert_generated_clip(self.video_path) - -class WgpClientWidget(QWidget): - status_updated = pyqtSignal(str) - - def __init__(self, plugin): - super().__init__() - self.plugin = plugin - self.processed_files = set() - - layout = QVBoxLayout(self) - form_layout = QFormLayout() - - self.previews_widget = QWidget() - previews_layout = QHBoxLayout(self.previews_widget) - previews_layout.setContentsMargins(0, 0, 0, 0) - - start_preview_layout = QVBoxLayout() - start_preview_layout.addWidget(QLabel("Start Frame")) - self.start_frame_preview = QLabel("N/A") - self.start_frame_preview.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.start_frame_preview.setFixedSize(160, 90) - self.start_frame_preview.setStyleSheet("background-color: #222; color: #888;") - start_preview_layout.addWidget(self.start_frame_preview) - previews_layout.addLayout(start_preview_layout) - - end_preview_layout = QVBoxLayout() - end_preview_layout.addWidget(QLabel("End Frame")) - self.end_frame_preview = QLabel("N/A") - self.end_frame_preview.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.end_frame_preview.setFixedSize(160, 90) - self.end_frame_preview.setStyleSheet("background-color: #222; color: #888;") - end_preview_layout.addWidget(self.end_frame_preview) - previews_layout.addLayout(end_preview_layout) - - self.start_frame_input = QLineEdit() - self.end_frame_input = QLineEdit() - self.duration_input = QLineEdit() - - self.autostart_checkbox = QCheckBox("Start Generation Immediately") - self.autostart_checkbox.setChecked(True) - - self.generate_button = QPushButton("Generate") - - layout.addWidget(self.previews_widget) - form_layout.addRow(self.autostart_checkbox) - form_layout.addRow(self.generate_button) - - layout.addLayout(form_layout) - - # --- Results Area --- - results_group = QGroupBox("Generated Clips (Hover to play, Click button to insert)") - results_layout = QVBoxLayout() - self.results_list = QListWidget() - self.results_list.setFlow(QListWidget.Flow.LeftToRight) - self.results_list.setWrapping(True) - self.results_list.setResizeMode(QListWidget.ResizeMode.Adjust) - self.results_list.setSpacing(10) - results_layout.addWidget(self.results_list) - results_group.setLayout(results_layout) - layout.addWidget(results_group) - - layout.addStretch() - - self.generate_button.clicked.connect(self.generate) - - self.poll_timer = QTimer(self) - self.poll_timer.setInterval(3000) - self.poll_timer.timeout.connect(self.poll_for_output) - - def set_previews(self, start_pixmap, end_pixmap): - if start_pixmap: - self.start_frame_preview.setPixmap(start_pixmap) - else: - self.start_frame_preview.setText("N/A") - self.start_frame_preview.setPixmap(QPixmap()) - - if end_pixmap: - self.end_frame_preview.setPixmap(end_pixmap) - else: - self.end_frame_preview.setText("N/A") - self.end_frame_preview.setPixmap(QPixmap()) - - def start_polling(self): - self.poll_timer.start() - - def stop_polling(self): - self.poll_timer.stop() - - def handle_api_error(self, response, action="performing action"): - try: - error_msg = response.json().get("error", "Unknown error") - except requests.exceptions.JSONDecodeError: - error_msg = response.text - self.status_updated.emit(f"AI Joiner Error: {error_msg}") - QMessageBox.warning(self, "API Error", f"Failed while {action}.\n\nServer response:\n{error_msg}") - - def check_server_status(self): - try: - requests.get(f"{API_BASE_URL}/api/outputs", timeout=1) - self.status_updated.emit("AI Joiner: Connected to WanGP server.") - return True - except requests.exceptions.ConnectionError: - self.status_updated.emit("AI Joiner Error: Cannot connect to WanGP server.") - QMessageBox.critical(self, "Connection Error", f"Could not connect to the WanGP API at {API_BASE_URL}.\n\nPlease ensure wgptool.py is running.") - return False - - def generate(self): - payload = {} - - model_type = self.model_type_to_use - if model_type: - payload['model_type'] = model_type - - if self.start_frame_input.text(): - payload['start_frame'] = self.start_frame_input.text() - if self.end_frame_input.text(): - payload['end_frame'] = self.end_frame_input.text() - - if self.duration_input.text(): - payload['duration_sec'] = self.duration_input.text() - - payload['start_generation'] = self.autostart_checkbox.isChecked() - - self.status_updated.emit("AI Joiner: Sending parameters...") - try: - response = requests.post(f"{API_BASE_URL}/api/generate", json=payload) - if response.status_code == 200: - if payload['start_generation']: - self.status_updated.emit("AI Joiner: Generation sent to server. Polling for output...") - else: - self.status_updated.emit("AI Joiner: Parameters set. Polling for manually started generation...") - self.start_polling() - else: - self.handle_api_error(response, "setting parameters") - except requests.exceptions.RequestException as e: - self.status_updated.emit(f"AI Joiner Error: Connection error: {e}") - - def poll_for_output(self): - try: - response = requests.get(f"{API_BASE_URL}/api/outputs") - if response.status_code == 200: - data = response.json() - output_files = data.get("outputs", []) - - new_files = set(output_files) - self.processed_files - if new_files: - self.status_updated.emit(f"AI Joiner: Received {len(new_files)} new clip(s).") - for file_path in sorted(list(new_files)): - self.add_result_item(file_path) - self.processed_files.add(file_path) - else: - self.status_updated.emit("AI Joiner: Polling for output...") - else: - self.status_updated.emit("AI Joiner: Polling for output...") - except requests.exceptions.RequestException: - self.status_updated.emit("AI Joiner: Polling... (Connection issue)") - - def add_result_item(self, video_path): - item_widget = VideoResultItemWidget(video_path, self.plugin) - list_item = QListWidgetItem(self.results_list) - list_item.setSizeHint(item_widget.sizeHint()) - self.results_list.addItem(list_item) - self.results_list.setItemWidget(list_item, item_widget) - - def clear_results(self): - self.results_list.clear() - self.processed_files.clear() - -class Plugin(VideoEditorPlugin): - def initialize(self): - self.name = "AI Frame Joiner" - self.description = "Uses a local AI server to generate a video between two frames." - self.client_widget = WgpClientWidget(self) - self.dock_widget = None - self.active_region = None - self.temp_dir = None - self.insert_on_new_track = False - self.client_widget.status_updated.connect(self.update_main_status) - - def enable(self): - if not self.dock_widget: - self.dock_widget = self.app.add_dock_widget(self, self.client_widget, "AI Frame Joiner", show_on_creation=False) - - self.dock_widget.hide() - - self.app.timeline_widget.context_menu_requested.connect(self.on_timeline_context_menu) - - def disable(self): - try: - self.app.timeline_widget.context_menu_requested.disconnect(self.on_timeline_context_menu) - except TypeError: - pass - self._cleanup_temp_dir() - self.client_widget.stop_polling() - - def update_main_status(self, message): - self.app.status_label.setText(message) - - def _cleanup_temp_dir(self): - if self.temp_dir and os.path.exists(self.temp_dir): - shutil.rmtree(self.temp_dir) - self.temp_dir = None - - def _reset_state(self): - self.active_region = None - self.insert_on_new_track = False - self.client_widget.stop_polling() - self.client_widget.clear_results() - self._cleanup_temp_dir() - self.update_main_status("AI Joiner: Idle") - self.client_widget.set_previews(None, None) - self.client_widget.model_type_to_use = None - - def on_timeline_context_menu(self, menu, event): - region = self.app.timeline_widget.get_region_at_pos(event.pos()) - if region: - menu.addSeparator() - - start_sec, end_sec = region - start_data, _, _ = self.app.get_frame_data_at_time(start_sec) - end_data, _, _ = self.app.get_frame_data_at_time(end_sec) - - if start_data and end_data: - join_action = menu.addAction("Join Frames With AI") - join_action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) - - join_action_new_track = menu.addAction("Join Frames With AI (New Track)") - join_action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) - - create_action = menu.addAction("Create Frames With AI") - create_action.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=False)) - - create_action_new_track = menu.addAction("Create Frames With AI (New Track)") - create_action_new_track.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=True)) - - def setup_generator_for_region(self, region, on_new_track=False): - self._reset_state() - self.client_widget.model_type_to_use = "i2v_2_2" - self.active_region = region - self.insert_on_new_track = on_new_track - self.client_widget.previews_widget.setVisible(True) - - start_sec, end_sec = region - if not self.client_widget.check_server_status(): - return - - start_data, w, h = self.app.get_frame_data_at_time(start_sec) - end_data, _, _ = self.app.get_frame_data_at_time(end_sec) - - if not start_data or not end_data: - QMessageBox.warning(self.app, "Frame Error", "Could not extract start and/or end frames for the selected region.") - return - - preview_size = QSize(160, 90) - start_pixmap, end_pixmap = None, None - - try: - start_img = QImage(start_data, w, h, QImage.Format.Format_RGB888) - start_pixmap = QPixmap.fromImage(start_img).scaled(preview_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - - end_img = QImage(end_data, w, h, QImage.Format.Format_RGB888) - end_pixmap = QPixmap.fromImage(end_img).scaled(preview_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - except Exception as e: - QMessageBox.critical(self.app, "Image Error", f"Could not create preview images: {e}") - - self.client_widget.set_previews(start_pixmap, end_pixmap) - - try: - self.temp_dir = tempfile.mkdtemp(prefix="ai_joiner_") - start_img_path = os.path.join(self.temp_dir, "start_frame.png") - end_img_path = os.path.join(self.temp_dir, "end_frame.png") - - QImage(start_data, w, h, QImage.Format.Format_RGB888).save(start_img_path) - QImage(end_data, w, h, QImage.Format.Format_RGB888).save(end_img_path) - except Exception as e: - QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame images: {e}") - self._cleanup_temp_dir() - return - - duration_sec = end_sec - start_sec - self.client_widget.duration_input.setText(str(duration_sec)) - - self.client_widget.start_frame_input.setText(start_img_path) - self.client_widget.end_frame_input.setText(end_img_path) - self.update_main_status(f"AI Joiner: Ready for region {start_sec:.2f}s - {end_sec:.2f}s") - - self.dock_widget.show() - self.dock_widget.raise_() - - def setup_creator_for_region(self, region, on_new_track=False): - self._reset_state() - self.client_widget.model_type_to_use = "t2v_2_2" - self.active_region = region - self.insert_on_new_track = on_new_track - self.client_widget.previews_widget.setVisible(False) - - start_sec, end_sec = region - if not self.client_widget.check_server_status(): - return - - self.client_widget.set_previews(None, None) - - duration_sec = end_sec - start_sec - self.client_widget.duration_input.setText(str(duration_sec)) - - self.client_widget.start_frame_input.clear() - self.client_widget.end_frame_input.clear() - - self.update_main_status(f"AI Creator: Ready for region {start_sec:.2f}s - {end_sec:.2f}s") - - self.dock_widget.show() - self.dock_widget.raise_() - - def insert_generated_clip(self, video_path): - from main import TimelineClip - - if not self.active_region: - self.update_main_status("AI Joiner Error: No active region to insert into.") - return - - if not os.path.exists(video_path): - self.update_main_status(f"AI Joiner Error: Output file not found: {video_path}") - return - - start_sec, end_sec = self.active_region - is_new_track_mode = self.insert_on_new_track - - self.update_main_status(f"AI Joiner: Inserting clip {os.path.basename(video_path)}") - - def complex_insertion_action(): - probe = ffmpeg.probe(video_path) - actual_duration = float(probe['format']['duration']) - - if is_new_track_mode: - self.app.timeline.num_video_tracks += 1 - new_track_index = self.app.timeline.num_video_tracks - - new_clip = TimelineClip( - source_path=video_path, - timeline_start_sec=start_sec, - clip_start_sec=0, - duration_sec=actual_duration, - track_index=new_track_index, - track_type='video', - media_type='video', - group_id=str(uuid.uuid4()) - ) - self.app.timeline.add_clip(new_clip) - else: - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) - - clips_to_remove = [ - c for c in self.app.timeline.clips - if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec - ] - for clip in clips_to_remove: - if clip in self.app.timeline.clips: - self.app.timeline.clips.remove(clip) - - new_clip = TimelineClip( - source_path=video_path, - timeline_start_sec=start_sec, - clip_start_sec=0, - duration_sec=actual_duration, - track_index=1, - track_type='video', - media_type='video', - group_id=str(uuid.uuid4()) - ) - self.app.timeline.add_clip(new_clip) - - try: - self.app._perform_complex_timeline_change("Insert AI Clip", complex_insertion_action) - - self.app.prune_empty_tracks() - self.update_main_status("AI clip inserted successfully.") - - for i in range(self.client_widget.results_list.count()): - item = self.client_widget.results_list.item(i) - widget = self.client_widget.results_list.itemWidget(item) - if widget and widget.video_path == video_path: - self.client_widget.results_list.takeItem(i) - break - - except Exception as e: - error_message = f"AI Joiner Error during clip insertion/probing: {e}" - self.update_main_status(error_message) - print(error_message) \ No newline at end of file From 4f9c03bf4d1546bfc0837f43bdf2dd4e321931e6 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 21:19:09 +1100 Subject: [PATCH 31/67] update screenshots --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3726bd212..8f30a9927 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,9 @@ python videoeditor.py ``` ## Screenshots -image -image -image -image +image +image +image ## Credits The AI Video Generator plugin is built from a desktop port of WAN2GP by DeepBeepMeep. From 1622655827b1f904ccaf104807013a68def53739 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 21:27:19 +1100 Subject: [PATCH 32/67] update install instructions --- README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f30a9927..15764d5d3 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,32 @@ A simple, non-linear video editor built with Python, PyQt6, and FFmpeg. It provi ## Installation + +#### **Windows** + ```bash git clone https://github.com/Tophness/Wan2GP.git cd Wan2GP git checkout video_editor -conda create -n wan2gp python=3.10.9 -conda activate wan2gp +python -m venv venv +venv\Scripts\activate +pip install torch==2.7.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/test/cu128 +pip install -r requirements.txt +``` + +#### **Linux / macOS** + +``` +git clone https://github.com/Tophness/Wan2GP.git +cd Wan2GP +git checkout video_editor +python3 -m venv venv +source venv/bin/activate pip install torch==2.7.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/test/cu128 pip install -r requirements.txt ``` + ## Usage **Run the video editor:** From 4aded7a158fab544fc66f607247e3211d468c333 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 22:15:37 +1100 Subject: [PATCH 33/67] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 15764d5d3..712fbfba4 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,11 @@ python videoeditor.py ## Screenshots image -image -image +image +image + ## Credits The AI Video Generator plugin is built from a desktop port of WAN2GP by DeepBeepMeep. See WAN2GP for more details. -https://github.com/deepbeepmeep/Wan2GP \ No newline at end of file +https://github.com/deepbeepmeep/Wan2GP From 8c9286cbc4b80ae9a167b1317ff30e69a339d6f4 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 22:19:14 +1100 Subject: [PATCH 34/67] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 712fbfba4..1fc0c3473 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,9 @@ python videoeditor.py ## Screenshots image -image -image +image +image + ## Credits From f7d62b539a9d0aa6f69dfc81ab72dbc246064647 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 22:22:45 +1100 Subject: [PATCH 35/67] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1fc0c3473..eff58e558 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ python videoeditor.py ``` ## Screenshots -image +image image image From 0953b56c0fc26a5aa737054139b517f2f591c568 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 23:31:21 +1100 Subject: [PATCH 36/67] Add create to new track option back --- plugins/wan2gp/main.py | 3417 +++++++++++++++++++++------------------- 1 file changed, 1764 insertions(+), 1653 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index ca201dcda..0b925227c 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -1,1653 +1,1764 @@ -import sys -import os -import threading -import time -import json -import tempfile -import shutil -import uuid -from unittest.mock import MagicMock -from pathlib import Path - -# --- Start of Gradio Hijacking --- -# This block creates a mock Gradio module. When wgp.py is imported, -# all calls to `gr.*` will be intercepted by these mock objects, -# preventing any UI from being built and allowing us to use the -# backend logic directly. - -class MockGradioComponent(MagicMock): - """A smarter mock that captures constructor arguments.""" - def __init__(self, *args, **kwargs): - super().__init__(name=f"gr.{kwargs.get('elem_id', 'component')}") - self.kwargs = kwargs - self.value = kwargs.get('value') - self.choices = kwargs.get('choices') - - for method in ['then', 'change', 'click', 'input', 'select', 'upload', 'mount', 'launch', 'on', 'release']: - setattr(self, method, lambda *a, **kw: self) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - pass - -class MockGradioError(Exception): - pass - -class MockGradioModule: - def __getattr__(self, name): - if name == 'Error': - return lambda *args, **kwargs: MockGradioError(*args) - - if name in ['Info', 'Warning']: - return lambda *args, **kwargs: print(f"Intercepted gr.{name}:", *args) - - return lambda *args, **kwargs: MockGradioComponent(*args, **kwargs) - -sys.modules['gradio'] = MockGradioModule() -sys.modules['gradio.gallery'] = MockGradioModule() -sys.modules['shared.gradio.gallery'] = MockGradioModule() -# --- End of Gradio Hijacking --- - -import ffmpeg - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, - QPushButton, QLabel, QLineEdit, QTextEdit, QSlider, QCheckBox, QComboBox, - QFileDialog, QGroupBox, QFormLayout, QTableWidget, QTableWidgetItem, - QHeaderView, QProgressBar, QScrollArea, QListWidget, QListWidgetItem, - QMessageBox, QRadioButton, QSizePolicy -) -from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QUrl, QSize, QRectF -from PyQt6.QtGui import QPixmap, QImage, QDropEvent -from PyQt6.QtMultimedia import QMediaPlayer -from PyQt6.QtMultimediaWidgets import QVideoWidget -from PIL.ImageQt import ImageQt - -# Import the base plugin class from the main application's path -sys.path.append(str(Path(__file__).parent.parent.parent)) -from plugins import VideoEditorPlugin - -class VideoResultItemWidget(QWidget): - """A widget to display a generated video with a hover-to-play preview and insert button.""" - def __init__(self, video_path, plugin, parent=None): - super().__init__(parent) - self.video_path = video_path - self.plugin = plugin - self.app = plugin.app - self.duration = 0.0 - self.has_audio = False - - self.setMinimumSize(200, 180) - self.setMaximumHeight(190) - - layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(5) - - self.media_player = QMediaPlayer() - self.video_widget = QVideoWidget() - self.video_widget.setFixedSize(160, 90) - self.media_player.setVideoOutput(self.video_widget) - self.media_player.setSource(QUrl.fromLocalFile(self.video_path)) - self.media_player.setLoops(QMediaPlayer.Loops.Infinite) - - self.info_label = QLabel(os.path.basename(video_path)) - self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.info_label.setWordWrap(True) - - self.insert_button = QPushButton("Insert into Timeline") - self.insert_button.clicked.connect(self.on_insert) - - h_layout = QHBoxLayout() - h_layout.addStretch() - h_layout.addWidget(self.video_widget) - h_layout.addStretch() - - layout.addLayout(h_layout) - layout.addWidget(self.info_label) - layout.addWidget(self.insert_button) - self.probe_video() - - def probe_video(self): - try: - probe = ffmpeg.probe(self.video_path) - self.duration = float(probe['format']['duration']) - self.has_audio = any(s['codec_type'] == 'audio' for s in probe.get('streams', [])) - self.info_label.setText(f"{os.path.basename(self.video_path)}\n({self.duration:.2f}s)") - except Exception as e: - self.info_label.setText(f"Error probing:\n{os.path.basename(self.video_path)}") - print(f"Error probing video {self.video_path}: {e}") - - def enterEvent(self, event): - super().enterEvent(event) - self.media_player.play() - if not self.plugin.active_region or self.duration == 0: return - start_sec, _ = self.plugin.active_region - timeline = self.app.timeline_widget - video_rect, audio_rect = None, None - x = timeline.sec_to_x(start_sec) - w = int(self.duration * timeline.pixels_per_second) - if self.plugin.insert_on_new_track: - video_y = timeline.TIMESCALE_HEIGHT - video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) - if self.has_audio: - audio_y = timeline.audio_tracks_y_start + self.app.timeline.num_audio_tracks * timeline.TRACK_HEIGHT - audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) - else: - v_track_idx = 1 - visual_v_idx = self.app.timeline.num_video_tracks - v_track_idx - video_y = timeline.video_tracks_y_start + visual_v_idx * timeline.TRACK_HEIGHT - video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) - if self.has_audio: - a_track_idx = 1 - visual_a_idx = a_track_idx - 1 - audio_y = timeline.audio_tracks_y_start + visual_a_idx * timeline.TRACK_HEIGHT - audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) - timeline.set_hover_preview_rects(video_rect, audio_rect) - - def leaveEvent(self, event): - super().leaveEvent(event) - self.media_player.pause() - self.media_player.setPosition(0) - self.app.timeline_widget.set_hover_preview_rects(None, None) - - def on_insert(self): - self.media_player.stop() - self.media_player.setSource(QUrl()) - self.media_player.setVideoOutput(None) - self.app.timeline_widget.set_hover_preview_rects(None, None) - self.plugin.insert_generated_clip(self.video_path) - - -class QueueTableWidget(QTableWidget): - rowsMoved = pyqtSignal(int, int) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setDragEnabled(True) - self.setAcceptDrops(True) - self.setDropIndicatorShown(True) - self.setDragDropMode(self.DragDropMode.InternalMove) - self.setSelectionBehavior(self.SelectionBehavior.SelectRows) - self.setSelectionMode(self.SelectionMode.SingleSelection) - - def dropEvent(self, event: QDropEvent): - if event.source() == self and event.dropAction() == Qt.DropAction.MoveAction: - source_row = self.currentRow() - target_item = self.itemAt(event.position().toPoint()) - dest_row = target_item.row() if target_item else self.rowCount() - if source_row < dest_row: dest_row -=1 - if source_row != dest_row: self.rowsMoved.emit(source_row, dest_row) - event.acceptProposedAction() - else: - super().dropEvent(event) - - -class Worker(QObject): - progress = pyqtSignal(list) - status = pyqtSignal(str) - preview = pyqtSignal(object) - output = pyqtSignal() - finished = pyqtSignal() - error = pyqtSignal(str) - def __init__(self, plugin, state): - super().__init__() - self.plugin = plugin - self.wgp = plugin.wgp - self.state = state - self._is_running = True - self._last_progress_phase = None - self._last_preview = None - - def run(self): - def generation_target(): - try: - for _ in self.wgp.process_tasks(self.state): - if self._is_running: self.output.emit() - else: break - except Exception as e: - import traceback - print("Error in generation thread:") - traceback.print_exc() - if "gradio.Error" in str(type(e)): self.error.emit(str(e)) - else: self.error.emit(f"An unexpected error occurred: {e}") - finally: - self._is_running = False - gen_thread = threading.Thread(target=generation_target, daemon=True) - gen_thread.start() - while self._is_running: - gen = self.state.get('gen', {}) - current_phase = gen.get("progress_phase") - if current_phase and current_phase != self._last_progress_phase: - self._last_progress_phase = current_phase - phase_name, step = current_phase - total_steps = gen.get("num_inference_steps", 1) - high_level_status = gen.get("progress_status", "") - status_msg = self.wgp.merge_status_context(high_level_status, phase_name) - progress_args = [(step, total_steps), status_msg] - self.progress.emit(progress_args) - preview_img = gen.get('preview') - if preview_img is not None and preview_img is not self._last_preview: - self._last_preview = preview_img - self.preview.emit(preview_img) - gen['preview'] = None - time.sleep(0.1) - gen_thread.join() - self.finished.emit() - - -class WgpDesktopPluginWidget(QWidget): - def __init__(self, plugin): - super().__init__() - self.plugin = plugin - self.wgp = plugin.wgp - self.widgets = {} - self.state = {} - self.worker = None - self.thread = None - self.lora_map = {} - self.full_resolution_choices = [] - self.main_config = {} - self.processed_files = set() - - self.load_main_config() - self.setup_ui() - self.apply_initial_config() - self.connect_signals() - self.init_wgp_state() - - def setup_ui(self): - main_layout = QVBoxLayout(self) - self.header_info = QLabel("Header Info") - main_layout.addWidget(self.header_info) - self.tabs = QTabWidget() - main_layout.addWidget(self.tabs) - self.setup_generator_tab() - self.setup_config_tab() - - def create_widget(self, widget_class, name, *args, **kwargs): - widget = widget_class(*args, **kwargs) - self.widgets[name] = widget - return widget - - def _create_slider_with_label(self, name, min_val, max_val, initial_val, scale=1.0, precision=1): - container = QWidget() - hbox = QHBoxLayout(container) - hbox.setContentsMargins(0, 0, 0, 0) - slider = self.create_widget(QSlider, name, Qt.Orientation.Horizontal) - slider.setRange(min_val, max_val) - slider.setValue(int(initial_val * scale)) - value_label = self.create_widget(QLabel, f"{name}_label", f"{initial_val:.{precision}f}") - value_label.setMinimumWidth(50) - slider.valueChanged.connect(lambda v, lbl=value_label, s=scale, p=precision: lbl.setText(f"{v/s:.{p}f}")) - hbox.addWidget(slider) - hbox.addWidget(value_label) - return container - - def _create_file_input(self, name, label_text): - container = self.create_widget(QWidget, f"{name}_container") - hbox = QHBoxLayout(container) - hbox.setContentsMargins(0, 0, 0, 0) - line_edit = self.create_widget(QLineEdit, name) - line_edit.setPlaceholderText("No file selected or path pasted") - button = QPushButton("Browse...") - def open_dialog(): - if "refs" in name: - filenames, _ = QFileDialog.getOpenFileNames(self, f"Select {label_text}") - if filenames: line_edit.setText(";".join(filenames)) - else: - filename, _ = QFileDialog.getOpenFileName(self, f"Select {label_text}") - if filename: line_edit.setText(filename) - button.clicked.connect(open_dialog) - clear_button = QPushButton("X") - clear_button.setFixedWidth(30) - clear_button.clicked.connect(lambda: line_edit.clear()) - hbox.addWidget(QLabel(f"{label_text}:")) - hbox.addWidget(line_edit, 1) - hbox.addWidget(button) - hbox.addWidget(clear_button) - return container - - def setup_generator_tab(self): - gen_tab = QWidget() - self.tabs.addTab(gen_tab, "Video Generator") - gen_layout = QHBoxLayout(gen_tab) - left_panel = QWidget() - left_layout = QVBoxLayout(left_panel) - gen_layout.addWidget(left_panel, 1) - right_panel = QWidget() - right_layout = QVBoxLayout(right_panel) - gen_layout.addWidget(right_panel, 1) - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - left_layout.addWidget(scroll_area) - options_widget = QWidget() - scroll_area.setWidget(options_widget) - options_layout = QVBoxLayout(options_widget) - model_layout = QHBoxLayout() - self.widgets['model_family'] = QComboBox() - self.widgets['model_base_type_choice'] = QComboBox() - self.widgets['model_choice'] = QComboBox() - model_layout.addWidget(QLabel("Model:")) - model_layout.addWidget(self.widgets['model_family'], 2) - model_layout.addWidget(self.widgets['model_base_type_choice'], 3) - model_layout.addWidget(self.widgets['model_choice'], 3) - options_layout.addLayout(model_layout) - options_layout.addWidget(QLabel("Prompt:")) - self.create_widget(QTextEdit, 'prompt').setMinimumHeight(100) - options_layout.addWidget(self.widgets['prompt']) - options_layout.addWidget(QLabel("Negative Prompt:")) - self.create_widget(QTextEdit, 'negative_prompt').setMinimumHeight(60) - options_layout.addWidget(self.widgets['negative_prompt']) - basic_group = QGroupBox("Basic Options") - basic_layout = QFormLayout(basic_group) - res_container = QWidget() - res_hbox = QHBoxLayout(res_container) - res_hbox.setContentsMargins(0, 0, 0, 0) - res_hbox.addWidget(self.create_widget(QComboBox, 'resolution_group'), 2) - res_hbox.addWidget(self.create_widget(QComboBox, 'resolution'), 3) - basic_layout.addRow("Resolution:", res_container) - basic_layout.addRow("Video Length:", self._create_slider_with_label('video_length', 1, 737, 81, 1.0, 0)) - basic_layout.addRow("Inference Steps:", self._create_slider_with_label('num_inference_steps', 1, 100, 30, 1.0, 0)) - basic_layout.addRow("Seed:", self.create_widget(QLineEdit, 'seed', '-1')) - options_layout.addWidget(basic_group) - mode_options_group = QGroupBox("Generation Mode & Input Options") - mode_options_layout = QVBoxLayout(mode_options_group) - mode_hbox = QHBoxLayout() - mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_t', "Text Prompt Only")) - mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_s', "Start with Image")) - mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_v', "Continue Video")) - mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_l', "Continue Last Video")) - self.widgets['mode_t'].setChecked(True) - mode_options_layout.addLayout(mode_hbox) - options_hbox = QHBoxLayout() - options_hbox.addWidget(self.create_widget(QCheckBox, 'image_end_checkbox', "Use End Image")) - options_hbox.addWidget(self.create_widget(QCheckBox, 'control_video_checkbox', "Use Control Video")) - options_hbox.addWidget(self.create_widget(QCheckBox, 'ref_image_checkbox', "Use Reference Image(s)")) - mode_options_layout.addLayout(options_hbox) - options_layout.addWidget(mode_options_group) - inputs_group = QGroupBox("Inputs") - inputs_layout = QVBoxLayout(inputs_group) - inputs_layout.addWidget(self._create_file_input('image_start', "Start Image")) - inputs_layout.addWidget(self._create_file_input('image_end', "End Image")) - inputs_layout.addWidget(self._create_file_input('video_source', "Source Video")) - inputs_layout.addWidget(self._create_file_input('video_guide', "Control Video")) - inputs_layout.addWidget(self._create_file_input('video_mask', "Video Mask")) - inputs_layout.addWidget(self._create_file_input('image_refs', "Reference Image(s)")) - denoising_row = QFormLayout() - denoising_row.addRow("Denoising Strength:", self._create_slider_with_label('denoising_strength', 0, 100, 50, 100.0, 2)) - inputs_layout.addLayout(denoising_row) - options_layout.addWidget(inputs_group) - self.advanced_group = self.create_widget(QGroupBox, 'advanced_group', "Advanced Options") - self.advanced_group.setCheckable(True) - self.advanced_group.setChecked(False) - advanced_layout = QVBoxLayout(self.advanced_group) - advanced_tabs = self.create_widget(QTabWidget, 'advanced_tabs') - advanced_layout.addWidget(advanced_tabs) - self._setup_adv_tab_general(advanced_tabs) - self._setup_adv_tab_loras(advanced_tabs) - self._setup_adv_tab_speed(advanced_tabs) - self._setup_adv_tab_postproc(advanced_tabs) - self._setup_adv_tab_audio(advanced_tabs) - self._setup_adv_tab_quality(advanced_tabs) - self._setup_adv_tab_sliding_window(advanced_tabs) - self._setup_adv_tab_misc(advanced_tabs) - options_layout.addWidget(self.advanced_group) - - btn_layout = QHBoxLayout() - self.generate_btn = self.create_widget(QPushButton, 'generate_btn', "Generate") - self.add_to_queue_btn = self.create_widget(QPushButton, 'add_to_queue_btn', "Add to Queue") - self.generate_btn.setEnabled(True) - self.add_to_queue_btn.setEnabled(False) - btn_layout.addWidget(self.generate_btn) - btn_layout.addWidget(self.add_to_queue_btn) - right_layout.addLayout(btn_layout) - self.status_label = self.create_widget(QLabel, 'status_label', "Idle") - right_layout.addWidget(self.status_label) - self.progress_bar = self.create_widget(QProgressBar, 'progress_bar') - right_layout.addWidget(self.progress_bar) - preview_group = self.create_widget(QGroupBox, 'preview_group', "Preview") - preview_group.setCheckable(True) - preview_group.setStyleSheet("QGroupBox { border: 1px solid #cccccc; }") - preview_group_layout = QVBoxLayout(preview_group) - self.preview_image = self.create_widget(QLabel, 'preview_image', "") - self.preview_image.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.preview_image.setMinimumSize(200, 200) - preview_group_layout.addWidget(self.preview_image) - right_layout.addWidget(preview_group) - - results_group = QGroupBox("Generated Clips") - results_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - results_layout = QVBoxLayout(results_group) - self.results_list = self.create_widget(QListWidget, 'results_list') - self.results_list.setFlow(QListWidget.Flow.LeftToRight) - self.results_list.setWrapping(True) - self.results_list.setResizeMode(QListWidget.ResizeMode.Adjust) - self.results_list.setSpacing(10) - results_layout.addWidget(self.results_list) - right_layout.addWidget(results_group) - - right_layout.addWidget(QLabel("Queue:")) - self.queue_table = self.create_widget(QueueTableWidget, 'queue_table') - right_layout.addWidget(self.queue_table) - queue_btn_layout = QHBoxLayout() - self.remove_queue_btn = self.create_widget(QPushButton, 'remove_queue_btn', "Remove Selected") - self.clear_queue_btn = self.create_widget(QPushButton, 'clear_queue_btn', "Clear Queue") - self.abort_btn = self.create_widget(QPushButton, 'abort_btn', "Abort") - queue_btn_layout.addWidget(self.remove_queue_btn) - queue_btn_layout.addWidget(self.clear_queue_btn) - queue_btn_layout.addWidget(self.abort_btn) - right_layout.addLayout(queue_btn_layout) - - def _setup_adv_tab_general(self, tabs): - tab = QWidget() - tabs.addTab(tab, "General") - layout = QFormLayout(tab) - self.widgets['adv_general_layout'] = layout - guidance_group = QGroupBox("Guidance") - guidance_layout = self.create_widget(QFormLayout, 'guidance_layout', guidance_group) - guidance_layout.addRow("Guidance (CFG):", self._create_slider_with_label('guidance_scale', 10, 200, 5.0, 10.0, 1)) - self.widgets['guidance_phases_row_index'] = guidance_layout.rowCount() - guidance_layout.addRow("Guidance Phases:", self.create_widget(QComboBox, 'guidance_phases')) - self.widgets['guidance2_row_index'] = guidance_layout.rowCount() - guidance_layout.addRow("Guidance 2:", self._create_slider_with_label('guidance2_scale', 10, 200, 5.0, 10.0, 1)) - self.widgets['guidance3_row_index'] = guidance_layout.rowCount() - guidance_layout.addRow("Guidance 3:", self._create_slider_with_label('guidance3_scale', 10, 200, 5.0, 10.0, 1)) - self.widgets['switch_thresh_row_index'] = guidance_layout.rowCount() - guidance_layout.addRow("Switch Threshold:", self._create_slider_with_label('switch_threshold', 0, 1000, 0, 1.0, 0)) - layout.addRow(guidance_group) - nag_group = self.create_widget(QGroupBox, 'nag_group', "NAG (Negative Adversarial Guidance)") - nag_layout = QFormLayout(nag_group) - nag_layout.addRow("NAG Scale:", self._create_slider_with_label('NAG_scale', 10, 200, 1.0, 10.0, 1)) - nag_layout.addRow("NAG Tau:", self._create_slider_with_label('NAG_tau', 10, 50, 3.5, 10.0, 1)) - nag_layout.addRow("NAG Alpha:", self._create_slider_with_label('NAG_alpha', 0, 20, 0.5, 10.0, 1)) - layout.addRow(nag_group) - self.widgets['solver_row_container'] = QWidget() - solver_hbox = QHBoxLayout(self.widgets['solver_row_container']) - solver_hbox.setContentsMargins(0,0,0,0) - solver_hbox.addWidget(QLabel("Sampler Solver:")) - solver_hbox.addWidget(self.create_widget(QComboBox, 'sample_solver')) - layout.addRow(self.widgets['solver_row_container']) - self.widgets['flow_shift_row_index'] = layout.rowCount() - layout.addRow("Shift Scale:", self._create_slider_with_label('flow_shift', 10, 250, 3.0, 10.0, 1)) - self.widgets['audio_guidance_row_index'] = layout.rowCount() - layout.addRow("Audio Guidance:", self._create_slider_with_label('audio_guidance_scale', 10, 200, 4.0, 10.0, 1)) - self.widgets['repeat_generation_row_index'] = layout.rowCount() - layout.addRow("Repeat Generations:", self._create_slider_with_label('repeat_generation', 1, 25, 1, 1.0, 0)) - combo = self.create_widget(QComboBox, 'multi_images_gen_type') - combo.addItem("Generate all combinations", 0) - combo.addItem("Match images and texts", 1) - self.widgets['multi_images_gen_type_row_index'] = layout.rowCount() - layout.addRow("Multi-Image Mode:", combo) - - def _setup_adv_tab_loras(self, tabs): - tab = QWidget() - tabs.addTab(tab, "Loras") - layout = QVBoxLayout(tab) - layout.addWidget(QLabel("Available Loras (Ctrl+Click to select multiple):")) - lora_list = self.create_widget(QListWidget, 'activated_loras') - lora_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection) - layout.addWidget(lora_list) - layout.addWidget(QLabel("Loras Multipliers:")) - layout.addWidget(self.create_widget(QTextEdit, 'loras_multipliers')) - - def _setup_adv_tab_speed(self, tabs): - tab = QWidget() - tabs.addTab(tab, "Speed") - layout = QFormLayout(tab) - combo = self.create_widget(QComboBox, 'skip_steps_cache_type') - combo.addItem("None", "") - combo.addItem("Tea Cache", "tea") - combo.addItem("Mag Cache", "mag") - layout.addRow("Cache Type:", combo) - combo = self.create_widget(QComboBox, 'skip_steps_multiplier') - combo.addItem("x1.5 speed up", 1.5) - combo.addItem("x1.75 speed up", 1.75) - combo.addItem("x2.0 speed up", 2.0) - combo.addItem("x2.25 speed up", 2.25) - combo.addItem("x2.5 speed up", 2.5) - layout.addRow("Acceleration:", combo) - layout.addRow("Start %:", self._create_slider_with_label('skip_steps_start_step_perc', 0, 100, 0, 1.0, 0)) - - def _setup_adv_tab_postproc(self, tabs): - tab = QWidget() - tabs.addTab(tab, "Post-Processing") - layout = QFormLayout(tab) - combo = self.create_widget(QComboBox, 'temporal_upsampling') - combo.addItem("Disabled", "") - combo.addItem("Rife x2 frames/s", "rife2") - combo.addItem("Rife x4 frames/s", "rife4") - layout.addRow("Temporal Upsampling:", combo) - combo = self.create_widget(QComboBox, 'spatial_upsampling') - combo.addItem("Disabled", "") - combo.addItem("Lanczos x1.5", "lanczos1.5") - combo.addItem("Lanczos x2.0", "lanczos2") - layout.addRow("Spatial Upsampling:", combo) - layout.addRow("Film Grain Intensity:", self._create_slider_with_label('film_grain_intensity', 0, 100, 0, 100.0, 2)) - layout.addRow("Film Grain Saturation:", self._create_slider_with_label('film_grain_saturation', 0, 100, 0.5, 100.0, 2)) - - def _setup_adv_tab_audio(self, tabs): - tab = QWidget() - tabs.addTab(tab, "Audio") - layout = QFormLayout(tab) - combo = self.create_widget(QComboBox, 'MMAudio_setting') - combo.addItem("Disabled", 0) - combo.addItem("Enabled", 1) - layout.addRow("MMAudio:", combo) - layout.addWidget(self.create_widget(QLineEdit, 'MMAudio_prompt', placeholderText="MMAudio Prompt")) - layout.addWidget(self.create_widget(QLineEdit, 'MMAudio_neg_prompt', placeholderText="MMAudio Negative Prompt")) - layout.addRow(self._create_file_input('audio_source', "Custom Soundtrack")) - - def _setup_adv_tab_quality(self, tabs): - tab = QWidget() - tabs.addTab(tab, "Quality") - layout = QVBoxLayout(tab) - slg_group = self.create_widget(QGroupBox, 'slg_group', "Skip Layer Guidance") - slg_layout = QFormLayout(slg_group) - slg_combo = self.create_widget(QComboBox, 'slg_switch') - slg_combo.addItem("OFF", 0) - slg_combo.addItem("ON", 1) - slg_layout.addRow("Enable SLG:", slg_combo) - slg_layout.addRow("Start %:", self._create_slider_with_label('slg_start_perc', 0, 100, 10, 1.0, 0)) - slg_layout.addRow("End %:", self._create_slider_with_label('slg_end_perc', 0, 100, 90, 1.0, 0)) - layout.addWidget(slg_group) - quality_form = QFormLayout() - self.widgets['quality_form_layout'] = quality_form - apg_combo = self.create_widget(QComboBox, 'apg_switch') - apg_combo.addItem("OFF", 0) - apg_combo.addItem("ON", 1) - self.widgets['apg_switch_row_index'] = quality_form.rowCount() - quality_form.addRow("Adaptive Projected Guidance:", apg_combo) - cfg_star_combo = self.create_widget(QComboBox, 'cfg_star_switch') - cfg_star_combo.addItem("OFF", 0) - cfg_star_combo.addItem("ON", 1) - self.widgets['cfg_star_switch_row_index'] = quality_form.rowCount() - quality_form.addRow("Classifier-Free Guidance Star:", cfg_star_combo) - self.widgets['cfg_zero_step_row_index'] = quality_form.rowCount() - quality_form.addRow("CFG Zero below Layer:", self._create_slider_with_label('cfg_zero_step', -1, 39, -1, 1.0, 0)) - combo = self.create_widget(QComboBox, 'min_frames_if_references') - combo.addItem("Disabled (1 frame)", 1) - combo.addItem("Generate 5 frames", 5) - combo.addItem("Generate 9 frames", 9) - combo.addItem("Generate 13 frames", 13) - combo.addItem("Generate 17 frames", 17) - self.widgets['min_frames_if_references_row_index'] = quality_form.rowCount() - quality_form.addRow("Min Frames for Quality:", combo) - layout.addLayout(quality_form) - - def _setup_adv_tab_sliding_window(self, tabs): - tab = QWidget() - self.widgets['sliding_window_tab_index'] = tabs.count() - tabs.addTab(tab, "Sliding Window") - layout = QFormLayout(tab) - layout.addRow("Window Size:", self._create_slider_with_label('sliding_window_size', 5, 257, 129, 1.0, 0)) - layout.addRow("Overlap:", self._create_slider_with_label('sliding_window_overlap', 1, 97, 5, 1.0, 0)) - layout.addRow("Color Correction:", self._create_slider_with_label('sliding_window_color_correction_strength', 0, 100, 0, 100.0, 2)) - layout.addRow("Overlap Noise:", self._create_slider_with_label('sliding_window_overlap_noise', 0, 150, 20, 1.0, 0)) - layout.addRow("Discard Last Frames:", self._create_slider_with_label('sliding_window_discard_last_frames', 0, 20, 0, 1.0, 0)) - - def _setup_adv_tab_misc(self, tabs): - tab = QWidget() - tabs.addTab(tab, "Misc") - layout = QFormLayout(tab) - self.widgets['misc_layout'] = layout - riflex_combo = self.create_widget(QComboBox, 'RIFLEx_setting') - riflex_combo.addItem("Auto", 0) - riflex_combo.addItem("Always ON", 1) - riflex_combo.addItem("Always OFF", 2) - self.widgets['riflex_row_index'] = layout.rowCount() - layout.addRow("RIFLEx Setting:", riflex_combo) - fps_combo = self.create_widget(QComboBox, 'force_fps') - layout.addRow("Force FPS:", fps_combo) - profile_combo = self.create_widget(QComboBox, 'override_profile') - profile_combo.addItem("Default Profile", -1) - for text, val in self.wgp.memory_profile_choices: profile_combo.addItem(text.split(':')[0], val) - layout.addRow("Override Memory Profile:", profile_combo) - combo = self.create_widget(QComboBox, 'multi_prompts_gen_type') - combo.addItem("Generate new Video per line", 0) - combo.addItem("Use line for new Sliding Window", 1) - layout.addRow("Multi-Prompt Mode:", combo) - - def setup_config_tab(self): - config_tab = QWidget() - self.tabs.addTab(config_tab, "Configuration") - main_layout = QVBoxLayout(config_tab) - self.config_status_label = QLabel("Apply changes for them to take effect. Some may require a restart.") - main_layout.addWidget(self.config_status_label) - config_tabs = QTabWidget() - main_layout.addWidget(config_tabs) - config_tabs.addTab(self._create_general_config_tab(), "General") - config_tabs.addTab(self._create_performance_config_tab(), "Performance") - config_tabs.addTab(self._create_extensions_config_tab(), "Extensions") - config_tabs.addTab(self._create_outputs_config_tab(), "Outputs") - config_tabs.addTab(self._create_notifications_config_tab(), "Notifications") - self.apply_config_btn = QPushButton("Apply Changes") - self.apply_config_btn.clicked.connect(self._on_apply_config_changes) - main_layout.addWidget(self.apply_config_btn) - - def _create_scrollable_form_tab(self): - tab_widget = QWidget() - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - layout = QVBoxLayout(tab_widget) - layout.addWidget(scroll_area) - content_widget = QWidget() - form_layout = QFormLayout(content_widget) - scroll_area.setWidget(content_widget) - return tab_widget, form_layout - - def _create_config_combo(self, form_layout, label, key, choices, default_value): - combo = QComboBox() - for text, data in choices: combo.addItem(text, data) - index = combo.findData(self.wgp.server_config.get(key, default_value)) - if index != -1: combo.setCurrentIndex(index) - self.widgets[f'config_{key}'] = combo - form_layout.addRow(label, combo) - - def _create_config_slider(self, form_layout, label, key, min_val, max_val, default_value, step=1): - container = QWidget() - hbox = QHBoxLayout(container) - hbox.setContentsMargins(0,0,0,0) - slider = QSlider(Qt.Orientation.Horizontal) - slider.setRange(min_val, max_val) - slider.setSingleStep(step) - slider.setValue(self.wgp.server_config.get(key, default_value)) - value_label = QLabel(str(slider.value())) - value_label.setMinimumWidth(40) - slider.valueChanged.connect(lambda v, lbl=value_label: lbl.setText(str(v))) - hbox.addWidget(slider) - hbox.addWidget(value_label) - self.widgets[f'config_{key}'] = slider - form_layout.addRow(label, container) - - def _create_config_checklist(self, form_layout, label, key, choices, default_value): - list_widget = QListWidget() - list_widget.setMinimumHeight(100) - current_values = self.wgp.server_config.get(key, default_value) - for text, data in choices: - item = QListWidgetItem(text) - item.setData(Qt.ItemDataRole.UserRole, data) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - item.setCheckState(Qt.CheckState.Checked if data in current_values else Qt.CheckState.Unchecked) - list_widget.addItem(item) - self.widgets[f'config_{key}'] = list_widget - form_layout.addRow(label, list_widget) - - def _create_config_textbox(self, form_layout, label, key, default_value, multi_line=False): - if multi_line: - textbox = QTextEdit(default_value) - textbox.setAcceptRichText(False) - else: - textbox = QLineEdit(default_value) - self.widgets[f'config_{key}'] = textbox - form_layout.addRow(label, textbox) - - def _create_general_config_tab(self): - tab, form = self._create_scrollable_form_tab() - _, _, dropdown_choices = self.wgp.get_sorted_dropdown(self.wgp.displayed_model_types, None, None, False) - self._create_config_checklist(form, "Selectable Models:", "transformer_types", dropdown_choices, self.wgp.transformer_types) - self._create_config_combo(form, "Model Hierarchy:", "model_hierarchy_type", [("Two Levels (Family > Model)", 0), ("Three Levels (Family > Base > Finetune)", 1)], 1) - self._create_config_combo(form, "Video Dimensions:", "fit_canvas", [("Dimensions are Pixels Budget", 0), ("Dimensions are Max Width/Height", 1), ("Dimensions are Output Width/Height (Cropped)", 2)], 0) - self._create_config_combo(form, "Attention Type:", "attention_mode", [("Auto (Recommended)", "auto"), ("SDPA", "sdpa"), ("Flash", "flash"), ("Xformers", "xformers"), ("Sage", "sage"), ("Sage2/2++", "sage2")], "auto") - self._create_config_combo(form, "Metadata Handling:", "metadata_type", [("Embed in file (Exif/Comment)", "metadata"), ("Export separate JSON", "json"), ("None", "none")], "metadata") - self._create_config_checklist(form, "RAM Loading Policy:", "preload_model_policy", [("Preload on App Launch", "P"), ("Preload on Model Switch", "S"), ("Unload when Queue is Done", "U")], []) - self._create_config_combo(form, "Keep Previous Videos:", "clear_file_list", [("None", 0), ("Keep last video", 1), ("Keep last 5", 5), ("Keep last 10", 10), ("Keep last 20", 20), ("Keep last 30", 30)], 5) - self._create_config_combo(form, "Display RAM/VRAM Stats:", "display_stats", [("Disabled", 0), ("Enabled", 1)], 0) - self._create_config_combo(form, "Max Frames Multiplier:", "max_frames_multiplier", [(f"x{i}", i) for i in range(1, 8)], 1) - checkpoints_paths_text = "\n".join(self.wgp.server_config.get("checkpoints_paths", self.wgp.fl.default_checkpoints_paths)) - checkpoints_textbox = QTextEdit() - checkpoints_textbox.setPlainText(checkpoints_paths_text) - checkpoints_textbox.setAcceptRichText(False) - checkpoints_textbox.setMinimumHeight(60) - self.widgets['config_checkpoints_paths'] = checkpoints_textbox - form.addRow("Checkpoints Paths:", checkpoints_textbox) - self._create_config_combo(form, "UI Theme (requires restart):", "UI_theme", [("Blue Sky", "default"), ("Classic Gradio", "gradio")], "default") - return tab - - def _create_performance_config_tab(self): - tab, form = self._create_scrollable_form_tab() - self._create_config_combo(form, "Transformer Quantization:", "transformer_quantization", [("Scaled Int8 (recommended)", "int8"), ("16-bit (no quantization)", "bf16")], "int8") - self._create_config_combo(form, "Transformer Data Type:", "transformer_dtype_policy", [("Best Supported by Hardware", ""), ("FP16", "fp16"), ("BF16", "bf16")], "") - self._create_config_combo(form, "Transformer Calculation:", "mixed_precision", [("16-bit only", "0"), ("Mixed 16/32-bit (better quality)", "1")], "0") - self._create_config_combo(form, "Text Encoder:", "text_encoder_quantization", [("16-bit (more RAM, better quality)", "bf16"), ("8-bit (less RAM)", "int8")], "int8") - self._create_config_combo(form, "VAE Precision:", "vae_precision", [("16-bit (faster, less VRAM)", "16"), ("32-bit (slower, better quality)", "32")], "16") - self._create_config_combo(form, "Compile Transformer:", "compile", [("On (requires Triton)", "transformer"), ("Off", "")], "") - self._create_config_combo(form, "DepthAnything v2 Variant:", "depth_anything_v2_variant", [("Large (more precise)", "vitl"), ("Big (faster)", "vitb")], "vitl") - self._create_config_combo(form, "VAE Tiling:", "vae_config", [("Auto", 0), ("Disabled", 1), ("256x256 (~8GB VRAM)", 2), ("128x128 (~6GB VRAM)", 3)], 0) - self._create_config_combo(form, "Boost:", "boost", [("On", 1), ("Off", 2)], 1) - self._create_config_combo(form, "Memory Profile:", "profile", self.wgp.memory_profile_choices, self.wgp.profile_type.LowRAM_LowVRAM) - self._create_config_slider(form, "Preload in VRAM (MB):", "preload_in_VRAM", 0, 40000, 0, 100) - release_ram_btn = QPushButton("Force Release Models from RAM") - release_ram_btn.clicked.connect(self._on_release_ram) - form.addRow(release_ram_btn) - return tab - - def _create_extensions_config_tab(self): - tab, form = self._create_scrollable_form_tab() - self._create_config_combo(form, "Prompt Enhancer:", "enhancer_enabled", [("Off", 0), ("Florence 2 + Llama 3.2", 1), ("Florence 2 + Joy Caption (uncensored)", 2)], 0) - self._create_config_combo(form, "Enhancer Mode:", "enhancer_mode", [("Automatic on Generate", 0), ("On Demand Only", 1)], 0) - self._create_config_combo(form, "MMAudio:", "mmaudio_enabled", [("Off", 0), ("Enabled (unloaded after use)", 1), ("Enabled (persistent in RAM)", 2)], 0) - return tab - - def _create_outputs_config_tab(self): - tab, form = self._create_scrollable_form_tab() - self._create_config_combo(form, "Video Codec:", "video_output_codec", [("x265 Balanced", 'libx265_28'), ("x264 Balanced", 'libx264_8'), ("x265 High Quality", 'libx265_8'), ("x264 High Quality", 'libx264_10'), ("x264 Lossless", 'libx264_lossless')], 'libx264_8') - self._create_config_combo(form, "Image Codec:", "image_output_codec", [("JPEG Q85", 'jpeg_85'), ("WEBP Q85", 'webp_85'), ("JPEG Q95", 'jpeg_95'), ("WEBP Q95", 'webp_95'), ("WEBP Lossless", 'webp_lossless'), ("PNG Lossless", 'png')], 'jpeg_95') - self._create_config_textbox(form, "Video Output Folder:", "save_path", "outputs") - self._create_config_textbox(form, "Image Output Folder:", "image_save_path", "outputs") - return tab - - def _create_notifications_config_tab(self): - tab, form = self._create_scrollable_form_tab() - self._create_config_combo(form, "Notification Sound:", "notification_sound_enabled", [("On", 1), ("Off", 0)], 0) - self._create_config_slider(form, "Sound Volume:", "notification_sound_volume", 0, 100, 50, 5) - return tab - - def init_wgp_state(self): - wgp = self.wgp - initial_model = wgp.server_config.get("last_model_type", wgp.transformer_type) - dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types - _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) - all_model_ids = [m[1] for m in all_models] - if initial_model not in all_model_ids: initial_model = wgp.transformer_type - state_dict = {} - state_dict["model_filename"] = wgp.get_model_filename(initial_model, wgp.transformer_quantization, wgp.transformer_dtype_policy) - state_dict["model_type"] = initial_model - state_dict["advanced"] = wgp.advanced - state_dict["last_model_per_family"] = wgp.server_config.get("last_model_per_family", {}) - state_dict["last_model_per_type"] = wgp.server_config.get("last_model_per_type", {}) - state_dict["last_resolution_per_group"] = wgp.server_config.get("last_resolution_per_group", {}) - state_dict["gen"] = {"queue": []} - self.state = state_dict - self.advanced_group.setChecked(wgp.advanced) - self.update_model_dropdowns(initial_model) - self.refresh_ui_from_model_change(initial_model) - self._update_input_visibility() - - def update_model_dropdowns(self, current_model_type): - wgp = self.wgp - family_mock, base_type_mock, choice_mock = wgp.generate_dropdown_model_list(current_model_type) - for combo_name, mock in [('model_family', family_mock), ('model_base_type_choice', base_type_mock), ('model_choice', choice_mock)]: - combo = self.widgets[combo_name] - combo.blockSignals(True) - combo.clear() - if mock.choices: - for display_name, internal_key in mock.choices: combo.addItem(display_name, internal_key) - index = combo.findData(mock.value) - if index != -1: combo.setCurrentIndex(index) - - is_visible = True - if hasattr(mock, 'kwargs') and isinstance(mock.kwargs, dict): - is_visible = mock.kwargs.get('visible', True) - elif hasattr(mock, 'visible'): - is_visible = mock.visible - combo.setVisible(is_visible) - - combo.blockSignals(False) - - def refresh_ui_from_model_change(self, model_type): - """Update UI controls with default settings when the model is changed.""" - wgp = self.wgp - self.header_info.setText(wgp.generate_header(model_type, wgp.compile, wgp.attention_mode)) - ui_defaults = wgp.get_default_settings(model_type) - wgp.set_model_settings(self.state, model_type, ui_defaults) - - model_def = wgp.get_model_def(model_type) - base_model_type = wgp.get_base_model_type(model_type) - model_filename = self.state.get('model_filename', '') - - image_outputs = model_def.get("image_outputs", False) - vace = wgp.test_vace_module(model_type) - t2v = base_model_type in ['t2v', 't2v_2_2'] - i2v = wgp.test_class_i2v(model_type) - fantasy = base_model_type in ["fantasy"] - multitalk = model_def.get("multitalk_class", False) - any_audio_guidance = fantasy or multitalk - sliding_window_enabled = wgp.test_any_sliding_window(model_type) - recammaster = base_model_type in ["recam_1.3B"] - ltxv = "ltxv" in model_filename - diffusion_forcing = "diffusion_forcing" in model_filename - any_skip_layer_guidance = model_def.get("skip_layer_guidance", False) - any_cfg_zero = model_def.get("cfg_zero", False) - any_cfg_star = model_def.get("cfg_star", False) - any_apg = model_def.get("adaptive_projected_guidance", False) - v2i_switch_supported = model_def.get("v2i_switch_supported", False) - - self._update_generation_mode_visibility(model_def) - - for widget in self.widgets.values(): - if hasattr(widget, 'blockSignals'): widget.blockSignals(True) - - self.widgets['prompt'].setText(ui_defaults.get("prompt", "")) - self.widgets['negative_prompt'].setText(ui_defaults.get("negative_prompt", "")) - self.widgets['seed'].setText(str(ui_defaults.get("seed", -1))) - - video_length_val = ui_defaults.get("video_length", 81) - self.widgets['video_length'].setValue(video_length_val) - self.widgets['video_length_label'].setText(str(video_length_val)) - - steps_val = ui_defaults.get("num_inference_steps", 30) - self.widgets['num_inference_steps'].setValue(steps_val) - self.widgets['num_inference_steps_label'].setText(str(steps_val)) - - self.widgets['resolution_group'].blockSignals(True) - self.widgets['resolution'].blockSignals(True) - - current_res_choice = ui_defaults.get("resolution") - model_resolutions = model_def.get("resolutions", None) - self.full_resolution_choices, current_res_choice = wgp.get_resolution_choices(current_res_choice, model_resolutions) - available_groups, selected_group_resolutions, selected_group = wgp.group_resolutions(model_def, self.full_resolution_choices, current_res_choice) - - self.widgets['resolution_group'].clear() - self.widgets['resolution_group'].addItems(available_groups) - group_index = self.widgets['resolution_group'].findText(selected_group) - if group_index != -1: - self.widgets['resolution_group'].setCurrentIndex(group_index) - - self.widgets['resolution'].clear() - for label, value in selected_group_resolutions: - self.widgets['resolution'].addItem(label, value) - res_index = self.widgets['resolution'].findData(current_res_choice) - if res_index != -1: - self.widgets['resolution'].setCurrentIndex(res_index) - - self.widgets['resolution_group'].blockSignals(False) - self.widgets['resolution'].blockSignals(False) - - for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'image_refs', 'audio_source']: - if name in self.widgets: self.widgets[name].clear() - - guidance_layout = self.widgets['guidance_layout'] - guidance_max = model_def.get("guidance_max_phases", 1) - guidance_layout.setRowVisible(self.widgets['guidance_phases_row_index'], guidance_max > 1) - - adv_general_layout = self.widgets['adv_general_layout'] - adv_general_layout.setRowVisible(self.widgets['flow_shift_row_index'], not image_outputs) - adv_general_layout.setRowVisible(self.widgets['audio_guidance_row_index'], any_audio_guidance) - adv_general_layout.setRowVisible(self.widgets['repeat_generation_row_index'], not image_outputs) - adv_general_layout.setRowVisible(self.widgets['multi_images_gen_type_row_index'], i2v) - - self.widgets['slg_group'].setVisible(any_skip_layer_guidance) - quality_form_layout = self.widgets['quality_form_layout'] - quality_form_layout.setRowVisible(self.widgets['apg_switch_row_index'], any_apg) - quality_form_layout.setRowVisible(self.widgets['cfg_star_switch_row_index'], any_cfg_star) - quality_form_layout.setRowVisible(self.widgets['cfg_zero_step_row_index'], any_cfg_zero) - quality_form_layout.setRowVisible(self.widgets['min_frames_if_references_row_index'], v2i_switch_supported and image_outputs) - - self.widgets['advanced_tabs'].setTabVisible(self.widgets['sliding_window_tab_index'], sliding_window_enabled and not image_outputs) - - misc_layout = self.widgets['misc_layout'] - misc_layout.setRowVisible(self.widgets['riflex_row_index'], not (recammaster or ltxv or diffusion_forcing)) - - index = self.widgets['multi_images_gen_type'].findData(ui_defaults.get('multi_images_gen_type', 0)) - if index != -1: self.widgets['multi_images_gen_type'].setCurrentIndex(index) - - guidance_val = ui_defaults.get("guidance_scale", 5.0) - self.widgets['guidance_scale'].setValue(int(guidance_val * 10)) - self.widgets['guidance_scale_label'].setText(f"{guidance_val:.1f}") - - guidance2_val = ui_defaults.get("guidance2_scale", 5.0) - self.widgets['guidance2_scale'].setValue(int(guidance2_val * 10)) - self.widgets['guidance2_scale_label'].setText(f"{guidance2_val:.1f}") - - guidance3_val = ui_defaults.get("guidance3_scale", 5.0) - self.widgets['guidance3_scale'].setValue(int(guidance3_val * 10)) - self.widgets['guidance3_scale_label'].setText(f"{guidance3_val:.1f}") - - self.widgets['guidance_phases'].clear() - if guidance_max >= 1: self.widgets['guidance_phases'].addItem("One Phase", 1) - if guidance_max >= 2: self.widgets['guidance_phases'].addItem("Two Phases", 2) - if guidance_max >= 3: self.widgets['guidance_phases'].addItem("Three Phases", 3) - index = self.widgets['guidance_phases'].findData(ui_defaults.get("guidance_phases", 1)) - if index != -1: self.widgets['guidance_phases'].setCurrentIndex(index) - - switch_thresh_val = ui_defaults.get("switch_threshold", 0) - self.widgets['switch_threshold'].setValue(switch_thresh_val) - self.widgets['switch_threshold_label'].setText(str(switch_thresh_val)) - - nag_scale_val = ui_defaults.get('NAG_scale', 1.0) - self.widgets['NAG_scale'].setValue(int(nag_scale_val * 10)) - self.widgets['NAG_scale_label'].setText(f"{nag_scale_val:.1f}") - - nag_tau_val = ui_defaults.get('NAG_tau', 3.5) - self.widgets['NAG_tau'].setValue(int(nag_tau_val * 10)) - self.widgets['NAG_tau_label'].setText(f"{nag_tau_val:.1f}") - - nag_alpha_val = ui_defaults.get('NAG_alpha', 0.5) - self.widgets['NAG_alpha'].setValue(int(nag_alpha_val * 10)) - self.widgets['NAG_alpha_label'].setText(f"{nag_alpha_val:.1f}") - - self.widgets['nag_group'].setVisible(vace or t2v or i2v) - - self.widgets['sample_solver'].clear() - sampler_choices = model_def.get("sample_solvers", []) - self.widgets['solver_row_container'].setVisible(bool(sampler_choices)) - if sampler_choices: - for label, value in sampler_choices: self.widgets['sample_solver'].addItem(label, value) - solver_val = ui_defaults.get('sample_solver', sampler_choices[0][1]) - index = self.widgets['sample_solver'].findData(solver_val) - if index != -1: self.widgets['sample_solver'].setCurrentIndex(index) - - flow_val = ui_defaults.get("flow_shift", 3.0) - self.widgets['flow_shift'].setValue(int(flow_val * 10)) - self.widgets['flow_shift_label'].setText(f"{flow_val:.1f}") - - audio_guidance_val = ui_defaults.get("audio_guidance_scale", 4.0) - self.widgets['audio_guidance_scale'].setValue(int(audio_guidance_val * 10)) - self.widgets['audio_guidance_scale_label'].setText(f"{audio_guidance_val:.1f}") - - repeat_val = ui_defaults.get("repeat_generation", 1) - self.widgets['repeat_generation'].setValue(repeat_val) - self.widgets['repeat_generation_label'].setText(str(repeat_val)) - - available_loras, _, _, _, _, _ = wgp.setup_loras(model_type, None, wgp.get_lora_dir(model_type), "") - self.state['loras'] = available_loras - self.lora_map = {os.path.basename(p): p for p in available_loras} - lora_list_widget = self.widgets['activated_loras'] - lora_list_widget.clear() - lora_list_widget.addItems(sorted(self.lora_map.keys())) - selected_loras = ui_defaults.get('activated_loras', []) - for i in range(lora_list_widget.count()): - item = lora_list_widget.item(i) - if any(item.text() == os.path.basename(p) for p in selected_loras): item.setSelected(True) - self.widgets['loras_multipliers'].setText(ui_defaults.get('loras_multipliers', '')) - - skip_cache_val = ui_defaults.get('skip_steps_cache_type', "") - index = self.widgets['skip_steps_cache_type'].findData(skip_cache_val) - if index != -1: self.widgets['skip_steps_cache_type'].setCurrentIndex(index) - - skip_mult = ui_defaults.get('skip_steps_multiplier', 1.5) - index = self.widgets['skip_steps_multiplier'].findData(skip_mult) - if index != -1: self.widgets['skip_steps_multiplier'].setCurrentIndex(index) - - skip_perc_val = ui_defaults.get('skip_steps_start_step_perc', 0) - self.widgets['skip_steps_start_step_perc'].setValue(skip_perc_val) - self.widgets['skip_steps_start_step_perc_label'].setText(str(skip_perc_val)) - - temp_up_val = ui_defaults.get('temporal_upsampling', "") - index = self.widgets['temporal_upsampling'].findData(temp_up_val) - if index != -1: self.widgets['temporal_upsampling'].setCurrentIndex(index) - - spat_up_val = ui_defaults.get('spatial_upsampling', "") - index = self.widgets['spatial_upsampling'].findData(spat_up_val) - if index != -1: self.widgets['spatial_upsampling'].setCurrentIndex(index) - - film_grain_i = ui_defaults.get('film_grain_intensity', 0) - self.widgets['film_grain_intensity'].setValue(int(film_grain_i * 100)) - self.widgets['film_grain_intensity_label'].setText(f"{film_grain_i:.2f}") - - film_grain_s = ui_defaults.get('film_grain_saturation', 0.5) - self.widgets['film_grain_saturation'].setValue(int(film_grain_s * 100)) - self.widgets['film_grain_saturation_label'].setText(f"{film_grain_s:.2f}") - - self.widgets['MMAudio_setting'].setCurrentIndex(ui_defaults.get('MMAudio_setting', 0)) - self.widgets['MMAudio_prompt'].setText(ui_defaults.get('MMAudio_prompt', '')) - self.widgets['MMAudio_neg_prompt'].setText(ui_defaults.get('MMAudio_neg_prompt', '')) - - self.widgets['slg_switch'].setCurrentIndex(ui_defaults.get('slg_switch', 0)) - slg_start_val = ui_defaults.get('slg_start_perc', 10) - self.widgets['slg_start_perc'].setValue(slg_start_val) - self.widgets['slg_start_perc_label'].setText(str(slg_start_val)) - slg_end_val = ui_defaults.get('slg_end_perc', 90) - self.widgets['slg_end_perc'].setValue(slg_end_val) - self.widgets['slg_end_perc_label'].setText(str(slg_end_val)) - - self.widgets['apg_switch'].setCurrentIndex(ui_defaults.get('apg_switch', 0)) - self.widgets['cfg_star_switch'].setCurrentIndex(ui_defaults.get('cfg_star_switch', 0)) - - cfg_zero_val = ui_defaults.get('cfg_zero_step', -1) - self.widgets['cfg_zero_step'].setValue(cfg_zero_val) - self.widgets['cfg_zero_step_label'].setText(str(cfg_zero_val)) - - min_frames_val = ui_defaults.get('min_frames_if_references', 1) - index = self.widgets['min_frames_if_references'].findData(min_frames_val) - if index != -1: self.widgets['min_frames_if_references'].setCurrentIndex(index) - - self.widgets['RIFLEx_setting'].setCurrentIndex(ui_defaults.get('RIFLEx_setting', 0)) - - fps = wgp.get_model_fps(model_type) - force_fps_choices = [ - (f"Model Default ({fps} fps)", ""), ("Auto", "auto"), ("Control Video fps", "control"), - ("Source Video fps", "source"), ("15", "15"), ("16", "16"), ("23", "23"), - ("24", "24"), ("25", "25"), ("30", "30") - ] - self.widgets['force_fps'].clear() - for label, value in force_fps_choices: self.widgets['force_fps'].addItem(label, value) - force_fps_val = ui_defaults.get('force_fps', "") - index = self.widgets['force_fps'].findData(force_fps_val) - if index != -1: self.widgets['force_fps'].setCurrentIndex(index) - - override_prof_val = ui_defaults.get('override_profile', -1) - index = self.widgets['override_profile'].findData(override_prof_val) - if index != -1: self.widgets['override_profile'].setCurrentIndex(index) - - self.widgets['multi_prompts_gen_type'].setCurrentIndex(ui_defaults.get('multi_prompts_gen_type', 0)) - - denoising_val = ui_defaults.get("denoising_strength", 0.5) - self.widgets['denoising_strength'].setValue(int(denoising_val * 100)) - self.widgets['denoising_strength_label'].setText(f"{denoising_val:.2f}") - - sw_size = ui_defaults.get("sliding_window_size", 129) - self.widgets['sliding_window_size'].setValue(sw_size) - self.widgets['sliding_window_size_label'].setText(str(sw_size)) - - sw_overlap = ui_defaults.get("sliding_window_overlap", 5) - self.widgets['sliding_window_overlap'].setValue(sw_overlap) - self.widgets['sliding_window_overlap_label'].setText(str(sw_overlap)) - - sw_color = ui_defaults.get("sliding_window_color_correction_strength", 0) - self.widgets['sliding_window_color_correction_strength'].setValue(int(sw_color * 100)) - self.widgets['sliding_window_color_correction_strength_label'].setText(f"{sw_color:.2f}") - - sw_noise = ui_defaults.get("sliding_window_overlap_noise", 20) - self.widgets['sliding_window_overlap_noise'].setValue(sw_noise) - self.widgets['sliding_window_overlap_noise_label'].setText(str(sw_noise)) - - sw_discard = ui_defaults.get("sliding_window_discard_last_frames", 0) - self.widgets['sliding_window_discard_last_frames'].setValue(sw_discard) - self.widgets['sliding_window_discard_last_frames_label'].setText(str(sw_discard)) - - for widget in self.widgets.values(): - if hasattr(widget, 'blockSignals'): widget.blockSignals(False) - - self._update_dynamic_ui() - self._update_input_visibility() - - def _update_dynamic_ui(self): - phases = self.widgets['guidance_phases'].currentData() or 1 - guidance_layout = self.widgets['guidance_layout'] - guidance_layout.setRowVisible(self.widgets['guidance2_row_index'], phases >= 2) - guidance_layout.setRowVisible(self.widgets['guidance3_row_index'], phases >= 3) - guidance_layout.setRowVisible(self.widgets['switch_thresh_row_index'], phases >= 2) - - def _update_generation_mode_visibility(self, model_def): - allowed = model_def.get("image_prompt_types_allowed", "") - choices = [] - if "T" in allowed or not allowed: choices.append(("Text Prompt Only" if "S" in allowed else "New Video", "T")) - if "S" in allowed: choices.append(("Start Video with Image", "S")) - if "V" in allowed: choices.append(("Continue Video", "V")) - if "L" in allowed: choices.append(("Continue Last Video", "L")) - button_map = { "T": self.widgets['mode_t'], "S": self.widgets['mode_s'], "V": self.widgets['mode_v'], "L": self.widgets['mode_l'] } - for btn in button_map.values(): btn.setVisible(False) - allowed_values = [c[1] for c in choices] - for label, value in choices: - if value in button_map: - btn = button_map[value] - btn.setText(label) - btn.setVisible(True) - current_checked_value = next((value for value, btn in button_map.items() if btn.isChecked()), None) - if current_checked_value is None or not button_map[current_checked_value].isVisible(): - if allowed_values: button_map[allowed_values[0]].setChecked(True) - end_image_visible = "E" in allowed - self.widgets['image_end_checkbox'].setVisible(end_image_visible) - if not end_image_visible: self.widgets['image_end_checkbox'].setChecked(False) - control_video_visible = model_def.get("guide_preprocessing") is not None - self.widgets['control_video_checkbox'].setVisible(control_video_visible) - if not control_video_visible: self.widgets['control_video_checkbox'].setChecked(False) - ref_image_visible = model_def.get("image_ref_choices") is not None - self.widgets['ref_image_checkbox'].setVisible(ref_image_visible) - if not ref_image_visible: self.widgets['ref_image_checkbox'].setChecked(False) - - def _update_input_visibility(self): - is_s_mode = self.widgets['mode_s'].isChecked() - is_v_mode = self.widgets['mode_v'].isChecked() - is_l_mode = self.widgets['mode_l'].isChecked() - use_end = self.widgets['image_end_checkbox'].isChecked() and self.widgets['image_end_checkbox'].isVisible() - use_control = self.widgets['control_video_checkbox'].isChecked() and self.widgets['control_video_checkbox'].isVisible() - use_ref = self.widgets['ref_image_checkbox'].isChecked() and self.widgets['ref_image_checkbox'].isVisible() - self.widgets['image_start_container'].setVisible(is_s_mode) - self.widgets['video_source_container'].setVisible(is_v_mode) - end_checkbox_enabled = is_s_mode or is_v_mode or is_l_mode - self.widgets['image_end_checkbox'].setEnabled(end_checkbox_enabled) - self.widgets['image_end_container'].setVisible(use_end and end_checkbox_enabled) - self.widgets['video_guide_container'].setVisible(use_control) - self.widgets['video_mask_container'].setVisible(use_control) - self.widgets['image_refs_container'].setVisible(use_ref) - - def connect_signals(self): - self.widgets['model_family'].currentIndexChanged.connect(self._on_family_changed) - self.widgets['model_base_type_choice'].currentIndexChanged.connect(self._on_base_type_changed) - self.widgets['model_choice'].currentIndexChanged.connect(self._on_model_changed) - self.widgets['resolution_group'].currentIndexChanged.connect(self._on_resolution_group_changed) - self.widgets['guidance_phases'].currentIndexChanged.connect(self._update_dynamic_ui) - self.widgets['mode_t'].toggled.connect(self._update_input_visibility) - self.widgets['mode_s'].toggled.connect(self._update_input_visibility) - self.widgets['mode_v'].toggled.connect(self._update_input_visibility) - self.widgets['mode_l'].toggled.connect(self._update_input_visibility) - self.widgets['image_end_checkbox'].toggled.connect(self._update_input_visibility) - self.widgets['control_video_checkbox'].toggled.connect(self._update_input_visibility) - self.widgets['ref_image_checkbox'].toggled.connect(self._update_input_visibility) - self.widgets['preview_group'].toggled.connect(self._on_preview_toggled) - self.generate_btn.clicked.connect(self._on_generate) - self.add_to_queue_btn.clicked.connect(self._on_add_to_queue) - self.remove_queue_btn.clicked.connect(self._on_remove_selected_from_queue) - self.clear_queue_btn.clicked.connect(self._on_clear_queue) - self.abort_btn.clicked.connect(self._on_abort) - self.queue_table.rowsMoved.connect(self._on_queue_rows_moved) - - def load_main_config(self): - try: - with open('main_config.json', 'r') as f: self.main_config = json.load(f) - except (FileNotFoundError, json.JSONDecodeError): self.main_config = {'preview_visible': False} - - def save_main_config(self): - try: - with open('main_config.json', 'w') as f: json.dump(self.main_config, f, indent=4) - except Exception as e: print(f"Error saving main_config.json: {e}") - - def apply_initial_config(self): - is_visible = self.main_config.get('preview_visible', True) - self.widgets['preview_group'].setChecked(is_visible) - self.widgets['preview_image'].setVisible(is_visible) - - def _on_preview_toggled(self, checked): - self.widgets['preview_image'].setVisible(checked) - self.main_config['preview_visible'] = checked - self.save_main_config() - - def _on_family_changed(self): - family = self.widgets['model_family'].currentData() - if not family or not self.state: return - base_type_mock, choice_mock = self.wgp.change_model_family(self.state, family) - - if hasattr(base_type_mock, 'kwargs') and isinstance(base_type_mock.kwargs, dict): - is_visible_base = base_type_mock.kwargs.get('visible', True) - elif hasattr(base_type_mock, 'visible'): - is_visible_base = base_type_mock.visible - else: - is_visible_base = True - - self.widgets['model_base_type_choice'].blockSignals(True) - self.widgets['model_base_type_choice'].clear() - if base_type_mock.choices: - for label, value in base_type_mock.choices: self.widgets['model_base_type_choice'].addItem(label, value) - self.widgets['model_base_type_choice'].setCurrentIndex(self.widgets['model_base_type_choice'].findData(base_type_mock.value)) - self.widgets['model_base_type_choice'].setVisible(is_visible_base) - self.widgets['model_base_type_choice'].blockSignals(False) - - if hasattr(choice_mock, 'kwargs') and isinstance(choice_mock.kwargs, dict): - is_visible_choice = choice_mock.kwargs.get('visible', True) - elif hasattr(choice_mock, 'visible'): - is_visible_choice = choice_mock.visible - else: - is_visible_choice = True - - self.widgets['model_choice'].blockSignals(True) - self.widgets['model_choice'].clear() - if choice_mock.choices: - for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) - self.widgets['model_choice'].setCurrentIndex(self.widgets['model_choice'].findData(choice_mock.value)) - self.widgets['model_choice'].setVisible(is_visible_choice) - self.widgets['model_choice'].blockSignals(False) - - self._on_model_changed() - - def _on_base_type_changed(self): - family = self.widgets['model_family'].currentData() - base_type = self.widgets['model_base_type_choice'].currentData() - if not family or not base_type or not self.state: return - base_type_mock, choice_mock = self.wgp.change_model_base_types(self.state, family, base_type) - - if hasattr(choice_mock, 'kwargs') and isinstance(choice_mock.kwargs, dict): - is_visible_choice = choice_mock.kwargs.get('visible', True) - elif hasattr(choice_mock, 'visible'): - is_visible_choice = choice_mock.visible - else: - is_visible_choice = True - - self.widgets['model_choice'].blockSignals(True) - self.widgets['model_choice'].clear() - if choice_mock.choices: - for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) - self.widgets['model_choice'].setCurrentIndex(self.widgets['model_choice'].findData(choice_mock.value)) - self.widgets['model_choice'].setVisible(is_visible_choice) - self.widgets['model_choice'].blockSignals(False) - self._on_model_changed() - - def _on_model_changed(self): - model_type = self.widgets['model_choice'].currentData() - if not model_type or model_type == self.state.get('model_type'): return - self.wgp.change_model(self.state, model_type) - self.refresh_ui_from_model_change(model_type) - - def _on_resolution_group_changed(self): - selected_group = self.widgets['resolution_group'].currentText() - if not selected_group or not hasattr(self, 'full_resolution_choices'): return - model_type = self.state['model_type'] - model_def = self.wgp.get_model_def(model_type) - model_resolutions = model_def.get("resolutions", None) - group_resolution_choices = [] - if model_resolutions is None: - group_resolution_choices = [res for res in self.full_resolution_choices if self.wgp.categorize_resolution(res[1]) == selected_group] - else: return - last_resolution = self.state.get("last_resolution_per_group", {}).get(selected_group, "") - if not any(last_resolution == res[1] for res in group_resolution_choices) and group_resolution_choices: - last_resolution = group_resolution_choices[0][1] - self.widgets['resolution'].blockSignals(True) - self.widgets['resolution'].clear() - for label, value in group_resolution_choices: self.widgets['resolution'].addItem(label, value) - self.widgets['resolution'].setCurrentIndex(self.widgets['resolution'].findData(last_resolution)) - self.widgets['resolution'].blockSignals(False) - - def collect_inputs(self): - full_inputs = self.wgp.get_current_model_settings(self.state).copy() - full_inputs['lset_name'] = "" - full_inputs['image_mode'] = 0 - expected_keys = { "audio_guide": None, "audio_guide2": None, "image_guide": None, "image_mask": None, "speakers_locations": "", "frames_positions": "", "keep_frames_video_guide": "", "keep_frames_video_source": "", "video_guide_outpainting": "", "switch_threshold2": 0, "model_switch_phase": 1, "batch_size": 1, "control_net_weight_alt": 1.0, "image_refs_relative_size": 50, } - for key, default_value in expected_keys.items(): - if key not in full_inputs: full_inputs[key] = default_value - full_inputs['prompt'] = self.widgets['prompt'].toPlainText() - full_inputs['negative_prompt'] = self.widgets['negative_prompt'].toPlainText() - full_inputs['resolution'] = self.widgets['resolution'].currentData() - full_inputs['video_length'] = self.widgets['video_length'].value() - full_inputs['num_inference_steps'] = self.widgets['num_inference_steps'].value() - full_inputs['seed'] = int(self.widgets['seed'].text()) - image_prompt_type = "" - video_prompt_type = "" - if self.widgets['mode_s'].isChecked(): image_prompt_type = 'S' - elif self.widgets['mode_v'].isChecked(): image_prompt_type = 'V' - elif self.widgets['mode_l'].isChecked(): image_prompt_type = 'L' - if self.widgets['image_end_checkbox'].isVisible() and self.widgets['image_end_checkbox'].isChecked(): image_prompt_type += 'E' - if self.widgets['control_video_checkbox'].isVisible() and self.widgets['control_video_checkbox'].isChecked(): video_prompt_type += 'V' - if self.widgets['ref_image_checkbox'].isVisible() and self.widgets['ref_image_checkbox'].isChecked(): video_prompt_type += 'I' - full_inputs['image_prompt_type'] = image_prompt_type - full_inputs['video_prompt_type'] = video_prompt_type - for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'audio_source']: - if name in self.widgets: full_inputs[name] = self.widgets[name].text() or None - paths = self.widgets['image_refs'].text().split(';') - full_inputs['image_refs'] = [p.strip() for p in paths if p.strip()] if paths and paths[0] else None - full_inputs['denoising_strength'] = self.widgets['denoising_strength'].value() / 100.0 - if self.advanced_group.isChecked(): - full_inputs['guidance_scale'] = self.widgets['guidance_scale'].value() / 10.0 - full_inputs['guidance_phases'] = self.widgets['guidance_phases'].currentData() - full_inputs['guidance2_scale'] = self.widgets['guidance2_scale'].value() / 10.0 - full_inputs['guidance3_scale'] = self.widgets['guidance3_scale'].value() / 10.0 - full_inputs['switch_threshold'] = self.widgets['switch_threshold'].value() - full_inputs['NAG_scale'] = self.widgets['NAG_scale'].value() / 10.0 - full_inputs['NAG_tau'] = self.widgets['NAG_tau'].value() / 10.0 - full_inputs['NAG_alpha'] = self.widgets['NAG_alpha'].value() / 10.0 - full_inputs['sample_solver'] = self.widgets['sample_solver'].currentData() - full_inputs['flow_shift'] = self.widgets['flow_shift'].value() / 10.0 - full_inputs['audio_guidance_scale'] = self.widgets['audio_guidance_scale'].value() / 10.0 - full_inputs['repeat_generation'] = self.widgets['repeat_generation'].value() - full_inputs['multi_images_gen_type'] = self.widgets['multi_images_gen_type'].currentData() - selected_items = self.widgets['activated_loras'].selectedItems() - full_inputs['activated_loras'] = [self.lora_map[item.text()] for item in selected_items if item.text() in self.lora_map] - full_inputs['loras_multipliers'] = self.widgets['loras_multipliers'].toPlainText() - full_inputs['skip_steps_cache_type'] = self.widgets['skip_steps_cache_type'].currentData() - full_inputs['skip_steps_multiplier'] = self.widgets['skip_steps_multiplier'].currentData() - full_inputs['skip_steps_start_step_perc'] = self.widgets['skip_steps_start_step_perc'].value() - full_inputs['temporal_upsampling'] = self.widgets['temporal_upsampling'].currentData() - full_inputs['spatial_upsampling'] = self.widgets['spatial_upsampling'].currentData() - full_inputs['film_grain_intensity'] = self.widgets['film_grain_intensity'].value() / 100.0 - full_inputs['film_grain_saturation'] = self.widgets['film_grain_saturation'].value() / 100.0 - full_inputs['MMAudio_setting'] = self.widgets['MMAudio_setting'].currentData() - full_inputs['MMAudio_prompt'] = self.widgets['MMAudio_prompt'].text() - full_inputs['MMAudio_neg_prompt'] = self.widgets['MMAudio_neg_prompt'].text() - full_inputs['RIFLEx_setting'] = self.widgets['RIFLEx_setting'].currentData() - full_inputs['force_fps'] = self.widgets['force_fps'].currentData() - full_inputs['override_profile'] = self.widgets['override_profile'].currentData() - full_inputs['multi_prompts_gen_type'] = self.widgets['multi_prompts_gen_type'].currentData() - full_inputs['slg_switch'] = self.widgets['slg_switch'].currentData() - full_inputs['slg_start_perc'] = self.widgets['slg_start_perc'].value() - full_inputs['slg_end_perc'] = self.widgets['slg_end_perc'].value() - full_inputs['apg_switch'] = self.widgets['apg_switch'].currentData() - full_inputs['cfg_star_switch'] = self.widgets['cfg_star_switch'].currentData() - full_inputs['cfg_zero_step'] = self.widgets['cfg_zero_step'].value() - full_inputs['min_frames_if_references'] = self.widgets['min_frames_if_references'].currentData() - full_inputs['sliding_window_size'] = self.widgets['sliding_window_size'].value() - full_inputs['sliding_window_overlap'] = self.widgets['sliding_window_overlap'].value() - full_inputs['sliding_window_color_correction_strength'] = self.widgets['sliding_window_color_correction_strength'].value() / 100.0 - full_inputs['sliding_window_overlap_noise'] = self.widgets['sliding_window_overlap_noise'].value() - full_inputs['sliding_window_discard_last_frames'] = self.widgets['sliding_window_discard_last_frames'].value() - return full_inputs - - def _prepare_state_for_generation(self): - if 'gen' in self.state: - self.state['gen'].pop('abort', None) - self.state['gen'].pop('in_progress', None) - - def _on_generate(self): - try: - is_running = self.thread and self.thread.isRunning() - self._add_task_to_queue() - if not is_running: self.start_generation() - except Exception as e: - import traceback; traceback.print_exc() - - def _on_add_to_queue(self): - try: - self._add_task_to_queue() - except Exception as e: - import traceback; traceback.print_exc() - - def _add_task_to_queue(self): - all_inputs = self.collect_inputs() - for key in ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality', 'base_model_type']: all_inputs.pop(key, None) - all_inputs['state'] = self.state - self.wgp.set_model_settings(self.state, self.state['model_type'], all_inputs) - self.state["validate_success"] = 1 - self.wgp.process_prompt_and_add_tasks(self.state, self.state['model_type']) - self.update_queue_table() - - def start_generation(self): - if not self.state['gen']['queue']: return - self._prepare_state_for_generation() - self.generate_btn.setEnabled(False) - self.add_to_queue_btn.setEnabled(True) - self.thread = QThread() - self.worker = Worker(self.plugin, self.state) - self.worker.moveToThread(self.thread) - self.thread.started.connect(self.worker.run) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - self.thread.finished.connect(self.on_generation_finished) - self.worker.status.connect(self.status_label.setText) - self.worker.progress.connect(self.update_progress) - self.worker.preview.connect(self.update_preview) - self.worker.output.connect(self.update_queue_and_results) - self.worker.error.connect(self.on_generation_error) - self.thread.start() - self.update_queue_table() - - def on_generation_finished(self): - time.sleep(0.1) - self.status_label.setText("Finished.") - self.progress_bar.setValue(0) - self.generate_btn.setEnabled(True) - self.add_to_queue_btn.setEnabled(False) - self.thread = None; self.worker = None - self.update_queue_table() - - def on_generation_error(self, err_msg): - QMessageBox.critical(self, "Generation Error", str(err_msg)) - self.on_generation_finished() - - def update_progress(self, data): - if len(data) > 1 and isinstance(data[0], tuple): - step, total = data[0] - self.progress_bar.setMaximum(total) - self.progress_bar.setValue(step) - self.status_label.setText(str(data[1])) - if step <= 1: self.update_queue_table() - elif len(data) > 1: self.status_label.setText(str(data[1])) - - def update_preview(self, pil_image): - if pil_image and self.widgets['preview_group'].isChecked(): - q_image = ImageQt(pil_image) - pixmap = QPixmap.fromImage(q_image) - self.preview_image.setPixmap(pixmap.scaled(self.preview_image.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) - - def update_queue_and_results(self): - self.update_queue_table() - file_list = self.state.get('gen', {}).get('file_list', []) - for file_path in file_list: - if file_path not in self.processed_files: - self.add_result_item(file_path) - self.processed_files.add(file_path) - - def add_result_item(self, video_path): - item_widget = VideoResultItemWidget(video_path, self.plugin) - list_item = QListWidgetItem(self.results_list) - list_item.setSizeHint(item_widget.sizeHint()) - self.results_list.addItem(list_item) - self.results_list.setItemWidget(list_item, item_widget) - - def update_queue_table(self): - with self.wgp.lock: - queue = self.state.get('gen', {}).get('queue', []) - is_running = self.thread and self.thread.isRunning() - queue_to_display = queue if is_running else [None] + queue - table_data = self.wgp.get_queue_table(queue_to_display) - self.queue_table.setRowCount(0) - self.queue_table.setRowCount(len(table_data)) - self.queue_table.setColumnCount(4) - self.queue_table.setHorizontalHeaderLabels(["Qty", "Prompt", "Length", "Steps"]) - for row_idx, row_data in enumerate(table_data): - prompt_text = str(row_data[1]).split('>')[1].split('<')[0] if '>' in str(row_data[1]) else str(row_data[1]) - for col_idx, cell_data in enumerate([row_data[0], prompt_text, row_data[2], row_data[3]]): - self.queue_table.setItem(row_idx, col_idx, QTableWidgetItem(str(cell_data))) - self.queue_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - self.queue_table.resizeColumnsToContents() - - def _on_remove_selected_from_queue(self): - selected_row = self.queue_table.currentRow() - if selected_row < 0: return - with self.wgp.lock: - is_running = self.thread and self.thread.isRunning() - offset = 1 if is_running else 0 - queue = self.state.get('gen', {}).get('queue', []) - if len(queue) > selected_row + offset: queue.pop(selected_row + offset) - self.update_queue_table() - - def _on_queue_rows_moved(self, source_row, dest_row): - with self.wgp.lock: - queue = self.state.get('gen', {}).get('queue', []) - is_running = self.thread and self.thread.isRunning() - offset = 1 if is_running else 0 - real_source_idx = source_row + offset - real_dest_idx = dest_row + offset - moved_item = queue.pop(real_source_idx) - queue.insert(real_dest_idx, moved_item) - self.update_queue_table() - - def _on_clear_queue(self): - self.wgp.clear_queue_action(self.state) - self.update_queue_table() - - def _on_abort(self): - if self.worker: - self.wgp.abort_generation(self.state) - self.status_label.setText("Aborting...") - self.worker._is_running = False - - def _on_release_ram(self): - self.wgp.release_RAM() - QMessageBox.information(self, "RAM Released", "Models stored in RAM have been released.") - - def _on_apply_config_changes(self): - changes = {} - list_widget = self.widgets['config_transformer_types'] - changes['transformer_types_choices'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] - list_widget = self.widgets['config_preload_model_policy'] - changes['preload_model_policy_choice'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] - changes['model_hierarchy_type_choice'] = self.widgets['config_model_hierarchy_type'].currentData() - changes['checkpoints_paths'] = self.widgets['config_checkpoints_paths'].toPlainText() - for key in ["fit_canvas", "attention_mode", "metadata_type", "clear_file_list", "display_stats", "max_frames_multiplier", "UI_theme"]: - changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - for key in ["transformer_quantization", "transformer_dtype_policy", "mixed_precision", "text_encoder_quantization", "vae_precision", "compile", "depth_anything_v2_variant", "vae_config", "boost", "profile"]: - changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - changes['preload_in_VRAM_choice'] = self.widgets['config_preload_in_VRAM'].value() - for key in ["enhancer_enabled", "enhancer_mode", "mmaudio_enabled"]: - changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - for key in ["video_output_codec", "image_output_codec", "save_path", "image_save_path"]: - widget = self.widgets[f'config_{key}'] - changes[f'{key}_choice'] = widget.currentData() if isinstance(widget, QComboBox) else widget.text() - changes['notification_sound_enabled_choice'] = self.widgets['config_notification_sound_enabled'].currentData() - changes['notification_sound_volume_choice'] = self.widgets['config_notification_sound_volume'].value() - changes['last_resolution_choice'] = self.widgets['resolution'].currentData() - try: - msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = self.wgp.apply_changes(self.state, **changes) - self.config_status_label.setText("Changes applied successfully. Some settings may require a restart.") - self.header_info.setText(self.wgp.generate_header(self.state['model_type'], self.wgp.compile, self.wgp.attention_mode)) - if family_mock.choices is not None or choice_mock.choices is not None: - self.update_model_dropdowns(self.wgp.transformer_type) - self.refresh_ui_from_model_change(self.wgp.transformer_type) - except Exception as e: - self.config_status_label.setText(f"Error applying changes: {e}") - import traceback; traceback.print_exc() - -class Plugin(VideoEditorPlugin): - def initialize(self): - self.name = "WanGP AI Generator" - self.description = "Uses the integrated WanGP library to generate video clips." - self.client_widget = WgpDesktopPluginWidget(self) - self.dock_widget = None - self.active_region = None; self.temp_dir = None - self.insert_on_new_track = False; self.start_frame_path = None; self.end_frame_path = None - - def enable(self): - if not self.dock_widget: self.dock_widget = self.app.add_dock_widget(self, self.client_widget, self.name) - self.app.timeline_widget.context_menu_requested.connect(self.on_timeline_context_menu) - self.app.status_label.setText(f"{self.name}: Enabled.") - - def disable(self): - try: self.app.timeline_widget.context_menu_requested.disconnect(self.on_timeline_context_menu) - except TypeError: pass - self._cleanup_temp_dir() - if self.client_widget.worker: self.client_widget._on_abort() - self.app.status_label.setText(f"{self.name}: Disabled.") - - def _cleanup_temp_dir(self): - if self.temp_dir and os.path.exists(self.temp_dir): - shutil.rmtree(self.temp_dir) - self.temp_dir = None - - def _reset_state(self): - self.active_region = None; self.insert_on_new_track = False - self.start_frame_path = None; self.end_frame_path = None - self.client_widget.processed_files.clear() - self.client_widget.results_list.clear() - self.client_widget.widgets['image_start'].clear() - self.client_widget.widgets['image_end'].clear() - self.client_widget.widgets['video_source'].clear() - self._cleanup_temp_dir() - self.app.status_label.setText(f"{self.name}: Ready.") - - def on_timeline_context_menu(self, menu, event): - region = self.app.timeline_widget.get_region_at_pos(event.pos()) - if region: - menu.addSeparator() - start_sec, end_sec = region - start_data, _, _ = self.app.get_frame_data_at_time(start_sec) - end_data, _, _ = self.app.get_frame_data_at_time(end_sec) - if start_data and end_data: - join_action = menu.addAction("Join Frames With WanGP") - join_action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) - join_action_new_track = menu.addAction("Join Frames With WanGP (New Track)") - join_action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) - create_action = menu.addAction("Create Video With WanGP") - create_action.triggered.connect(lambda: self.setup_creator_for_region(region)) - - def setup_generator_for_region(self, region, on_new_track=False): - self._reset_state() - self.active_region = region - self.insert_on_new_track = on_new_track - - model_to_set = 'i2v_2_2' - dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types - _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) - if any(model_to_set == m[1] for m in all_models): - if self.client_widget.state.get('model_type') != model_to_set: - self.client_widget.update_model_dropdowns(model_to_set) - self.client_widget._on_model_changed() - else: - print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") - - start_sec, end_sec = region - start_data, w, h = self.app.get_frame_data_at_time(start_sec) - end_data, _, _ = self.app.get_frame_data_at_time(end_sec) - if not start_data or not end_data: - QMessageBox.warning(self.app, "Frame Error", "Could not extract start and/or end frames.") - return - try: - self.temp_dir = tempfile.mkdtemp(prefix="wgp_plugin_") - self.start_frame_path = os.path.join(self.temp_dir, "start_frame.png") - self.end_frame_path = os.path.join(self.temp_dir, "end_frame.png") - QImage(start_data, w, h, QImage.Format.Format_RGB888).save(self.start_frame_path) - QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) - - duration_sec = end_sec - start_sec - wgp = self.client_widget.wgp - model_type = self.client_widget.state['model_type'] - fps = wgp.get_model_fps(model_type) - video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) - - self.client_widget.widgets['video_length'].setValue(video_length_frames) - self.client_widget.widgets['mode_s'].setChecked(True) - self.client_widget.widgets['image_end_checkbox'].setChecked(True) - self.client_widget.widgets['image_start'].setText(self.start_frame_path) - self.client_widget.widgets['image_end'].setText(self.end_frame_path) - - self.client_widget._update_input_visibility() - - except Exception as e: - QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame images: {e}") - self._cleanup_temp_dir() - return - self.app.status_label.setText(f"WanGP: Ready to join frames from {start_sec:.2f}s to {end_sec:.2f}s.") - self.dock_widget.show() - self.dock_widget.raise_() - - def setup_creator_for_region(self, region): - self._reset_state() - self.active_region = region - self.insert_on_new_track = True - - model_to_set = 't2v_2_2' - dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types - _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) - if any(model_to_set == m[1] for m in all_models): - if self.client_widget.state.get('model_type') != model_to_set: - self.client_widget.update_model_dropdowns(model_to_set) - self.client_widget._on_model_changed() - else: - print(f"Warning: Default model '{model_to_set}' not found for AI Creator. Using current model.") - - start_sec, end_sec = region - duration_sec = end_sec - start_sec - wgp = self.client_widget.wgp - model_type = self.client_widget.state['model_type'] - fps = wgp.get_model_fps(model_type) - video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) - - self.client_widget.widgets['video_length'].setValue(video_length_frames) - self.client_widget.widgets['mode_t'].setChecked(True) - - self.app.status_label.setText(f"WanGP: Ready to create video from {start_sec:.2f}s to {end_sec:.2f}s.") - self.dock_widget.show() - self.dock_widget.raise_() - - def insert_generated_clip(self, video_path): - from videoeditor import TimelineClip - if not self.active_region: - self.app.status_label.setText("WanGP Error: No active region to insert into."); return - if not os.path.exists(video_path): - self.app.status_label.setText(f"WanGP Error: Output file not found: {video_path}"); return - start_sec, end_sec = self.active_region - def complex_insertion_action(): - self.app._add_media_files_to_project([video_path]) - media_info = self.app.media_properties.get(video_path) - if not media_info: raise ValueError("Could not probe inserted clip.") - actual_duration, has_audio = media_info['duration'], media_info['has_audio'] - if self.insert_on_new_track: - self.app.timeline.num_video_tracks += 1 - video_track_index = self.app.timeline.num_video_tracks - audio_track_index = self.app.timeline.num_audio_tracks + 1 if has_audio else None - else: - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) - clips_to_remove = [c for c in self.app.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec] - for clip in clips_to_remove: - if clip in self.app.timeline.clips: self.app.timeline.clips.remove(clip) - video_track_index, audio_track_index = 1, 1 if has_audio else None - group_id = str(uuid.uuid4()) - new_clip = TimelineClip(video_path, start_sec, 0, actual_duration, video_track_index, 'video', 'video', group_id) - self.app.timeline.add_clip(new_clip) - if audio_track_index: - if audio_track_index > self.app.timeline.num_audio_tracks: self.app.timeline.num_audio_tracks = audio_track_index - audio_clip = TimelineClip(video_path, start_sec, 0, actual_duration, audio_track_index, 'audio', 'video', group_id) - self.app.timeline.add_clip(audio_clip) - try: - self.app._perform_complex_timeline_change("Insert AI Clip", complex_insertion_action) - self.app.prune_empty_tracks() - self.app.status_label.setText("AI clip inserted successfully.") - for i in range(self.client_widget.results_list.count()): - widget = self.client_widget.results_list.itemWidget(self.client_widget.results_list.item(i)) - if widget and widget.video_path == video_path: - self.client_widget.results_list.takeItem(i); break - except Exception as e: - import traceback; traceback.print_exc() - self.app.status_label.setText(f"WanGP Error during clip insertion: {e}") \ No newline at end of file +import sys +import os +import threading +import time +import json +import tempfile +import shutil +import uuid +from unittest.mock import MagicMock +from pathlib import Path + +# --- Start of Gradio Hijacking --- +# This block creates a mock Gradio module. When wgp.py is imported, +# all calls to `gr.*` will be intercepted by these mock objects, +# preventing any UI from being built and allowing us to use the +# backend logic directly. + +class MockGradioComponent(MagicMock): + """A smarter mock that captures constructor arguments.""" + def __init__(self, *args, **kwargs): + super().__init__(name=f"gr.{kwargs.get('elem_id', 'component')}") + self.kwargs = kwargs + self.value = kwargs.get('value') + self.choices = kwargs.get('choices') + + for method in ['then', 'change', 'click', 'input', 'select', 'upload', 'mount', 'launch', 'on', 'release']: + setattr(self, method, lambda *a, **kw: self) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + +class MockGradioError(Exception): + pass + +class MockGradioModule: + def __getattr__(self, name): + if name == 'Error': + return lambda *args, **kwargs: MockGradioError(*args) + + if name in ['Info', 'Warning']: + return lambda *args, **kwargs: print(f"Intercepted gr.{name}:", *args) + + return lambda *args, **kwargs: MockGradioComponent(*args, **kwargs) + +sys.modules['gradio'] = MockGradioModule() +sys.modules['gradio.gallery'] = MockGradioModule() +sys.modules['shared.gradio.gallery'] = MockGradioModule() +# --- End of Gradio Hijacking --- + +import ffmpeg + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, + QPushButton, QLabel, QLineEdit, QTextEdit, QSlider, QCheckBox, QComboBox, + QFileDialog, QGroupBox, QFormLayout, QTableWidget, QTableWidgetItem, + QHeaderView, QProgressBar, QScrollArea, QListWidget, QListWidgetItem, + QMessageBox, QRadioButton, QSizePolicy +) +from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QUrl, QSize, QRectF +from PyQt6.QtGui import QPixmap, QImage, QDropEvent +from PyQt6.QtMultimedia import QMediaPlayer +from PyQt6.QtMultimediaWidgets import QVideoWidget +from PIL.ImageQt import ImageQt + +# Import the base plugin class from the main application's path +sys.path.append(str(Path(__file__).parent.parent.parent)) +from plugins import VideoEditorPlugin + +class VideoResultItemWidget(QWidget): + """A widget to display a generated video with a hover-to-play preview and insert button.""" + def __init__(self, video_path, plugin, parent=None): + super().__init__(parent) + self.video_path = video_path + self.plugin = plugin + self.app = plugin.app + self.duration = 0.0 + self.has_audio = False + + self.setMinimumSize(200, 180) + self.setMaximumHeight(190) + + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(5) + + self.media_player = QMediaPlayer() + self.video_widget = QVideoWidget() + self.video_widget.setFixedSize(160, 90) + self.media_player.setVideoOutput(self.video_widget) + self.media_player.setSource(QUrl.fromLocalFile(self.video_path)) + self.media_player.setLoops(QMediaPlayer.Loops.Infinite) + + self.info_label = QLabel(os.path.basename(video_path)) + self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.info_label.setWordWrap(True) + + self.insert_button = QPushButton("Insert into Timeline") + self.insert_button.clicked.connect(self.on_insert) + + h_layout = QHBoxLayout() + h_layout.addStretch() + h_layout.addWidget(self.video_widget) + h_layout.addStretch() + + layout.addLayout(h_layout) + layout.addWidget(self.info_label) + layout.addWidget(self.insert_button) + self.probe_video() + + def probe_video(self): + try: + probe = ffmpeg.probe(self.video_path) + self.duration = float(probe['format']['duration']) + self.has_audio = any(s['codec_type'] == 'audio' for s in probe.get('streams', [])) + self.info_label.setText(f"{os.path.basename(self.video_path)}\n({self.duration:.2f}s)") + except Exception as e: + self.info_label.setText(f"Error probing:\n{os.path.basename(self.video_path)}") + print(f"Error probing video {self.video_path}: {e}") + + def enterEvent(self, event): + super().enterEvent(event) + self.media_player.play() + if not self.plugin.active_region or self.duration == 0: return + start_sec, _ = self.plugin.active_region + timeline = self.app.timeline_widget + video_rect, audio_rect = None, None + x = timeline.sec_to_x(start_sec) + w = int(self.duration * timeline.pixels_per_second) + if self.plugin.insert_on_new_track: + video_y = timeline.TIMESCALE_HEIGHT + video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) + if self.has_audio: + audio_y = timeline.audio_tracks_y_start + self.app.timeline.num_audio_tracks * timeline.TRACK_HEIGHT + audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) + else: + v_track_idx = 1 + visual_v_idx = self.app.timeline.num_video_tracks - v_track_idx + video_y = timeline.video_tracks_y_start + visual_v_idx * timeline.TRACK_HEIGHT + video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) + if self.has_audio: + a_track_idx = 1 + visual_a_idx = a_track_idx - 1 + audio_y = timeline.audio_tracks_y_start + visual_a_idx * timeline.TRACK_HEIGHT + audio_rect = QRectF(x, audio_y, w, timeline.TRACK_HEIGHT) + timeline.set_hover_preview_rects(video_rect, audio_rect) + + def leaveEvent(self, event): + super().leaveEvent(event) + self.media_player.pause() + self.media_player.setPosition(0) + self.app.timeline_widget.set_hover_preview_rects(None, None) + + def on_insert(self): + self.media_player.stop() + self.media_player.setSource(QUrl()) + self.media_player.setVideoOutput(None) + self.app.timeline_widget.set_hover_preview_rects(None, None) + self.plugin.insert_generated_clip(self.video_path) + + +class QueueTableWidget(QTableWidget): + rowsMoved = pyqtSignal(int, int) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(self.DragDropMode.InternalMove) + self.setSelectionBehavior(self.SelectionBehavior.SelectRows) + self.setSelectionMode(self.SelectionMode.SingleSelection) + + def dropEvent(self, event: QDropEvent): + if event.source() == self and event.dropAction() == Qt.DropAction.MoveAction: + source_row = self.currentRow() + target_item = self.itemAt(event.position().toPoint()) + dest_row = target_item.row() if target_item else self.rowCount() + if source_row < dest_row: dest_row -=1 + if source_row != dest_row: self.rowsMoved.emit(source_row, dest_row) + event.acceptProposedAction() + else: + super().dropEvent(event) + +class HoverVideoPreview(QWidget): + def __init__(self, player, video_widget, parent=None): + super().__init__(parent) + self.player = player + layout = QVBoxLayout(self) + layout.setContentsMargins(0,0,0,0) + layout.addWidget(video_widget) + self.setFixedSize(160, 90) + + def enterEvent(self, event): + super().enterEvent(event) + if self.player.source().isValid(): + self.player.play() + + def leaveEvent(self, event): + super().leaveEvent(event) + self.player.pause() + self.player.setPosition(0) + +class Worker(QObject): + progress = pyqtSignal(list) + status = pyqtSignal(str) + preview = pyqtSignal(object) + output = pyqtSignal() + finished = pyqtSignal() + error = pyqtSignal(str) + def __init__(self, plugin, state): + super().__init__() + self.plugin = plugin + self.wgp = plugin.wgp + self.state = state + self._is_running = True + self._last_progress_phase = None + self._last_preview = None + + def run(self): + def generation_target(): + try: + for _ in self.wgp.process_tasks(self.state): + if self._is_running: self.output.emit() + else: break + except Exception as e: + import traceback + print("Error in generation thread:") + traceback.print_exc() + if "gradio.Error" in str(type(e)): self.error.emit(str(e)) + else: self.error.emit(f"An unexpected error occurred: {e}") + finally: + self._is_running = False + gen_thread = threading.Thread(target=generation_target, daemon=True) + gen_thread.start() + while self._is_running: + gen = self.state.get('gen', {}) + current_phase = gen.get("progress_phase") + if current_phase and current_phase != self._last_progress_phase: + self._last_progress_phase = current_phase + phase_name, step = current_phase + total_steps = gen.get("num_inference_steps", 1) + high_level_status = gen.get("progress_status", "") + status_msg = self.wgp.merge_status_context(high_level_status, phase_name) + progress_args = [(step, total_steps), status_msg] + self.progress.emit(progress_args) + preview_img = gen.get('preview') + if preview_img is not None and preview_img is not self._last_preview: + self._last_preview = preview_img + self.preview.emit(preview_img) + gen['preview'] = None + time.sleep(0.1) + gen_thread.join() + self.finished.emit() + + +class WgpDesktopPluginWidget(QWidget): + def __init__(self, plugin): + super().__init__() + self.plugin = plugin + self.wgp = plugin.wgp + self.widgets = {} + self.state = {} + self.worker = None + self.thread = None + self.lora_map = {} + self.full_resolution_choices = [] + self.main_config = {} + self.processed_files = set() + + self.load_main_config() + self.setup_ui() + self.apply_initial_config() + self.connect_signals() + self.init_wgp_state() + + def setup_ui(self): + main_layout = QVBoxLayout(self) + self.header_info = QLabel("Header Info") + main_layout.addWidget(self.header_info) + self.tabs = QTabWidget() + main_layout.addWidget(self.tabs) + self.setup_generator_tab() + self.setup_config_tab() + + def create_widget(self, widget_class, name, *args, **kwargs): + widget = widget_class(*args, **kwargs) + self.widgets[name] = widget + return widget + + def _create_slider_with_label(self, name, min_val, max_val, initial_val, scale=1.0, precision=1): + container = QWidget() + hbox = QHBoxLayout(container) + hbox.setContentsMargins(0, 0, 0, 0) + slider = self.create_widget(QSlider, name, Qt.Orientation.Horizontal) + slider.setRange(min_val, max_val) + slider.setValue(int(initial_val * scale)) + value_label = self.create_widget(QLabel, f"{name}_label", f"{initial_val:.{precision}f}") + value_label.setMinimumWidth(50) + slider.valueChanged.connect(lambda v, lbl=value_label, s=scale, p=precision: lbl.setText(f"{v/s:.{p}f}")) + hbox.addWidget(slider) + hbox.addWidget(value_label) + return container + + def _create_file_input(self, name, label_text): + container = self.create_widget(QWidget, f"{name}_container") + vbox = QVBoxLayout(container) + vbox.setContentsMargins(0, 0, 0, 0) + vbox.setSpacing(5) + + input_widget = QWidget() + hbox = QHBoxLayout(input_widget) + hbox.setContentsMargins(0, 0, 0, 0) + + line_edit = self.create_widget(QLineEdit, name) + line_edit.setPlaceholderText("No file selected or path pasted") + button = QPushButton("Browse...") + + def open_dialog(): + if "refs" in name: + filenames, _ = QFileDialog.getOpenFileNames(self, f"Select {label_text}", filter="Image Files (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)") + if filenames: line_edit.setText(";".join(filenames)) + else: + filter_str = "All Files (*)" + if 'video' in name: + filter_str = "Video Files (*.mp4 *.mkv *.mov *.avi);;All Files (*)" + elif 'image' in name: + filter_str = "Image Files (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)" + elif 'audio' in name: + filter_str = "Audio Files (*.wav *.mp3 *.flac);;All Files (*)" + + filename, _ = QFileDialog.getOpenFileName(self, f"Select {label_text}", filter=filter_str) + if filename: line_edit.setText(filename) + + button.clicked.connect(open_dialog) + clear_button = QPushButton("X") + clear_button.setFixedWidth(30) + clear_button.clicked.connect(lambda: line_edit.clear()) + + hbox.addWidget(QLabel(f"{label_text}:")) + hbox.addWidget(line_edit, 1) + hbox.addWidget(button) + hbox.addWidget(clear_button) + vbox.addWidget(input_widget) + + # Preview Widget + preview_container = self.create_widget(QWidget, f"{name}_preview_container") + preview_hbox = QHBoxLayout(preview_container) + preview_hbox.setContentsMargins(0, 0, 0, 0) + preview_hbox.addStretch() + + is_image_input = 'image' in name and 'audio' not in name + is_video_input = 'video' in name and 'audio' not in name + + if is_image_input: + preview_widget = self.create_widget(QLabel, f"{name}_preview") + preview_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + preview_widget.setFixedSize(160, 90) + preview_widget.setStyleSheet("border: 1px solid #cccccc; background-color: #f0f0f0;") + preview_widget.setText("Image Preview") + preview_hbox.addWidget(preview_widget) + elif is_video_input: + media_player = QMediaPlayer() + video_widget = QVideoWidget() + video_widget.setFixedSize(160, 90) + media_player.setVideoOutput(video_widget) + media_player.setLoops(QMediaPlayer.Loops.Infinite) + + self.widgets[f"{name}_player"] = media_player + + preview_widget = HoverVideoPreview(media_player, video_widget) + preview_hbox.addWidget(preview_widget) + else: + preview_widget = self.create_widget(QLabel, f"{name}_preview") + preview_widget.setText("No preview available") + preview_hbox.addWidget(preview_widget) + + preview_hbox.addStretch() + vbox.addWidget(preview_container) + preview_container.setVisible(False) + + def update_preview(path): + container = self.widgets.get(f"{name}_preview_container") + if not container: return + + first_path = path.split(';')[0] if path else '' + + if not first_path or not os.path.exists(first_path): + container.setVisible(False) + if is_video_input: + player = self.widgets.get(f"{name}_player") + if player: player.setSource(QUrl()) + return + + container.setVisible(True) + + if is_image_input: + preview_label = self.widgets.get(f"{name}_preview") + if preview_label: + pixmap = QPixmap(first_path) + if not pixmap.isNull(): + preview_label.setPixmap(pixmap.scaled(preview_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + else: + preview_label.setText("Invalid Image") + + elif is_video_input: + player = self.widgets.get(f"{name}_player") + if player: + player.setSource(QUrl.fromLocalFile(first_path)) + + else: # Audio or other + preview_label = self.widgets.get(f"{name}_preview") + if preview_label: + preview_label.setText(os.path.basename(path)) + + line_edit.textChanged.connect(update_preview) + + return container + + def setup_generator_tab(self): + gen_tab = QWidget() + self.tabs.addTab(gen_tab, "Video Generator") + gen_layout = QHBoxLayout(gen_tab) + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + gen_layout.addWidget(left_panel, 1) + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + gen_layout.addWidget(right_panel, 1) + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + left_layout.addWidget(scroll_area) + options_widget = QWidget() + scroll_area.setWidget(options_widget) + options_layout = QVBoxLayout(options_widget) + model_layout = QHBoxLayout() + self.widgets['model_family'] = QComboBox() + self.widgets['model_base_type_choice'] = QComboBox() + self.widgets['model_choice'] = QComboBox() + model_layout.addWidget(QLabel("Model:")) + model_layout.addWidget(self.widgets['model_family'], 2) + model_layout.addWidget(self.widgets['model_base_type_choice'], 3) + model_layout.addWidget(self.widgets['model_choice'], 3) + options_layout.addLayout(model_layout) + options_layout.addWidget(QLabel("Prompt:")) + self.create_widget(QTextEdit, 'prompt').setMinimumHeight(100) + options_layout.addWidget(self.widgets['prompt']) + options_layout.addWidget(QLabel("Negative Prompt:")) + self.create_widget(QTextEdit, 'negative_prompt').setMinimumHeight(60) + options_layout.addWidget(self.widgets['negative_prompt']) + basic_group = QGroupBox("Basic Options") + basic_layout = QFormLayout(basic_group) + res_container = QWidget() + res_hbox = QHBoxLayout(res_container) + res_hbox.setContentsMargins(0, 0, 0, 0) + res_hbox.addWidget(self.create_widget(QComboBox, 'resolution_group'), 2) + res_hbox.addWidget(self.create_widget(QComboBox, 'resolution'), 3) + basic_layout.addRow("Resolution:", res_container) + basic_layout.addRow("Video Length:", self._create_slider_with_label('video_length', 1, 737, 81, 1.0, 0)) + basic_layout.addRow("Inference Steps:", self._create_slider_with_label('num_inference_steps', 1, 100, 30, 1.0, 0)) + basic_layout.addRow("Seed:", self.create_widget(QLineEdit, 'seed', '-1')) + options_layout.addWidget(basic_group) + mode_options_group = QGroupBox("Generation Mode & Input Options") + mode_options_layout = QVBoxLayout(mode_options_group) + mode_hbox = QHBoxLayout() + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_t', "Text Prompt Only")) + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_s', "Start with Image")) + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_v', "Continue Video")) + mode_hbox.addWidget(self.create_widget(QRadioButton, 'mode_l', "Continue Last Video")) + self.widgets['mode_t'].setChecked(True) + mode_options_layout.addLayout(mode_hbox) + options_hbox = QHBoxLayout() + options_hbox.addWidget(self.create_widget(QCheckBox, 'image_end_checkbox', "Use End Image")) + options_hbox.addWidget(self.create_widget(QCheckBox, 'control_video_checkbox', "Use Control Video")) + options_hbox.addWidget(self.create_widget(QCheckBox, 'ref_image_checkbox', "Use Reference Image(s)")) + mode_options_layout.addLayout(options_hbox) + options_layout.addWidget(mode_options_group) + inputs_group = QGroupBox("Inputs") + inputs_layout = QVBoxLayout(inputs_group) + inputs_layout.addWidget(self._create_file_input('image_start', "Start Image")) + inputs_layout.addWidget(self._create_file_input('image_end', "End Image")) + inputs_layout.addWidget(self._create_file_input('video_source', "Source Video")) + inputs_layout.addWidget(self._create_file_input('video_guide', "Control Video")) + inputs_layout.addWidget(self._create_file_input('video_mask', "Video Mask")) + inputs_layout.addWidget(self._create_file_input('image_refs', "Reference Image(s)")) + denoising_row = QFormLayout() + denoising_row.addRow("Denoising Strength:", self._create_slider_with_label('denoising_strength', 0, 100, 50, 100.0, 2)) + inputs_layout.addLayout(denoising_row) + options_layout.addWidget(inputs_group) + self.advanced_group = self.create_widget(QGroupBox, 'advanced_group', "Advanced Options") + self.advanced_group.setCheckable(True) + self.advanced_group.setChecked(False) + advanced_layout = QVBoxLayout(self.advanced_group) + advanced_tabs = self.create_widget(QTabWidget, 'advanced_tabs') + advanced_layout.addWidget(advanced_tabs) + self._setup_adv_tab_general(advanced_tabs) + self._setup_adv_tab_loras(advanced_tabs) + self._setup_adv_tab_speed(advanced_tabs) + self._setup_adv_tab_postproc(advanced_tabs) + self._setup_adv_tab_audio(advanced_tabs) + self._setup_adv_tab_quality(advanced_tabs) + self._setup_adv_tab_sliding_window(advanced_tabs) + self._setup_adv_tab_misc(advanced_tabs) + options_layout.addWidget(self.advanced_group) + + btn_layout = QHBoxLayout() + self.generate_btn = self.create_widget(QPushButton, 'generate_btn', "Generate") + self.add_to_queue_btn = self.create_widget(QPushButton, 'add_to_queue_btn', "Add to Queue") + self.generate_btn.setEnabled(True) + self.add_to_queue_btn.setEnabled(False) + btn_layout.addWidget(self.generate_btn) + btn_layout.addWidget(self.add_to_queue_btn) + right_layout.addLayout(btn_layout) + self.status_label = self.create_widget(QLabel, 'status_label', "Idle") + right_layout.addWidget(self.status_label) + self.progress_bar = self.create_widget(QProgressBar, 'progress_bar') + right_layout.addWidget(self.progress_bar) + preview_group = self.create_widget(QGroupBox, 'preview_group', "Preview") + preview_group.setCheckable(True) + preview_group.setStyleSheet("QGroupBox { border: 1px solid #cccccc; }") + preview_group_layout = QVBoxLayout(preview_group) + self.preview_image = self.create_widget(QLabel, 'preview_image', "") + self.preview_image.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_image.setMinimumSize(200, 200) + preview_group_layout.addWidget(self.preview_image) + right_layout.addWidget(preview_group) + + results_group = QGroupBox("Generated Clips") + results_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + results_layout = QVBoxLayout(results_group) + self.results_list = self.create_widget(QListWidget, 'results_list') + self.results_list.setFlow(QListWidget.Flow.LeftToRight) + self.results_list.setWrapping(True) + self.results_list.setResizeMode(QListWidget.ResizeMode.Adjust) + self.results_list.setSpacing(10) + results_layout.addWidget(self.results_list) + right_layout.addWidget(results_group) + + right_layout.addWidget(QLabel("Queue:")) + self.queue_table = self.create_widget(QueueTableWidget, 'queue_table') + right_layout.addWidget(self.queue_table) + queue_btn_layout = QHBoxLayout() + self.remove_queue_btn = self.create_widget(QPushButton, 'remove_queue_btn', "Remove Selected") + self.clear_queue_btn = self.create_widget(QPushButton, 'clear_queue_btn', "Clear Queue") + self.abort_btn = self.create_widget(QPushButton, 'abort_btn', "Abort") + queue_btn_layout.addWidget(self.remove_queue_btn) + queue_btn_layout.addWidget(self.clear_queue_btn) + queue_btn_layout.addWidget(self.abort_btn) + right_layout.addLayout(queue_btn_layout) + + def _setup_adv_tab_general(self, tabs): + tab = QWidget() + tabs.addTab(tab, "General") + layout = QFormLayout(tab) + self.widgets['adv_general_layout'] = layout + guidance_group = QGroupBox("Guidance") + guidance_layout = self.create_widget(QFormLayout, 'guidance_layout', guidance_group) + guidance_layout.addRow("Guidance (CFG):", self._create_slider_with_label('guidance_scale', 10, 200, 5.0, 10.0, 1)) + self.widgets['guidance_phases_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Guidance Phases:", self.create_widget(QComboBox, 'guidance_phases')) + self.widgets['guidance2_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Guidance 2:", self._create_slider_with_label('guidance2_scale', 10, 200, 5.0, 10.0, 1)) + self.widgets['guidance3_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Guidance 3:", self._create_slider_with_label('guidance3_scale', 10, 200, 5.0, 10.0, 1)) + self.widgets['switch_thresh_row_index'] = guidance_layout.rowCount() + guidance_layout.addRow("Switch Threshold:", self._create_slider_with_label('switch_threshold', 0, 1000, 0, 1.0, 0)) + layout.addRow(guidance_group) + nag_group = self.create_widget(QGroupBox, 'nag_group', "NAG (Negative Adversarial Guidance)") + nag_layout = QFormLayout(nag_group) + nag_layout.addRow("NAG Scale:", self._create_slider_with_label('NAG_scale', 10, 200, 1.0, 10.0, 1)) + nag_layout.addRow("NAG Tau:", self._create_slider_with_label('NAG_tau', 10, 50, 3.5, 10.0, 1)) + nag_layout.addRow("NAG Alpha:", self._create_slider_with_label('NAG_alpha', 0, 20, 0.5, 10.0, 1)) + layout.addRow(nag_group) + self.widgets['solver_row_container'] = QWidget() + solver_hbox = QHBoxLayout(self.widgets['solver_row_container']) + solver_hbox.setContentsMargins(0,0,0,0) + solver_hbox.addWidget(QLabel("Sampler Solver:")) + solver_hbox.addWidget(self.create_widget(QComboBox, 'sample_solver')) + layout.addRow(self.widgets['solver_row_container']) + self.widgets['flow_shift_row_index'] = layout.rowCount() + layout.addRow("Shift Scale:", self._create_slider_with_label('flow_shift', 10, 250, 3.0, 10.0, 1)) + self.widgets['audio_guidance_row_index'] = layout.rowCount() + layout.addRow("Audio Guidance:", self._create_slider_with_label('audio_guidance_scale', 10, 200, 4.0, 10.0, 1)) + self.widgets['repeat_generation_row_index'] = layout.rowCount() + layout.addRow("Repeat Generations:", self._create_slider_with_label('repeat_generation', 1, 25, 1, 1.0, 0)) + combo = self.create_widget(QComboBox, 'multi_images_gen_type') + combo.addItem("Generate all combinations", 0) + combo.addItem("Match images and texts", 1) + self.widgets['multi_images_gen_type_row_index'] = layout.rowCount() + layout.addRow("Multi-Image Mode:", combo) + + def _setup_adv_tab_loras(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Loras") + layout = QVBoxLayout(tab) + layout.addWidget(QLabel("Available Loras (Ctrl+Click to select multiple):")) + lora_list = self.create_widget(QListWidget, 'activated_loras') + lora_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + layout.addWidget(lora_list) + layout.addWidget(QLabel("Loras Multipliers:")) + layout.addWidget(self.create_widget(QTextEdit, 'loras_multipliers')) + + def _setup_adv_tab_speed(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Speed") + layout = QFormLayout(tab) + combo = self.create_widget(QComboBox, 'skip_steps_cache_type') + combo.addItem("None", "") + combo.addItem("Tea Cache", "tea") + combo.addItem("Mag Cache", "mag") + layout.addRow("Cache Type:", combo) + combo = self.create_widget(QComboBox, 'skip_steps_multiplier') + combo.addItem("x1.5 speed up", 1.5) + combo.addItem("x1.75 speed up", 1.75) + combo.addItem("x2.0 speed up", 2.0) + combo.addItem("x2.25 speed up", 2.25) + combo.addItem("x2.5 speed up", 2.5) + layout.addRow("Acceleration:", combo) + layout.addRow("Start %:", self._create_slider_with_label('skip_steps_start_step_perc', 0, 100, 0, 1.0, 0)) + + def _setup_adv_tab_postproc(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Post-Processing") + layout = QFormLayout(tab) + combo = self.create_widget(QComboBox, 'temporal_upsampling') + combo.addItem("Disabled", "") + combo.addItem("Rife x2 frames/s", "rife2") + combo.addItem("Rife x4 frames/s", "rife4") + layout.addRow("Temporal Upsampling:", combo) + combo = self.create_widget(QComboBox, 'spatial_upsampling') + combo.addItem("Disabled", "") + combo.addItem("Lanczos x1.5", "lanczos1.5") + combo.addItem("Lanczos x2.0", "lanczos2") + layout.addRow("Spatial Upsampling:", combo) + layout.addRow("Film Grain Intensity:", self._create_slider_with_label('film_grain_intensity', 0, 100, 0, 100.0, 2)) + layout.addRow("Film Grain Saturation:", self._create_slider_with_label('film_grain_saturation', 0, 100, 0.5, 100.0, 2)) + + def _setup_adv_tab_audio(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Audio") + layout = QFormLayout(tab) + combo = self.create_widget(QComboBox, 'MMAudio_setting') + combo.addItem("Disabled", 0) + combo.addItem("Enabled", 1) + layout.addRow("MMAudio:", combo) + layout.addWidget(self.create_widget(QLineEdit, 'MMAudio_prompt', placeholderText="MMAudio Prompt")) + layout.addWidget(self.create_widget(QLineEdit, 'MMAudio_neg_prompt', placeholderText="MMAudio Negative Prompt")) + layout.addRow(self._create_file_input('audio_source', "Custom Soundtrack")) + + def _setup_adv_tab_quality(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Quality") + layout = QVBoxLayout(tab) + slg_group = self.create_widget(QGroupBox, 'slg_group', "Skip Layer Guidance") + slg_layout = QFormLayout(slg_group) + slg_combo = self.create_widget(QComboBox, 'slg_switch') + slg_combo.addItem("OFF", 0) + slg_combo.addItem("ON", 1) + slg_layout.addRow("Enable SLG:", slg_combo) + slg_layout.addRow("Start %:", self._create_slider_with_label('slg_start_perc', 0, 100, 10, 1.0, 0)) + slg_layout.addRow("End %:", self._create_slider_with_label('slg_end_perc', 0, 100, 90, 1.0, 0)) + layout.addWidget(slg_group) + quality_form = QFormLayout() + self.widgets['quality_form_layout'] = quality_form + apg_combo = self.create_widget(QComboBox, 'apg_switch') + apg_combo.addItem("OFF", 0) + apg_combo.addItem("ON", 1) + self.widgets['apg_switch_row_index'] = quality_form.rowCount() + quality_form.addRow("Adaptive Projected Guidance:", apg_combo) + cfg_star_combo = self.create_widget(QComboBox, 'cfg_star_switch') + cfg_star_combo.addItem("OFF", 0) + cfg_star_combo.addItem("ON", 1) + self.widgets['cfg_star_switch_row_index'] = quality_form.rowCount() + quality_form.addRow("Classifier-Free Guidance Star:", cfg_star_combo) + self.widgets['cfg_zero_step_row_index'] = quality_form.rowCount() + quality_form.addRow("CFG Zero below Layer:", self._create_slider_with_label('cfg_zero_step', -1, 39, -1, 1.0, 0)) + combo = self.create_widget(QComboBox, 'min_frames_if_references') + combo.addItem("Disabled (1 frame)", 1) + combo.addItem("Generate 5 frames", 5) + combo.addItem("Generate 9 frames", 9) + combo.addItem("Generate 13 frames", 13) + combo.addItem("Generate 17 frames", 17) + self.widgets['min_frames_if_references_row_index'] = quality_form.rowCount() + quality_form.addRow("Min Frames for Quality:", combo) + layout.addLayout(quality_form) + + def _setup_adv_tab_sliding_window(self, tabs): + tab = QWidget() + self.widgets['sliding_window_tab_index'] = tabs.count() + tabs.addTab(tab, "Sliding Window") + layout = QFormLayout(tab) + layout.addRow("Window Size:", self._create_slider_with_label('sliding_window_size', 5, 257, 129, 1.0, 0)) + layout.addRow("Overlap:", self._create_slider_with_label('sliding_window_overlap', 1, 97, 5, 1.0, 0)) + layout.addRow("Color Correction:", self._create_slider_with_label('sliding_window_color_correction_strength', 0, 100, 0, 100.0, 2)) + layout.addRow("Overlap Noise:", self._create_slider_with_label('sliding_window_overlap_noise', 0, 150, 20, 1.0, 0)) + layout.addRow("Discard Last Frames:", self._create_slider_with_label('sliding_window_discard_last_frames', 0, 20, 0, 1.0, 0)) + + def _setup_adv_tab_misc(self, tabs): + tab = QWidget() + tabs.addTab(tab, "Misc") + layout = QFormLayout(tab) + self.widgets['misc_layout'] = layout + riflex_combo = self.create_widget(QComboBox, 'RIFLEx_setting') + riflex_combo.addItem("Auto", 0) + riflex_combo.addItem("Always ON", 1) + riflex_combo.addItem("Always OFF", 2) + self.widgets['riflex_row_index'] = layout.rowCount() + layout.addRow("RIFLEx Setting:", riflex_combo) + fps_combo = self.create_widget(QComboBox, 'force_fps') + layout.addRow("Force FPS:", fps_combo) + profile_combo = self.create_widget(QComboBox, 'override_profile') + profile_combo.addItem("Default Profile", -1) + for text, val in self.wgp.memory_profile_choices: profile_combo.addItem(text.split(':')[0], val) + layout.addRow("Override Memory Profile:", profile_combo) + combo = self.create_widget(QComboBox, 'multi_prompts_gen_type') + combo.addItem("Generate new Video per line", 0) + combo.addItem("Use line for new Sliding Window", 1) + layout.addRow("Multi-Prompt Mode:", combo) + + def setup_config_tab(self): + config_tab = QWidget() + self.tabs.addTab(config_tab, "Configuration") + main_layout = QVBoxLayout(config_tab) + self.config_status_label = QLabel("Apply changes for them to take effect. Some may require a restart.") + main_layout.addWidget(self.config_status_label) + config_tabs = QTabWidget() + main_layout.addWidget(config_tabs) + config_tabs.addTab(self._create_general_config_tab(), "General") + config_tabs.addTab(self._create_performance_config_tab(), "Performance") + config_tabs.addTab(self._create_extensions_config_tab(), "Extensions") + config_tabs.addTab(self._create_outputs_config_tab(), "Outputs") + config_tabs.addTab(self._create_notifications_config_tab(), "Notifications") + self.apply_config_btn = QPushButton("Apply Changes") + self.apply_config_btn.clicked.connect(self._on_apply_config_changes) + main_layout.addWidget(self.apply_config_btn) + + def _create_scrollable_form_tab(self): + tab_widget = QWidget() + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + layout = QVBoxLayout(tab_widget) + layout.addWidget(scroll_area) + content_widget = QWidget() + form_layout = QFormLayout(content_widget) + scroll_area.setWidget(content_widget) + return tab_widget, form_layout + + def _create_config_combo(self, form_layout, label, key, choices, default_value): + combo = QComboBox() + for text, data in choices: combo.addItem(text, data) + index = combo.findData(self.wgp.server_config.get(key, default_value)) + if index != -1: combo.setCurrentIndex(index) + self.widgets[f'config_{key}'] = combo + form_layout.addRow(label, combo) + + def _create_config_slider(self, form_layout, label, key, min_val, max_val, default_value, step=1): + container = QWidget() + hbox = QHBoxLayout(container) + hbox.setContentsMargins(0,0,0,0) + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(min_val, max_val) + slider.setSingleStep(step) + slider.setValue(self.wgp.server_config.get(key, default_value)) + value_label = QLabel(str(slider.value())) + value_label.setMinimumWidth(40) + slider.valueChanged.connect(lambda v, lbl=value_label: lbl.setText(str(v))) + hbox.addWidget(slider) + hbox.addWidget(value_label) + self.widgets[f'config_{key}'] = slider + form_layout.addRow(label, container) + + def _create_config_checklist(self, form_layout, label, key, choices, default_value): + list_widget = QListWidget() + list_widget.setMinimumHeight(100) + current_values = self.wgp.server_config.get(key, default_value) + for text, data in choices: + item = QListWidgetItem(text) + item.setData(Qt.ItemDataRole.UserRole, data) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(Qt.CheckState.Checked if data in current_values else Qt.CheckState.Unchecked) + list_widget.addItem(item) + self.widgets[f'config_{key}'] = list_widget + form_layout.addRow(label, list_widget) + + def _create_config_textbox(self, form_layout, label, key, default_value, multi_line=False): + if multi_line: + textbox = QTextEdit(default_value) + textbox.setAcceptRichText(False) + else: + textbox = QLineEdit(default_value) + self.widgets[f'config_{key}'] = textbox + form_layout.addRow(label, textbox) + + def _create_general_config_tab(self): + tab, form = self._create_scrollable_form_tab() + _, _, dropdown_choices = self.wgp.get_sorted_dropdown(self.wgp.displayed_model_types, None, None, False) + self._create_config_checklist(form, "Selectable Models:", "transformer_types", dropdown_choices, self.wgp.transformer_types) + self._create_config_combo(form, "Model Hierarchy:", "model_hierarchy_type", [("Two Levels (Family > Model)", 0), ("Three Levels (Family > Base > Finetune)", 1)], 1) + self._create_config_combo(form, "Video Dimensions:", "fit_canvas", [("Dimensions are Pixels Budget", 0), ("Dimensions are Max Width/Height", 1), ("Dimensions are Output Width/Height (Cropped)", 2)], 0) + self._create_config_combo(form, "Attention Type:", "attention_mode", [("Auto (Recommended)", "auto"), ("SDPA", "sdpa"), ("Flash", "flash"), ("Xformers", "xformers"), ("Sage", "sage"), ("Sage2/2++", "sage2")], "auto") + self._create_config_combo(form, "Metadata Handling:", "metadata_type", [("Embed in file (Exif/Comment)", "metadata"), ("Export separate JSON", "json"), ("None", "none")], "metadata") + self._create_config_checklist(form, "RAM Loading Policy:", "preload_model_policy", [("Preload on App Launch", "P"), ("Preload on Model Switch", "S"), ("Unload when Queue is Done", "U")], []) + self._create_config_combo(form, "Keep Previous Videos:", "clear_file_list", [("None", 0), ("Keep last video", 1), ("Keep last 5", 5), ("Keep last 10", 10), ("Keep last 20", 20), ("Keep last 30", 30)], 5) + self._create_config_combo(form, "Display RAM/VRAM Stats:", "display_stats", [("Disabled", 0), ("Enabled", 1)], 0) + self._create_config_combo(form, "Max Frames Multiplier:", "max_frames_multiplier", [(f"x{i}", i) for i in range(1, 8)], 1) + checkpoints_paths_text = "\n".join(self.wgp.server_config.get("checkpoints_paths", self.wgp.fl.default_checkpoints_paths)) + checkpoints_textbox = QTextEdit() + checkpoints_textbox.setPlainText(checkpoints_paths_text) + checkpoints_textbox.setAcceptRichText(False) + checkpoints_textbox.setMinimumHeight(60) + self.widgets['config_checkpoints_paths'] = checkpoints_textbox + form.addRow("Checkpoints Paths:", checkpoints_textbox) + self._create_config_combo(form, "UI Theme (requires restart):", "UI_theme", [("Blue Sky", "default"), ("Classic Gradio", "gradio")], "default") + return tab + + def _create_performance_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Transformer Quantization:", "transformer_quantization", [("Scaled Int8 (recommended)", "int8"), ("16-bit (no quantization)", "bf16")], "int8") + self._create_config_combo(form, "Transformer Data Type:", "transformer_dtype_policy", [("Best Supported by Hardware", ""), ("FP16", "fp16"), ("BF16", "bf16")], "") + self._create_config_combo(form, "Transformer Calculation:", "mixed_precision", [("16-bit only", "0"), ("Mixed 16/32-bit (better quality)", "1")], "0") + self._create_config_combo(form, "Text Encoder:", "text_encoder_quantization", [("16-bit (more RAM, better quality)", "bf16"), ("8-bit (less RAM)", "int8")], "int8") + self._create_config_combo(form, "VAE Precision:", "vae_precision", [("16-bit (faster, less VRAM)", "16"), ("32-bit (slower, better quality)", "32")], "16") + self._create_config_combo(form, "Compile Transformer:", "compile", [("On (requires Triton)", "transformer"), ("Off", "")], "") + self._create_config_combo(form, "DepthAnything v2 Variant:", "depth_anything_v2_variant", [("Large (more precise)", "vitl"), ("Big (faster)", "vitb")], "vitl") + self._create_config_combo(form, "VAE Tiling:", "vae_config", [("Auto", 0), ("Disabled", 1), ("256x256 (~8GB VRAM)", 2), ("128x128 (~6GB VRAM)", 3)], 0) + self._create_config_combo(form, "Boost:", "boost", [("On", 1), ("Off", 2)], 1) + self._create_config_combo(form, "Memory Profile:", "profile", self.wgp.memory_profile_choices, self.wgp.profile_type.LowRAM_LowVRAM) + self._create_config_slider(form, "Preload in VRAM (MB):", "preload_in_VRAM", 0, 40000, 0, 100) + release_ram_btn = QPushButton("Force Release Models from RAM") + release_ram_btn.clicked.connect(self._on_release_ram) + form.addRow(release_ram_btn) + return tab + + def _create_extensions_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Prompt Enhancer:", "enhancer_enabled", [("Off", 0), ("Florence 2 + Llama 3.2", 1), ("Florence 2 + Joy Caption (uncensored)", 2)], 0) + self._create_config_combo(form, "Enhancer Mode:", "enhancer_mode", [("Automatic on Generate", 0), ("On Demand Only", 1)], 0) + self._create_config_combo(form, "MMAudio:", "mmaudio_enabled", [("Off", 0), ("Enabled (unloaded after use)", 1), ("Enabled (persistent in RAM)", 2)], 0) + return tab + + def _create_outputs_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Video Codec:", "video_output_codec", [("x265 Balanced", 'libx265_28'), ("x264 Balanced", 'libx264_8'), ("x265 High Quality", 'libx265_8'), ("x264 High Quality", 'libx264_10'), ("x264 Lossless", 'libx264_lossless')], 'libx264_8') + self._create_config_combo(form, "Image Codec:", "image_output_codec", [("JPEG Q85", 'jpeg_85'), ("WEBP Q85", 'webp_85'), ("JPEG Q95", 'jpeg_95'), ("WEBP Q95", 'webp_95'), ("WEBP Lossless", 'webp_lossless'), ("PNG Lossless", 'png')], 'jpeg_95') + self._create_config_textbox(form, "Video Output Folder:", "save_path", "outputs") + self._create_config_textbox(form, "Image Output Folder:", "image_save_path", "outputs") + return tab + + def _create_notifications_config_tab(self): + tab, form = self._create_scrollable_form_tab() + self._create_config_combo(form, "Notification Sound:", "notification_sound_enabled", [("On", 1), ("Off", 0)], 0) + self._create_config_slider(form, "Sound Volume:", "notification_sound_volume", 0, 100, 50, 5) + return tab + + def init_wgp_state(self): + wgp = self.wgp + initial_model = wgp.server_config.get("last_model_type", wgp.transformer_type) + dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types + _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) + all_model_ids = [m[1] for m in all_models] + if initial_model not in all_model_ids: initial_model = wgp.transformer_type + state_dict = {} + state_dict["model_filename"] = wgp.get_model_filename(initial_model, wgp.transformer_quantization, wgp.transformer_dtype_policy) + state_dict["model_type"] = initial_model + state_dict["advanced"] = wgp.advanced + state_dict["last_model_per_family"] = wgp.server_config.get("last_model_per_family", {}) + state_dict["last_model_per_type"] = wgp.server_config.get("last_model_per_type", {}) + state_dict["last_resolution_per_group"] = wgp.server_config.get("last_resolution_per_group", {}) + state_dict["gen"] = {"queue": []} + self.state = state_dict + self.advanced_group.setChecked(wgp.advanced) + self.update_model_dropdowns(initial_model) + self.refresh_ui_from_model_change(initial_model) + self._update_input_visibility() + + def update_model_dropdowns(self, current_model_type): + wgp = self.wgp + family_mock, base_type_mock, choice_mock = wgp.generate_dropdown_model_list(current_model_type) + for combo_name, mock in [('model_family', family_mock), ('model_base_type_choice', base_type_mock), ('model_choice', choice_mock)]: + combo = self.widgets[combo_name] + combo.blockSignals(True) + combo.clear() + if mock.choices: + for display_name, internal_key in mock.choices: combo.addItem(display_name, internal_key) + index = combo.findData(mock.value) + if index != -1: combo.setCurrentIndex(index) + + is_visible = True + if hasattr(mock, 'kwargs') and isinstance(mock.kwargs, dict): + is_visible = mock.kwargs.get('visible', True) + elif hasattr(mock, 'visible'): + is_visible = mock.visible + combo.setVisible(is_visible) + + combo.blockSignals(False) + + def refresh_ui_from_model_change(self, model_type): + """Update UI controls with default settings when the model is changed.""" + wgp = self.wgp + self.header_info.setText(wgp.generate_header(model_type, wgp.compile, wgp.attention_mode)) + ui_defaults = wgp.get_default_settings(model_type) + wgp.set_model_settings(self.state, model_type, ui_defaults) + + model_def = wgp.get_model_def(model_type) + base_model_type = wgp.get_base_model_type(model_type) + model_filename = self.state.get('model_filename', '') + + image_outputs = model_def.get("image_outputs", False) + vace = wgp.test_vace_module(model_type) + t2v = base_model_type in ['t2v', 't2v_2_2'] + i2v = wgp.test_class_i2v(model_type) + fantasy = base_model_type in ["fantasy"] + multitalk = model_def.get("multitalk_class", False) + any_audio_guidance = fantasy or multitalk + sliding_window_enabled = wgp.test_any_sliding_window(model_type) + recammaster = base_model_type in ["recam_1.3B"] + ltxv = "ltxv" in model_filename + diffusion_forcing = "diffusion_forcing" in model_filename + any_skip_layer_guidance = model_def.get("skip_layer_guidance", False) + any_cfg_zero = model_def.get("cfg_zero", False) + any_cfg_star = model_def.get("cfg_star", False) + any_apg = model_def.get("adaptive_projected_guidance", False) + v2i_switch_supported = model_def.get("v2i_switch_supported", False) + + self._update_generation_mode_visibility(model_def) + + for widget in self.widgets.values(): + if hasattr(widget, 'blockSignals'): widget.blockSignals(True) + + self.widgets['prompt'].setText(ui_defaults.get("prompt", "")) + self.widgets['negative_prompt'].setText(ui_defaults.get("negative_prompt", "")) + self.widgets['seed'].setText(str(ui_defaults.get("seed", -1))) + + video_length_val = ui_defaults.get("video_length", 81) + self.widgets['video_length'].setValue(video_length_val) + self.widgets['video_length_label'].setText(str(video_length_val)) + + steps_val = ui_defaults.get("num_inference_steps", 30) + self.widgets['num_inference_steps'].setValue(steps_val) + self.widgets['num_inference_steps_label'].setText(str(steps_val)) + + self.widgets['resolution_group'].blockSignals(True) + self.widgets['resolution'].blockSignals(True) + + current_res_choice = ui_defaults.get("resolution") + model_resolutions = model_def.get("resolutions", None) + self.full_resolution_choices, current_res_choice = wgp.get_resolution_choices(current_res_choice, model_resolutions) + available_groups, selected_group_resolutions, selected_group = wgp.group_resolutions(model_def, self.full_resolution_choices, current_res_choice) + + self.widgets['resolution_group'].clear() + self.widgets['resolution_group'].addItems(available_groups) + group_index = self.widgets['resolution_group'].findText(selected_group) + if group_index != -1: + self.widgets['resolution_group'].setCurrentIndex(group_index) + + self.widgets['resolution'].clear() + for label, value in selected_group_resolutions: + self.widgets['resolution'].addItem(label, value) + res_index = self.widgets['resolution'].findData(current_res_choice) + if res_index != -1: + self.widgets['resolution'].setCurrentIndex(res_index) + + self.widgets['resolution_group'].blockSignals(False) + self.widgets['resolution'].blockSignals(False) + + for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'image_refs', 'audio_source']: + if name in self.widgets: self.widgets[name].clear() + + guidance_layout = self.widgets['guidance_layout'] + guidance_max = model_def.get("guidance_max_phases", 1) + guidance_layout.setRowVisible(self.widgets['guidance_phases_row_index'], guidance_max > 1) + + adv_general_layout = self.widgets['adv_general_layout'] + adv_general_layout.setRowVisible(self.widgets['flow_shift_row_index'], not image_outputs) + adv_general_layout.setRowVisible(self.widgets['audio_guidance_row_index'], any_audio_guidance) + adv_general_layout.setRowVisible(self.widgets['repeat_generation_row_index'], not image_outputs) + adv_general_layout.setRowVisible(self.widgets['multi_images_gen_type_row_index'], i2v) + + self.widgets['slg_group'].setVisible(any_skip_layer_guidance) + quality_form_layout = self.widgets['quality_form_layout'] + quality_form_layout.setRowVisible(self.widgets['apg_switch_row_index'], any_apg) + quality_form_layout.setRowVisible(self.widgets['cfg_star_switch_row_index'], any_cfg_star) + quality_form_layout.setRowVisible(self.widgets['cfg_zero_step_row_index'], any_cfg_zero) + quality_form_layout.setRowVisible(self.widgets['min_frames_if_references_row_index'], v2i_switch_supported and image_outputs) + + self.widgets['advanced_tabs'].setTabVisible(self.widgets['sliding_window_tab_index'], sliding_window_enabled and not image_outputs) + + misc_layout = self.widgets['misc_layout'] + misc_layout.setRowVisible(self.widgets['riflex_row_index'], not (recammaster or ltxv or diffusion_forcing)) + + index = self.widgets['multi_images_gen_type'].findData(ui_defaults.get('multi_images_gen_type', 0)) + if index != -1: self.widgets['multi_images_gen_type'].setCurrentIndex(index) + + guidance_val = ui_defaults.get("guidance_scale", 5.0) + self.widgets['guidance_scale'].setValue(int(guidance_val * 10)) + self.widgets['guidance_scale_label'].setText(f"{guidance_val:.1f}") + + guidance2_val = ui_defaults.get("guidance2_scale", 5.0) + self.widgets['guidance2_scale'].setValue(int(guidance2_val * 10)) + self.widgets['guidance2_scale_label'].setText(f"{guidance2_val:.1f}") + + guidance3_val = ui_defaults.get("guidance3_scale", 5.0) + self.widgets['guidance3_scale'].setValue(int(guidance3_val * 10)) + self.widgets['guidance3_scale_label'].setText(f"{guidance3_val:.1f}") + + self.widgets['guidance_phases'].clear() + if guidance_max >= 1: self.widgets['guidance_phases'].addItem("One Phase", 1) + if guidance_max >= 2: self.widgets['guidance_phases'].addItem("Two Phases", 2) + if guidance_max >= 3: self.widgets['guidance_phases'].addItem("Three Phases", 3) + index = self.widgets['guidance_phases'].findData(ui_defaults.get("guidance_phases", 1)) + if index != -1: self.widgets['guidance_phases'].setCurrentIndex(index) + + switch_thresh_val = ui_defaults.get("switch_threshold", 0) + self.widgets['switch_threshold'].setValue(switch_thresh_val) + self.widgets['switch_threshold_label'].setText(str(switch_thresh_val)) + + nag_scale_val = ui_defaults.get('NAG_scale', 1.0) + self.widgets['NAG_scale'].setValue(int(nag_scale_val * 10)) + self.widgets['NAG_scale_label'].setText(f"{nag_scale_val:.1f}") + + nag_tau_val = ui_defaults.get('NAG_tau', 3.5) + self.widgets['NAG_tau'].setValue(int(nag_tau_val * 10)) + self.widgets['NAG_tau_label'].setText(f"{nag_tau_val:.1f}") + + nag_alpha_val = ui_defaults.get('NAG_alpha', 0.5) + self.widgets['NAG_alpha'].setValue(int(nag_alpha_val * 10)) + self.widgets['NAG_alpha_label'].setText(f"{nag_alpha_val:.1f}") + + self.widgets['nag_group'].setVisible(vace or t2v or i2v) + + self.widgets['sample_solver'].clear() + sampler_choices = model_def.get("sample_solvers", []) + self.widgets['solver_row_container'].setVisible(bool(sampler_choices)) + if sampler_choices: + for label, value in sampler_choices: self.widgets['sample_solver'].addItem(label, value) + solver_val = ui_defaults.get('sample_solver', sampler_choices[0][1]) + index = self.widgets['sample_solver'].findData(solver_val) + if index != -1: self.widgets['sample_solver'].setCurrentIndex(index) + + flow_val = ui_defaults.get("flow_shift", 3.0) + self.widgets['flow_shift'].setValue(int(flow_val * 10)) + self.widgets['flow_shift_label'].setText(f"{flow_val:.1f}") + + audio_guidance_val = ui_defaults.get("audio_guidance_scale", 4.0) + self.widgets['audio_guidance_scale'].setValue(int(audio_guidance_val * 10)) + self.widgets['audio_guidance_scale_label'].setText(f"{audio_guidance_val:.1f}") + + repeat_val = ui_defaults.get("repeat_generation", 1) + self.widgets['repeat_generation'].setValue(repeat_val) + self.widgets['repeat_generation_label'].setText(str(repeat_val)) + + available_loras, _, _, _, _, _ = wgp.setup_loras(model_type, None, wgp.get_lora_dir(model_type), "") + self.state['loras'] = available_loras + self.lora_map = {os.path.basename(p): p for p in available_loras} + lora_list_widget = self.widgets['activated_loras'] + lora_list_widget.clear() + lora_list_widget.addItems(sorted(self.lora_map.keys())) + selected_loras = ui_defaults.get('activated_loras', []) + for i in range(lora_list_widget.count()): + item = lora_list_widget.item(i) + if any(item.text() == os.path.basename(p) for p in selected_loras): item.setSelected(True) + self.widgets['loras_multipliers'].setText(ui_defaults.get('loras_multipliers', '')) + + skip_cache_val = ui_defaults.get('skip_steps_cache_type', "") + index = self.widgets['skip_steps_cache_type'].findData(skip_cache_val) + if index != -1: self.widgets['skip_steps_cache_type'].setCurrentIndex(index) + + skip_mult = ui_defaults.get('skip_steps_multiplier', 1.5) + index = self.widgets['skip_steps_multiplier'].findData(skip_mult) + if index != -1: self.widgets['skip_steps_multiplier'].setCurrentIndex(index) + + skip_perc_val = ui_defaults.get('skip_steps_start_step_perc', 0) + self.widgets['skip_steps_start_step_perc'].setValue(skip_perc_val) + self.widgets['skip_steps_start_step_perc_label'].setText(str(skip_perc_val)) + + temp_up_val = ui_defaults.get('temporal_upsampling', "") + index = self.widgets['temporal_upsampling'].findData(temp_up_val) + if index != -1: self.widgets['temporal_upsampling'].setCurrentIndex(index) + + spat_up_val = ui_defaults.get('spatial_upsampling', "") + index = self.widgets['spatial_upsampling'].findData(spat_up_val) + if index != -1: self.widgets['spatial_upsampling'].setCurrentIndex(index) + + film_grain_i = ui_defaults.get('film_grain_intensity', 0) + self.widgets['film_grain_intensity'].setValue(int(film_grain_i * 100)) + self.widgets['film_grain_intensity_label'].setText(f"{film_grain_i:.2f}") + + film_grain_s = ui_defaults.get('film_grain_saturation', 0.5) + self.widgets['film_grain_saturation'].setValue(int(film_grain_s * 100)) + self.widgets['film_grain_saturation_label'].setText(f"{film_grain_s:.2f}") + + self.widgets['MMAudio_setting'].setCurrentIndex(ui_defaults.get('MMAudio_setting', 0)) + self.widgets['MMAudio_prompt'].setText(ui_defaults.get('MMAudio_prompt', '')) + self.widgets['MMAudio_neg_prompt'].setText(ui_defaults.get('MMAudio_neg_prompt', '')) + + self.widgets['slg_switch'].setCurrentIndex(ui_defaults.get('slg_switch', 0)) + slg_start_val = ui_defaults.get('slg_start_perc', 10) + self.widgets['slg_start_perc'].setValue(slg_start_val) + self.widgets['slg_start_perc_label'].setText(str(slg_start_val)) + slg_end_val = ui_defaults.get('slg_end_perc', 90) + self.widgets['slg_end_perc'].setValue(slg_end_val) + self.widgets['slg_end_perc_label'].setText(str(slg_end_val)) + + self.widgets['apg_switch'].setCurrentIndex(ui_defaults.get('apg_switch', 0)) + self.widgets['cfg_star_switch'].setCurrentIndex(ui_defaults.get('cfg_star_switch', 0)) + + cfg_zero_val = ui_defaults.get('cfg_zero_step', -1) + self.widgets['cfg_zero_step'].setValue(cfg_zero_val) + self.widgets['cfg_zero_step_label'].setText(str(cfg_zero_val)) + + min_frames_val = ui_defaults.get('min_frames_if_references', 1) + index = self.widgets['min_frames_if_references'].findData(min_frames_val) + if index != -1: self.widgets['min_frames_if_references'].setCurrentIndex(index) + + self.widgets['RIFLEx_setting'].setCurrentIndex(ui_defaults.get('RIFLEx_setting', 0)) + + fps = wgp.get_model_fps(model_type) + force_fps_choices = [ + (f"Model Default ({fps} fps)", ""), ("Auto", "auto"), ("Control Video fps", "control"), + ("Source Video fps", "source"), ("15", "15"), ("16", "16"), ("23", "23"), + ("24", "24"), ("25", "25"), ("30", "30") + ] + self.widgets['force_fps'].clear() + for label, value in force_fps_choices: self.widgets['force_fps'].addItem(label, value) + force_fps_val = ui_defaults.get('force_fps', "") + index = self.widgets['force_fps'].findData(force_fps_val) + if index != -1: self.widgets['force_fps'].setCurrentIndex(index) + + override_prof_val = ui_defaults.get('override_profile', -1) + index = self.widgets['override_profile'].findData(override_prof_val) + if index != -1: self.widgets['override_profile'].setCurrentIndex(index) + + self.widgets['multi_prompts_gen_type'].setCurrentIndex(ui_defaults.get('multi_prompts_gen_type', 0)) + + denoising_val = ui_defaults.get("denoising_strength", 0.5) + self.widgets['denoising_strength'].setValue(int(denoising_val * 100)) + self.widgets['denoising_strength_label'].setText(f"{denoising_val:.2f}") + + sw_size = ui_defaults.get("sliding_window_size", 129) + self.widgets['sliding_window_size'].setValue(sw_size) + self.widgets['sliding_window_size_label'].setText(str(sw_size)) + + sw_overlap = ui_defaults.get("sliding_window_overlap", 5) + self.widgets['sliding_window_overlap'].setValue(sw_overlap) + self.widgets['sliding_window_overlap_label'].setText(str(sw_overlap)) + + sw_color = ui_defaults.get("sliding_window_color_correction_strength", 0) + self.widgets['sliding_window_color_correction_strength'].setValue(int(sw_color * 100)) + self.widgets['sliding_window_color_correction_strength_label'].setText(f"{sw_color:.2f}") + + sw_noise = ui_defaults.get("sliding_window_overlap_noise", 20) + self.widgets['sliding_window_overlap_noise'].setValue(sw_noise) + self.widgets['sliding_window_overlap_noise_label'].setText(str(sw_noise)) + + sw_discard = ui_defaults.get("sliding_window_discard_last_frames", 0) + self.widgets['sliding_window_discard_last_frames'].setValue(sw_discard) + self.widgets['sliding_window_discard_last_frames_label'].setText(str(sw_discard)) + + for widget in self.widgets.values(): + if hasattr(widget, 'blockSignals'): widget.blockSignals(False) + + self._update_dynamic_ui() + self._update_input_visibility() + + def _update_dynamic_ui(self): + phases = self.widgets['guidance_phases'].currentData() or 1 + guidance_layout = self.widgets['guidance_layout'] + guidance_layout.setRowVisible(self.widgets['guidance2_row_index'], phases >= 2) + guidance_layout.setRowVisible(self.widgets['guidance3_row_index'], phases >= 3) + guidance_layout.setRowVisible(self.widgets['switch_thresh_row_index'], phases >= 2) + + def _update_generation_mode_visibility(self, model_def): + allowed = model_def.get("image_prompt_types_allowed", "") + choices = [] + if "T" in allowed or not allowed: choices.append(("Text Prompt Only" if "S" in allowed else "New Video", "T")) + if "S" in allowed: choices.append(("Start Video with Image", "S")) + if "V" in allowed: choices.append(("Continue Video", "V")) + if "L" in allowed: choices.append(("Continue Last Video", "L")) + button_map = { "T": self.widgets['mode_t'], "S": self.widgets['mode_s'], "V": self.widgets['mode_v'], "L": self.widgets['mode_l'] } + for btn in button_map.values(): btn.setVisible(False) + allowed_values = [c[1] for c in choices] + for label, value in choices: + if value in button_map: + btn = button_map[value] + btn.setText(label) + btn.setVisible(True) + current_checked_value = next((value for value, btn in button_map.items() if btn.isChecked()), None) + if current_checked_value is None or not button_map[current_checked_value].isVisible(): + if allowed_values: button_map[allowed_values[0]].setChecked(True) + end_image_visible = "E" in allowed + self.widgets['image_end_checkbox'].setVisible(end_image_visible) + if not end_image_visible: self.widgets['image_end_checkbox'].setChecked(False) + control_video_visible = model_def.get("guide_preprocessing") is not None + self.widgets['control_video_checkbox'].setVisible(control_video_visible) + if not control_video_visible: self.widgets['control_video_checkbox'].setChecked(False) + ref_image_visible = model_def.get("image_ref_choices") is not None + self.widgets['ref_image_checkbox'].setVisible(ref_image_visible) + if not ref_image_visible: self.widgets['ref_image_checkbox'].setChecked(False) + + def _update_input_visibility(self): + is_s_mode = self.widgets['mode_s'].isChecked() + is_v_mode = self.widgets['mode_v'].isChecked() + is_l_mode = self.widgets['mode_l'].isChecked() + use_end = self.widgets['image_end_checkbox'].isChecked() and self.widgets['image_end_checkbox'].isVisible() + use_control = self.widgets['control_video_checkbox'].isChecked() and self.widgets['control_video_checkbox'].isVisible() + use_ref = self.widgets['ref_image_checkbox'].isChecked() and self.widgets['ref_image_checkbox'].isVisible() + self.widgets['image_start_container'].setVisible(is_s_mode) + self.widgets['video_source_container'].setVisible(is_v_mode) + end_checkbox_enabled = is_s_mode or is_v_mode or is_l_mode + self.widgets['image_end_checkbox'].setEnabled(end_checkbox_enabled) + self.widgets['image_end_container'].setVisible(use_end and end_checkbox_enabled) + self.widgets['video_guide_container'].setVisible(use_control) + self.widgets['video_mask_container'].setVisible(use_control) + self.widgets['image_refs_container'].setVisible(use_ref) + + def connect_signals(self): + self.widgets['model_family'].currentIndexChanged.connect(self._on_family_changed) + self.widgets['model_base_type_choice'].currentIndexChanged.connect(self._on_base_type_changed) + self.widgets['model_choice'].currentIndexChanged.connect(self._on_model_changed) + self.widgets['resolution_group'].currentIndexChanged.connect(self._on_resolution_group_changed) + self.widgets['guidance_phases'].currentIndexChanged.connect(self._update_dynamic_ui) + self.widgets['mode_t'].toggled.connect(self._update_input_visibility) + self.widgets['mode_s'].toggled.connect(self._update_input_visibility) + self.widgets['mode_v'].toggled.connect(self._update_input_visibility) + self.widgets['mode_l'].toggled.connect(self._update_input_visibility) + self.widgets['image_end_checkbox'].toggled.connect(self._update_input_visibility) + self.widgets['control_video_checkbox'].toggled.connect(self._update_input_visibility) + self.widgets['ref_image_checkbox'].toggled.connect(self._update_input_visibility) + self.widgets['preview_group'].toggled.connect(self._on_preview_toggled) + self.generate_btn.clicked.connect(self._on_generate) + self.add_to_queue_btn.clicked.connect(self._on_add_to_queue) + self.remove_queue_btn.clicked.connect(self._on_remove_selected_from_queue) + self.clear_queue_btn.clicked.connect(self._on_clear_queue) + self.abort_btn.clicked.connect(self._on_abort) + self.queue_table.rowsMoved.connect(self._on_queue_rows_moved) + + def load_main_config(self): + try: + with open('main_config.json', 'r') as f: self.main_config = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): self.main_config = {'preview_visible': False} + + def save_main_config(self): + try: + with open('main_config.json', 'w') as f: json.dump(self.main_config, f, indent=4) + except Exception as e: print(f"Error saving main_config.json: {e}") + + def apply_initial_config(self): + is_visible = self.main_config.get('preview_visible', True) + self.widgets['preview_group'].setChecked(is_visible) + self.widgets['preview_image'].setVisible(is_visible) + + def _on_preview_toggled(self, checked): + self.widgets['preview_image'].setVisible(checked) + self.main_config['preview_visible'] = checked + self.save_main_config() + + def _on_family_changed(self): + family = self.widgets['model_family'].currentData() + if not family or not self.state: return + base_type_mock, choice_mock = self.wgp.change_model_family(self.state, family) + + if hasattr(base_type_mock, 'kwargs') and isinstance(base_type_mock.kwargs, dict): + is_visible_base = base_type_mock.kwargs.get('visible', True) + elif hasattr(base_type_mock, 'visible'): + is_visible_base = base_type_mock.visible + else: + is_visible_base = True + + self.widgets['model_base_type_choice'].blockSignals(True) + self.widgets['model_base_type_choice'].clear() + if base_type_mock.choices: + for label, value in base_type_mock.choices: self.widgets['model_base_type_choice'].addItem(label, value) + self.widgets['model_base_type_choice'].setCurrentIndex(self.widgets['model_base_type_choice'].findData(base_type_mock.value)) + self.widgets['model_base_type_choice'].setVisible(is_visible_base) + self.widgets['model_base_type_choice'].blockSignals(False) + + if hasattr(choice_mock, 'kwargs') and isinstance(choice_mock.kwargs, dict): + is_visible_choice = choice_mock.kwargs.get('visible', True) + elif hasattr(choice_mock, 'visible'): + is_visible_choice = choice_mock.visible + else: + is_visible_choice = True + + self.widgets['model_choice'].blockSignals(True) + self.widgets['model_choice'].clear() + if choice_mock.choices: + for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) + self.widgets['model_choice'].setCurrentIndex(self.widgets['model_choice'].findData(choice_mock.value)) + self.widgets['model_choice'].setVisible(is_visible_choice) + self.widgets['model_choice'].blockSignals(False) + + self._on_model_changed() + + def _on_base_type_changed(self): + family = self.widgets['model_family'].currentData() + base_type = self.widgets['model_base_type_choice'].currentData() + if not family or not base_type or not self.state: return + base_type_mock, choice_mock = self.wgp.change_model_base_types(self.state, family, base_type) + + if hasattr(choice_mock, 'kwargs') and isinstance(choice_mock.kwargs, dict): + is_visible_choice = choice_mock.kwargs.get('visible', True) + elif hasattr(choice_mock, 'visible'): + is_visible_choice = choice_mock.visible + else: + is_visible_choice = True + + self.widgets['model_choice'].blockSignals(True) + self.widgets['model_choice'].clear() + if choice_mock.choices: + for label, value in choice_mock.choices: self.widgets['model_choice'].addItem(label, value) + self.widgets['model_choice'].setCurrentIndex(self.widgets['model_choice'].findData(choice_mock.value)) + self.widgets['model_choice'].setVisible(is_visible_choice) + self.widgets['model_choice'].blockSignals(False) + self._on_model_changed() + + def _on_model_changed(self): + model_type = self.widgets['model_choice'].currentData() + if not model_type or model_type == self.state.get('model_type'): return + self.wgp.change_model(self.state, model_type) + self.refresh_ui_from_model_change(model_type) + + def _on_resolution_group_changed(self): + selected_group = self.widgets['resolution_group'].currentText() + if not selected_group or not hasattr(self, 'full_resolution_choices'): return + model_type = self.state['model_type'] + model_def = self.wgp.get_model_def(model_type) + model_resolutions = model_def.get("resolutions", None) + group_resolution_choices = [] + if model_resolutions is None: + group_resolution_choices = [res for res in self.full_resolution_choices if self.wgp.categorize_resolution(res[1]) == selected_group] + else: return + last_resolution = self.state.get("last_resolution_per_group", {}).get(selected_group, "") + if not any(last_resolution == res[1] for res in group_resolution_choices) and group_resolution_choices: + last_resolution = group_resolution_choices[0][1] + self.widgets['resolution'].blockSignals(True) + self.widgets['resolution'].clear() + for label, value in group_resolution_choices: self.widgets['resolution'].addItem(label, value) + self.widgets['resolution'].setCurrentIndex(self.widgets['resolution'].findData(last_resolution)) + self.widgets['resolution'].blockSignals(False) + + def collect_inputs(self): + full_inputs = self.wgp.get_current_model_settings(self.state).copy() + full_inputs['lset_name'] = "" + full_inputs['image_mode'] = 0 + expected_keys = { "audio_guide": None, "audio_guide2": None, "image_guide": None, "image_mask": None, "speakers_locations": "", "frames_positions": "", "keep_frames_video_guide": "", "keep_frames_video_source": "", "video_guide_outpainting": "", "switch_threshold2": 0, "model_switch_phase": 1, "batch_size": 1, "control_net_weight_alt": 1.0, "image_refs_relative_size": 50, } + for key, default_value in expected_keys.items(): + if key not in full_inputs: full_inputs[key] = default_value + full_inputs['prompt'] = self.widgets['prompt'].toPlainText() + full_inputs['negative_prompt'] = self.widgets['negative_prompt'].toPlainText() + full_inputs['resolution'] = self.widgets['resolution'].currentData() + full_inputs['video_length'] = self.widgets['video_length'].value() + full_inputs['num_inference_steps'] = self.widgets['num_inference_steps'].value() + full_inputs['seed'] = int(self.widgets['seed'].text()) + image_prompt_type = "" + video_prompt_type = "" + if self.widgets['mode_s'].isChecked(): image_prompt_type = 'S' + elif self.widgets['mode_v'].isChecked(): image_prompt_type = 'V' + elif self.widgets['mode_l'].isChecked(): image_prompt_type = 'L' + if self.widgets['image_end_checkbox'].isVisible() and self.widgets['image_end_checkbox'].isChecked(): image_prompt_type += 'E' + if self.widgets['control_video_checkbox'].isVisible() and self.widgets['control_video_checkbox'].isChecked(): video_prompt_type += 'V' + if self.widgets['ref_image_checkbox'].isVisible() and self.widgets['ref_image_checkbox'].isChecked(): video_prompt_type += 'I' + full_inputs['image_prompt_type'] = image_prompt_type + full_inputs['video_prompt_type'] = video_prompt_type + for name in ['video_source', 'image_start', 'image_end', 'video_guide', 'video_mask', 'audio_source']: + if name in self.widgets: full_inputs[name] = self.widgets[name].text() or None + paths = self.widgets['image_refs'].text().split(';') + full_inputs['image_refs'] = [p.strip() for p in paths if p.strip()] if paths and paths[0] else None + full_inputs['denoising_strength'] = self.widgets['denoising_strength'].value() / 100.0 + if self.advanced_group.isChecked(): + full_inputs['guidance_scale'] = self.widgets['guidance_scale'].value() / 10.0 + full_inputs['guidance_phases'] = self.widgets['guidance_phases'].currentData() + full_inputs['guidance2_scale'] = self.widgets['guidance2_scale'].value() / 10.0 + full_inputs['guidance3_scale'] = self.widgets['guidance3_scale'].value() / 10.0 + full_inputs['switch_threshold'] = self.widgets['switch_threshold'].value() + full_inputs['NAG_scale'] = self.widgets['NAG_scale'].value() / 10.0 + full_inputs['NAG_tau'] = self.widgets['NAG_tau'].value() / 10.0 + full_inputs['NAG_alpha'] = self.widgets['NAG_alpha'].value() / 10.0 + full_inputs['sample_solver'] = self.widgets['sample_solver'].currentData() + full_inputs['flow_shift'] = self.widgets['flow_shift'].value() / 10.0 + full_inputs['audio_guidance_scale'] = self.widgets['audio_guidance_scale'].value() / 10.0 + full_inputs['repeat_generation'] = self.widgets['repeat_generation'].value() + full_inputs['multi_images_gen_type'] = self.widgets['multi_images_gen_type'].currentData() + selected_items = self.widgets['activated_loras'].selectedItems() + full_inputs['activated_loras'] = [self.lora_map[item.text()] for item in selected_items if item.text() in self.lora_map] + full_inputs['loras_multipliers'] = self.widgets['loras_multipliers'].toPlainText() + full_inputs['skip_steps_cache_type'] = self.widgets['skip_steps_cache_type'].currentData() + full_inputs['skip_steps_multiplier'] = self.widgets['skip_steps_multiplier'].currentData() + full_inputs['skip_steps_start_step_perc'] = self.widgets['skip_steps_start_step_perc'].value() + full_inputs['temporal_upsampling'] = self.widgets['temporal_upsampling'].currentData() + full_inputs['spatial_upsampling'] = self.widgets['spatial_upsampling'].currentData() + full_inputs['film_grain_intensity'] = self.widgets['film_grain_intensity'].value() / 100.0 + full_inputs['film_grain_saturation'] = self.widgets['film_grain_saturation'].value() / 100.0 + full_inputs['MMAudio_setting'] = self.widgets['MMAudio_setting'].currentData() + full_inputs['MMAudio_prompt'] = self.widgets['MMAudio_prompt'].text() + full_inputs['MMAudio_neg_prompt'] = self.widgets['MMAudio_neg_prompt'].text() + full_inputs['RIFLEx_setting'] = self.widgets['RIFLEx_setting'].currentData() + full_inputs['force_fps'] = self.widgets['force_fps'].currentData() + full_inputs['override_profile'] = self.widgets['override_profile'].currentData() + full_inputs['multi_prompts_gen_type'] = self.widgets['multi_prompts_gen_type'].currentData() + full_inputs['slg_switch'] = self.widgets['slg_switch'].currentData() + full_inputs['slg_start_perc'] = self.widgets['slg_start_perc'].value() + full_inputs['slg_end_perc'] = self.widgets['slg_end_perc'].value() + full_inputs['apg_switch'] = self.widgets['apg_switch'].currentData() + full_inputs['cfg_star_switch'] = self.widgets['cfg_star_switch'].currentData() + full_inputs['cfg_zero_step'] = self.widgets['cfg_zero_step'].value() + full_inputs['min_frames_if_references'] = self.widgets['min_frames_if_references'].currentData() + full_inputs['sliding_window_size'] = self.widgets['sliding_window_size'].value() + full_inputs['sliding_window_overlap'] = self.widgets['sliding_window_overlap'].value() + full_inputs['sliding_window_color_correction_strength'] = self.widgets['sliding_window_color_correction_strength'].value() / 100.0 + full_inputs['sliding_window_overlap_noise'] = self.widgets['sliding_window_overlap_noise'].value() + full_inputs['sliding_window_discard_last_frames'] = self.widgets['sliding_window_discard_last_frames'].value() + return full_inputs + + def _prepare_state_for_generation(self): + if 'gen' in self.state: + self.state['gen'].pop('abort', None) + self.state['gen'].pop('in_progress', None) + + def _on_generate(self): + try: + is_running = self.thread and self.thread.isRunning() + self._add_task_to_queue() + if not is_running: self.start_generation() + except Exception as e: + import traceback; traceback.print_exc() + + def _on_add_to_queue(self): + try: + self._add_task_to_queue() + except Exception as e: + import traceback; traceback.print_exc() + + def _add_task_to_queue(self): + all_inputs = self.collect_inputs() + for key in ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality', 'base_model_type']: all_inputs.pop(key, None) + all_inputs['state'] = self.state + self.wgp.set_model_settings(self.state, self.state['model_type'], all_inputs) + self.state["validate_success"] = 1 + self.wgp.process_prompt_and_add_tasks(self.state, self.state['model_type']) + self.update_queue_table() + + def start_generation(self): + if not self.state['gen']['queue']: return + self._prepare_state_for_generation() + self.generate_btn.setEnabled(False) + self.add_to_queue_btn.setEnabled(True) + self.thread = QThread() + self.worker = Worker(self.plugin, self.state) + self.worker.moveToThread(self.thread) + self.thread.started.connect(self.worker.run) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + self.thread.finished.connect(self.on_generation_finished) + self.worker.status.connect(self.status_label.setText) + self.worker.progress.connect(self.update_progress) + self.worker.preview.connect(self.update_preview) + self.worker.output.connect(self.update_queue_and_results) + self.worker.error.connect(self.on_generation_error) + self.thread.start() + self.update_queue_table() + + def on_generation_finished(self): + time.sleep(0.1) + self.status_label.setText("Finished.") + self.progress_bar.setValue(0) + self.generate_btn.setEnabled(True) + self.add_to_queue_btn.setEnabled(False) + self.thread = None; self.worker = None + self.update_queue_table() + + def on_generation_error(self, err_msg): + QMessageBox.critical(self, "Generation Error", str(err_msg)) + self.on_generation_finished() + + def update_progress(self, data): + if len(data) > 1 and isinstance(data[0], tuple): + step, total = data[0] + self.progress_bar.setMaximum(total) + self.progress_bar.setValue(step) + self.status_label.setText(str(data[1])) + if step <= 1: self.update_queue_table() + elif len(data) > 1: self.status_label.setText(str(data[1])) + + def update_preview(self, pil_image): + if pil_image and self.widgets['preview_group'].isChecked(): + q_image = ImageQt(pil_image) + pixmap = QPixmap.fromImage(q_image) + self.preview_image.setPixmap(pixmap.scaled(self.preview_image.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + + def update_queue_and_results(self): + self.update_queue_table() + file_list = self.state.get('gen', {}).get('file_list', []) + for file_path in file_list: + if file_path not in self.processed_files: + self.add_result_item(file_path) + self.processed_files.add(file_path) + + def add_result_item(self, video_path): + item_widget = VideoResultItemWidget(video_path, self.plugin) + list_item = QListWidgetItem(self.results_list) + list_item.setSizeHint(item_widget.sizeHint()) + self.results_list.addItem(list_item) + self.results_list.setItemWidget(list_item, item_widget) + + def update_queue_table(self): + with self.wgp.lock: + queue = self.state.get('gen', {}).get('queue', []) + is_running = self.thread and self.thread.isRunning() + queue_to_display = queue if is_running else [None] + queue + table_data = self.wgp.get_queue_table(queue_to_display) + self.queue_table.setRowCount(0) + self.queue_table.setRowCount(len(table_data)) + self.queue_table.setColumnCount(4) + self.queue_table.setHorizontalHeaderLabels(["Qty", "Prompt", "Length", "Steps"]) + for row_idx, row_data in enumerate(table_data): + prompt_text = str(row_data[1]).split('>')[1].split('<')[0] if '>' in str(row_data[1]) else str(row_data[1]) + for col_idx, cell_data in enumerate([row_data[0], prompt_text, row_data[2], row_data[3]]): + self.queue_table.setItem(row_idx, col_idx, QTableWidgetItem(str(cell_data))) + self.queue_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.queue_table.resizeColumnsToContents() + + def _on_remove_selected_from_queue(self): + selected_row = self.queue_table.currentRow() + if selected_row < 0: return + with self.wgp.lock: + is_running = self.thread and self.thread.isRunning() + offset = 1 if is_running else 0 + queue = self.state.get('gen', {}).get('queue', []) + if len(queue) > selected_row + offset: queue.pop(selected_row + offset) + self.update_queue_table() + + def _on_queue_rows_moved(self, source_row, dest_row): + with self.wgp.lock: + queue = self.state.get('gen', {}).get('queue', []) + is_running = self.thread and self.thread.isRunning() + offset = 1 if is_running else 0 + real_source_idx = source_row + offset + real_dest_idx = dest_row + offset + moved_item = queue.pop(real_source_idx) + queue.insert(real_dest_idx, moved_item) + self.update_queue_table() + + def _on_clear_queue(self): + self.wgp.clear_queue_action(self.state) + self.update_queue_table() + + def _on_abort(self): + if self.worker: + self.wgp.abort_generation(self.state) + self.status_label.setText("Aborting...") + self.worker._is_running = False + + def _on_release_ram(self): + self.wgp.release_RAM() + QMessageBox.information(self, "RAM Released", "Models stored in RAM have been released.") + + def _on_apply_config_changes(self): + changes = {} + list_widget = self.widgets['config_transformer_types'] + changes['transformer_types_choices'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] + list_widget = self.widgets['config_preload_model_policy'] + changes['preload_model_policy_choice'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] + changes['model_hierarchy_type_choice'] = self.widgets['config_model_hierarchy_type'].currentData() + changes['checkpoints_paths'] = self.widgets['config_checkpoints_paths'].toPlainText() + for key in ["fit_canvas", "attention_mode", "metadata_type", "clear_file_list", "display_stats", "max_frames_multiplier", "UI_theme"]: + changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() + for key in ["transformer_quantization", "transformer_dtype_policy", "mixed_precision", "text_encoder_quantization", "vae_precision", "compile", "depth_anything_v2_variant", "vae_config", "boost", "profile"]: + changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() + changes['preload_in_VRAM_choice'] = self.widgets['config_preload_in_VRAM'].value() + for key in ["enhancer_enabled", "enhancer_mode", "mmaudio_enabled"]: + changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() + for key in ["video_output_codec", "image_output_codec", "save_path", "image_save_path"]: + widget = self.widgets[f'config_{key}'] + changes[f'{key}_choice'] = widget.currentData() if isinstance(widget, QComboBox) else widget.text() + changes['notification_sound_enabled_choice'] = self.widgets['config_notification_sound_enabled'].currentData() + changes['notification_sound_volume_choice'] = self.widgets['config_notification_sound_volume'].value() + changes['last_resolution_choice'] = self.widgets['resolution'].currentData() + try: + msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = self.wgp.apply_changes(self.state, **changes) + self.config_status_label.setText("Changes applied successfully. Some settings may require a restart.") + self.header_info.setText(self.wgp.generate_header(self.state['model_type'], self.wgp.compile, self.wgp.attention_mode)) + if family_mock.choices is not None or choice_mock.choices is not None: + self.update_model_dropdowns(self.wgp.transformer_type) + self.refresh_ui_from_model_change(self.wgp.transformer_type) + except Exception as e: + self.config_status_label.setText(f"Error applying changes: {e}") + import traceback; traceback.print_exc() + +class Plugin(VideoEditorPlugin): + def initialize(self): + self.name = "AI Generator" + self.description = "Uses the integrated Wan2GP library to generate video clips." + self.client_widget = WgpDesktopPluginWidget(self) + self.dock_widget = None + self.active_region = None; self.temp_dir = None + self.insert_on_new_track = False; self.start_frame_path = None; self.end_frame_path = None + + def enable(self): + if not self.dock_widget: self.dock_widget = self.app.add_dock_widget(self, self.client_widget, self.name) + self.app.timeline_widget.context_menu_requested.connect(self.on_timeline_context_menu) + self.app.status_label.setText(f"{self.name}: Enabled.") + + def disable(self): + try: self.app.timeline_widget.context_menu_requested.disconnect(self.on_timeline_context_menu) + except TypeError: pass + self._cleanup_temp_dir() + if self.client_widget.worker: self.client_widget._on_abort() + self.app.status_label.setText(f"{self.name}: Disabled.") + + def _cleanup_temp_dir(self): + if self.temp_dir and os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + self.temp_dir = None + + def _reset_state(self): + self.active_region = None; self.insert_on_new_track = False + self.start_frame_path = None; self.end_frame_path = None + self.client_widget.processed_files.clear() + self.client_widget.results_list.clear() + self.client_widget.widgets['image_start'].clear() + self.client_widget.widgets['image_end'].clear() + self.client_widget.widgets['video_source'].clear() + self._cleanup_temp_dir() + self.app.status_label.setText(f"{self.name}: Ready.") + + def on_timeline_context_menu(self, menu, event): + region = self.app.timeline_widget.get_region_at_pos(event.pos()) + if region: + menu.addSeparator() + start_sec, end_sec = region + start_data, _, _ = self.app.get_frame_data_at_time(start_sec) + end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + if start_data and end_data: + join_action = menu.addAction("Join Frames With AI") + join_action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) + join_action_new_track = menu.addAction("Join Frames With AI (New Track)") + join_action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) + create_action = menu.addAction("Create Frames With AI") + create_action.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=False)) + create_action_new_track = menu.addAction("Create Frames With AI (New Track)") + create_action_new_track.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=True)) + + def setup_generator_for_region(self, region, on_new_track=False): + self._reset_state() + self.active_region = region + self.insert_on_new_track = on_new_track + + model_to_set = 'i2v_2_2' + dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types + _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + if any(model_to_set == m[1] for m in all_models): + if self.client_widget.state.get('model_type') != model_to_set: + self.client_widget.update_model_dropdowns(model_to_set) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") + + start_sec, end_sec = region + start_data, w, h = self.app.get_frame_data_at_time(start_sec) + end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + if not start_data or not end_data: + QMessageBox.warning(self.app, "Frame Error", "Could not extract start and/or end frames.") + return + try: + self.temp_dir = tempfile.mkdtemp(prefix="wgp_plugin_") + self.start_frame_path = os.path.join(self.temp_dir, "start_frame.png") + self.end_frame_path = os.path.join(self.temp_dir, "end_frame.png") + QImage(start_data, w, h, QImage.Format.Format_RGB888).save(self.start_frame_path) + QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) + + duration_sec = end_sec - start_sec + wgp = self.client_widget.wgp + model_type = self.client_widget.state['model_type'] + fps = wgp.get_model_fps(model_type) + video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + + self.client_widget.widgets['video_length'].setValue(video_length_frames) + self.client_widget.widgets['mode_s'].setChecked(True) + self.client_widget.widgets['image_end_checkbox'].setChecked(True) + self.client_widget.widgets['image_start'].setText(self.start_frame_path) + self.client_widget.widgets['image_end'].setText(self.end_frame_path) + + self.client_widget._update_input_visibility() + + except Exception as e: + QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame images: {e}") + self._cleanup_temp_dir() + return + self.app.status_label.setText(f"Ready to join frames from {start_sec:.2f}s to {end_sec:.2f}s.") + self.dock_widget.show() + self.dock_widget.raise_() + + def setup_creator_for_region(self, region, on_new_track=False): + self._reset_state() + self.active_region = region + self.insert_on_new_track = on_new_track + + model_to_set = 't2v_2_2' + dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types + _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + if any(model_to_set == m[1] for m in all_models): + if self.client_widget.state.get('model_type') != model_to_set: + self.client_widget.update_model_dropdowns(model_to_set) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Creator. Using current model.") + + start_sec, end_sec = region + duration_sec = end_sec - start_sec + wgp = self.client_widget.wgp + model_type = self.client_widget.state['model_type'] + fps = wgp.get_model_fps(model_type) + video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + + self.client_widget.widgets['video_length'].setValue(video_length_frames) + self.client_widget.widgets['mode_t'].setChecked(True) + + self.app.status_label.setText(f"Ready to create video from {start_sec:.2f}s to {end_sec:.2f}s.") + self.dock_widget.show() + self.dock_widget.raise_() + + def insert_generated_clip(self, video_path): + from videoeditor import TimelineClip + if not self.active_region: + self.app.status_label.setText("Error: No active region to insert into."); return + if not os.path.exists(video_path): + self.app.status_label.setText(f"Error: Output file not found: {video_path}"); return + start_sec, end_sec = self.active_region + def complex_insertion_action(): + self.app._add_media_files_to_project([video_path]) + media_info = self.app.media_properties.get(video_path) + if not media_info: raise ValueError("Could not probe inserted clip.") + actual_duration, has_audio = media_info['duration'], media_info['has_audio'] + if self.insert_on_new_track: + self.app.timeline.num_video_tracks += 1 + video_track_index = self.app.timeline.num_video_tracks + audio_track_index = self.app.timeline.num_audio_tracks + 1 if has_audio else None + else: + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) + clips_to_remove = [c for c in self.app.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec] + for clip in clips_to_remove: + if clip in self.app.timeline.clips: self.app.timeline.clips.remove(clip) + video_track_index, audio_track_index = 1, 1 if has_audio else None + group_id = str(uuid.uuid4()) + new_clip = TimelineClip(video_path, start_sec, 0, actual_duration, video_track_index, 'video', 'video', group_id) + self.app.timeline.add_clip(new_clip) + if audio_track_index: + if audio_track_index > self.app.timeline.num_audio_tracks: self.app.timeline.num_audio_tracks = audio_track_index + audio_clip = TimelineClip(video_path, start_sec, 0, actual_duration, audio_track_index, 'audio', 'video', group_id) + self.app.timeline.add_clip(audio_clip) + try: + self.app._perform_complex_timeline_change("Insert AI Clip", complex_insertion_action) + self.app.prune_empty_tracks() + self.app.status_label.setText("AI clip inserted successfully.") + for i in range(self.client_widget.results_list.count()): + widget = self.client_widget.results_list.itemWidget(self.client_widget.results_list.item(i)) + if widget and widget.video_path == video_path: + self.client_widget.results_list.takeItem(i); break + except Exception as e: + import traceback; traceback.print_exc() + self.app.status_label.setText(f"Error during clip insertion: {e}") \ No newline at end of file From 556280398351454626f953169970dff560131556 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sat, 11 Oct 2025 23:39:55 +1100 Subject: [PATCH 37/67] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eff58e558..81c93be78 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ python videoeditor.py ``` ## Screenshots -image +image image image From 19a5cd85583d5f38edc2dfa4437818d0407c4d8c Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 10:08:29 +1100 Subject: [PATCH 38/67] allow region selection resizing, remove all region context menu options showing up for single regions and vice versa --- videoeditor.py | 105 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index 1c7466957..4a124b785 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -134,6 +134,10 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.resize_edge = None self.resize_start_pos = QPoint() + self.resizing_selection_region = None + self.resize_selection_edge = None + self.resize_selection_start_values = None + self.highlighted_track_info = None self.highlighted_ghost_track_info = None self.add_video_track_btn_rect = QRect() @@ -522,6 +526,29 @@ def mousePressEvent(self, event: QMouseEvent): self.resizing_clip = None self.resize_edge = None self.drag_original_clip_states.clear() + self.resizing_selection_region = None + self.resize_selection_edge = None + self.resize_selection_start_values = None + + for region in self.selection_regions: + if not region: continue + x_start = self.sec_to_x(region[0]) + x_end = self.sec_to_x(region[1]) + if event.pos().y() > self.TIMESCALE_HEIGHT: + if abs(event.pos().x() - x_start) < self.RESIZE_HANDLE_WIDTH: + self.resizing_selection_region = region + self.resize_selection_edge = 'left' + break + elif abs(event.pos().x() - x_end) < self.RESIZE_HANDLE_WIDTH: + self.resizing_selection_region = region + self.resize_selection_edge = 'right' + break + + if self.resizing_selection_region: + self.resize_selection_start_values = tuple(self.resizing_selection_region) + self.drag_start_pos = event.pos() + self.update() + return for clip in reversed(self.timeline.clips): clip_rect = self.get_clip_rect(clip) @@ -595,6 +622,27 @@ def mousePressEvent(self, event: QMouseEvent): self.update() def mouseMoveEvent(self, event: QMouseEvent): + if self.resizing_selection_region: + current_sec = max(0, self.x_to_sec(event.pos().x())) + original_start, original_end = self.resize_selection_start_values + + if self.resize_selection_edge == 'left': + new_start = current_sec + new_end = original_end + else: # right + new_start = original_start + new_end = current_sec + + self.resizing_selection_region[0] = min(new_start, new_end) + self.resizing_selection_region[1] = max(new_start, new_end) + + if (self.resize_selection_edge == 'left' and new_start > new_end) or \ + (self.resize_selection_edge == 'right' and new_end < new_start): + self.resize_selection_edge = 'right' if self.resize_selection_edge == 'left' else 'left' + self.resize_selection_start_values = (original_end, original_start) + + self.update() + return if self.resizing_clip: linked_clip = next((c for c in self.timeline.clips if c.group_id == self.resizing_clip.group_id and c.id != self.resizing_clip.id), None) delta_x = event.pos().x() - self.resize_start_pos.x() @@ -686,6 +734,15 @@ def mouseMoveEvent(self, event: QMouseEvent): self.setCursor(Qt.CursorShape.SizeHorCursor) cursor_set = True + if not cursor_set and is_in_track_area: + for region in self.selection_regions: + x_start = self.sec_to_x(region[0]) + x_end = self.sec_to_x(region[1]) + if abs(event.pos().x() - x_start) < self.RESIZE_HANDLE_WIDTH or \ + abs(event.pos().x() - x_end) < self.RESIZE_HANDLE_WIDTH: + self.setCursor(Qt.CursorShape.SizeHorCursor) + cursor_set = True + break if not cursor_set: for clip in self.timeline.clips: clip_rect = self.get_clip_rect(clip) @@ -786,6 +843,12 @@ def mouseMoveEvent(self, event: QMouseEvent): def mouseReleaseEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton: + if self.resizing_selection_region: + self.resizing_selection_region = None + self.resize_selection_edge = None + self.resize_selection_start_values = None + self.update() + return if self.resizing_clip: new_state = self.window()._get_current_timeline_state() command = TimelineStateChangeCommand("Resize Clip", self.timeline, *self.drag_start_state, *new_state) @@ -1074,23 +1137,31 @@ def contextMenuEvent(self, event: 'QContextMenuEvent'): region_at_pos = self.get_region_at_pos(event.pos()) if region_at_pos: - split_this_action = menu.addAction("Split This Region") - split_all_action = menu.addAction("Split All Regions") - join_this_action = menu.addAction("Join This Region") - join_all_action = menu.addAction("Join All Regions") - delete_this_action = menu.addAction("Delete This Region") - delete_all_action = menu.addAction("Delete All Regions") - menu.addSeparator() - clear_this_action = menu.addAction("Clear This Region") - clear_all_action = menu.addAction("Clear All Regions") - split_this_action.triggered.connect(lambda: self.split_region_requested.emit(region_at_pos)) - split_all_action.triggered.connect(lambda: self.split_all_regions_requested.emit(self.selection_regions)) - join_this_action.triggered.connect(lambda: self.join_region_requested.emit(region_at_pos)) - join_all_action.triggered.connect(lambda: self.join_all_regions_requested.emit(self.selection_regions)) - delete_this_action.triggered.connect(lambda: self.delete_region_requested.emit(region_at_pos)) - delete_all_action.triggered.connect(lambda: self.delete_all_regions_requested.emit(self.selection_regions)) - clear_this_action.triggered.connect(lambda: self.clear_region(region_at_pos)) - clear_all_action.triggered.connect(self.clear_all_regions) + num_regions = len(self.selection_regions) + + if num_regions == 1: + split_this_action = menu.addAction("Split Region") + join_this_action = menu.addAction("Join Region") + delete_this_action = menu.addAction("Delete Region") + menu.addSeparator() + clear_this_action = menu.addAction("Clear Region") + + split_this_action.triggered.connect(lambda: self.split_region_requested.emit(region_at_pos)) + join_this_action.triggered.connect(lambda: self.join_region_requested.emit(region_at_pos)) + delete_this_action.triggered.connect(lambda: self.delete_region_requested.emit(region_at_pos)) + clear_this_action.triggered.connect(lambda: self.clear_region(region_at_pos)) + + elif num_regions > 1: + split_all_action = menu.addAction("Split All Regions") + join_all_action = menu.addAction("Join All Regions") + delete_all_action = menu.addAction("Delete All Regions") + menu.addSeparator() + clear_all_action = menu.addAction("Clear All Regions") + + split_all_action.triggered.connect(lambda: self.split_all_regions_requested.emit(self.selection_regions)) + join_all_action.triggered.connect(lambda: self.join_all_regions_requested.emit(self.selection_regions)) + delete_all_action.triggered.connect(lambda: self.delete_all_regions_requested.emit(self.selection_regions)) + clear_all_action.triggered.connect(self.clear_all_regions) clip_at_pos = None for clip in self.timeline.clips: From e45796c6b977176293cbf44178d0c517bab74863 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 13:32:45 +1100 Subject: [PATCH 39/67] add preview video scaling --- videoeditor.py | 186 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 55 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index 4a124b785..15e5f0d7d 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -1363,9 +1363,15 @@ def __init__(self, project_to_load=None): self.plugin_manager = PluginManager(self, wgp) self.plugin_manager.discover_and_load_plugins() + # Project settings with defaults self.project_fps = 25.0 self.project_width = 1280 self.project_height = 720 + + # Preview settings + self.scale_to_fit = True + self.current_preview_pixmap = None + self.playback_timer = QTimer(self) self.playback_process = None self.playback_clip = None @@ -1380,6 +1386,10 @@ def __init__(self, project_to_load=None): if not self.settings_file_was_loaded: self._save_settings() if project_to_load: QTimer.singleShot(100, lambda: self._load_project_from_path(project_to_load)) + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_preview_display() + def _get_current_timeline_state(self): return ( copy.deepcopy(self.timeline.clips), @@ -1395,12 +1405,18 @@ def _setup_ui(self): self.splitter = QSplitter(Qt.Orientation.Vertical) + # --- PREVIEW WIDGET SETUP (CHANGED) --- + self.preview_scroll_area = QScrollArea() + self.preview_scroll_area.setWidgetResizable(False) # Important for 1:1 scaling + self.preview_scroll_area.setStyleSheet("background-color: black; border: 0px;") + self.preview_widget = QLabel() self.preview_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_widget.setMinimumSize(640, 360) - self.preview_widget.setFrameShape(QFrame.Shape.Box) - self.preview_widget.setStyleSheet("background-color: black; color: white;") - self.splitter.addWidget(self.preview_widget) + self.preview_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + + self.preview_scroll_area.setWidget(self.preview_widget) + self.splitter.addWidget(self.preview_scroll_area) self.timeline_widget = TimelineWidget(self.timeline, self.settings, self.project_fps, self) self.timeline_scroll_area = QScrollArea() @@ -1412,6 +1428,9 @@ def _setup_ui(self): self.timeline_scroll_area.setMinimumHeight(250) self.splitter.addWidget(self.timeline_scroll_area) + self.splitter.setStretchFactor(0, 1) + self.splitter.setStretchFactor(1, 0) + container_widget = QWidget() main_layout = QVBoxLayout(container_widget) main_layout.setContentsMargins(0,0,0,0) @@ -1445,7 +1464,7 @@ def _setup_ui(self): self.setCentralWidget(container_widget) self.managed_widgets = { - 'preview': {'widget': self.preview_widget, 'name': 'Video Preview', 'action': None}, + 'preview': {'widget': self.preview_scroll_area, 'name': 'Video Preview', 'action': None}, 'timeline': {'widget': self.timeline_scroll_area, 'name': 'Timeline', 'action': None}, 'project_media': {'widget': self.media_dock, 'name': 'Project Media', 'action': None} } @@ -1459,6 +1478,7 @@ def _setup_ui(self): def _connect_signals(self): self.splitter.splitterMoved.connect(self.on_splitter_moved) + self.preview_widget.customContextMenuRequested.connect(self._show_preview_context_menu) self.timeline_widget.split_requested.connect(self.split_clip_at_playhead) self.timeline_widget.delete_clip_requested.connect(self.delete_clip) @@ -1487,6 +1507,18 @@ def _connect_signals(self): self.undo_stack.history_changed.connect(self.update_undo_redo_actions) self.undo_stack.timeline_changed.connect(self.on_timeline_changed_by_undo) + def _show_preview_context_menu(self, pos): + menu = QMenu(self) + scale_action = QAction("Scale to Fit", self, checkable=True) + scale_action.setChecked(self.scale_to_fit) + scale_action.toggled.connect(self._toggle_scale_to_fit) + menu.addAction(scale_action) + menu.exec(self.preview_widget.mapToGlobal(pos)) + + def _toggle_scale_to_fit(self, checked): + self.scale_to_fit = checked + self._update_preview_display() + def on_timeline_changed_by_undo(self): self.prune_empty_tracks() self.timeline_widget.update() @@ -1644,7 +1676,7 @@ def _create_menu_bar(self): self.windows_menu = menu_bar.addMenu("&Windows") for key, data in self.managed_widgets.items(): - if data['widget'] is self.preview_widget: continue + if data['widget'] is self.preview_scroll_area: continue action = QAction(data['name'], self, checkable=True) if hasattr(data['widget'], 'visibilityChanged'): action.toggled.connect(data['widget'].setVisible) @@ -1700,9 +1732,12 @@ def _get_media_properties(self, file_path): media_info = {} if file_ext in ['.png', '.jpg', '.jpeg']: + img = QImage(file_path) media_info['media_type'] = 'image' media_info['duration'] = 5.0 media_info['has_audio'] = False + media_info['width'] = img.width() + media_info['height'] = img.height() else: probe = ffmpeg.probe(file_path) video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) @@ -1712,6 +1747,11 @@ def _get_media_properties(self, file_path): media_info['media_type'] = 'video' media_info['duration'] = float(video_stream.get('duration', probe['format'].get('duration', 0))) media_info['has_audio'] = audio_stream is not None + media_info['width'] = int(video_stream['width']) + media_info['height'] = int(video_stream['height']) + if 'r_frame_rate' in video_stream and video_stream['r_frame_rate'] != '0/0': + num, den = map(int, video_stream['r_frame_rate'].split('/')) + if den > 0: media_info['fps'] = num / den elif audio_stream: media_info['media_type'] = 'audio' media_info['duration'] = float(audio_stream.get('duration', probe['format'].get('duration', 0))) @@ -1724,20 +1764,37 @@ def _get_media_properties(self, file_path): print(f"Failed to probe file {os.path.basename(file_path)}: {e}") return None - def _set_project_properties_from_clip(self, source_path): + def _update_project_properties_from_clip(self, source_path): try: - probe = ffmpeg.probe(source_path) - video_stream = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None) - if video_stream: - self.project_width = int(video_stream['width']); self.project_height = int(video_stream['height']) - if 'r_frame_rate' in video_stream and video_stream['r_frame_rate'] != '0/0': - num, den = map(int, video_stream['r_frame_rate'].split('/')) - if den > 0: - self.project_fps = num / den - self.timeline_widget.set_project_fps(self.project_fps) - print(f"Project properties set: {self.project_width}x{self.project_height} @ {self.project_fps:.2f} FPS") - return True - except Exception as e: print(f"Could not probe for project properties: {e}") + media_info = self._get_media_properties(source_path) + if not media_info or media_info['media_type'] not in ['video', 'image']: + return False + + new_w = media_info.get('width') + new_h = media_info.get('height') + new_fps = media_info.get('fps') + + if not new_w or not new_h: + return False + + is_first_video = not any(c.media_type in ['video', 'image'] for c in self.timeline.clips if c.source_path != source_path) + + if is_first_video: + self.project_width = new_w + self.project_height = new_h + if new_fps: self.project_fps = new_fps + self.timeline_widget.set_project_fps(self.project_fps) + print(f"Project properties set from first clip: {self.project_width}x{self.project_height} @ {self.project_fps:.2f} FPS") + else: + current_area = self.project_width * self.project_height + new_area = new_w * new_h + if new_area > current_area: + self.project_width = new_w + self.project_height = new_h + print(f"Project resolution updated to: {self.project_width}x{self.project_height}") + return True + except Exception as e: + print(f"Could not probe for project properties: {e}") return False def _probe_for_drag(self, file_path): @@ -1774,9 +1831,8 @@ def get_frame_data_at_time(self, time_sec): return (None, 0, 0) def get_frame_at_time(self, time_sec): - black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) clip_at_time = self._get_topmost_video_clip_at(time_sec) - if not clip_at_time: return black_pixmap + if not clip_at_time: return None try: w, h = self.project_width, self.project_height if clip_at_time.media_type == 'image': @@ -1797,16 +1853,36 @@ def get_frame_at_time(self, time_sec): image = QImage(out, self.project_width, self.project_height, QImage.Format.Format_RGB888) return QPixmap.fromImage(image) - except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return black_pixmap + except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return None def seek_preview(self, time_sec): self._stop_playback_stream() self.timeline_widget.playhead_pos_sec = time_sec self.timeline_widget.update() - frame_pixmap = self.get_frame_at_time(time_sec) - if frame_pixmap: - scaled_pixmap = frame_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.current_preview_pixmap = self.get_frame_at_time(time_sec) + self._update_preview_display() + + def _update_preview_display(self): + pixmap_to_show = self.current_preview_pixmap + if not pixmap_to_show: + pixmap_to_show = QPixmap(self.project_width, self.project_height) + pixmap_to_show.fill(QColor("black")) + + if self.scale_to_fit: + # When fitting, we want the label to resize with the scroll area + self.preview_scroll_area.setWidgetResizable(True) + scaled_pixmap = pixmap_to_show.scaled( + self.preview_scroll_area.viewport().size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) self.preview_widget.setPixmap(scaled_pixmap) + else: + # For 1:1, the label takes the size of the pixmap, and the scroll area handles overflow + self.preview_scroll_area.setWidgetResizable(False) + self.preview_widget.setPixmap(pixmap_to_show) + self.preview_widget.adjustSize() + def toggle_playback(self): if self.playback_timer.isActive(): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play") @@ -1835,19 +1911,16 @@ def advance_playback_frame(self): if not clip_at_new_time: self._stop_playback_stream() - black_pixmap = QPixmap(self.project_width, self.project_height); black_pixmap.fill(QColor("black")) - scaled_pixmap = black_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - self.preview_widget.setPixmap(scaled_pixmap) + self.current_preview_pixmap = None + self._update_preview_display() return if clip_at_new_time.media_type == 'image': if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: self._stop_playback_stream() self.playback_clip = clip_at_new_time - frame_pixmap = self.get_frame_at_time(new_time) - if frame_pixmap: - scaled_pixmap = frame_pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - self.preview_widget.setPixmap(scaled_pixmap) + self.current_preview_pixmap = self.get_frame_at_time(new_time) + self._update_preview_display() return if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: @@ -1858,9 +1931,8 @@ def advance_playback_frame(self): frame_bytes = self.playback_process.stdout.read(frame_size) if len(frame_bytes) == frame_size: image = QImage(frame_bytes, self.project_width, self.project_height, QImage.Format.Format_RGB888) - pixmap = QPixmap.fromImage(image) - scaled_pixmap = pixmap.scaled(self.preview_widget.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - self.preview_widget.setPixmap(scaled_pixmap) + self.current_preview_pixmap = QPixmap.fromImage(image) + self._update_preview_display() else: self._stop_playback_stream() @@ -1892,13 +1964,15 @@ def _apply_loaded_settings(self): visibility_settings = self.settings.get("window_visibility", {}) for key, data in self.managed_widgets.items(): is_visible = visibility_settings.get(key, False if key == 'project_media' else True) - if data['widget'] is not self.preview_widget: + if data['widget'] is not self.preview_scroll_area: data['widget'].setVisible(is_visible) if data['action']: data['action'].setChecked(is_visible) splitter_state = self.settings.get("splitter_state") if splitter_state: self.splitter.restoreState(QByteArray.fromHex(splitter_state.encode('ascii'))) - def on_splitter_moved(self, pos, index): self.splitter_save_timer.start(500) + def on_splitter_moved(self, pos, index): + self.splitter_save_timer.start(500) + self._update_preview_display() def toggle_widget_visibility(self, key, checked): if self.is_shutting_down: return @@ -1916,6 +1990,8 @@ def new_project(self): self.media_pool.clear(); self.media_properties.clear(); self.project_media_widget.clear_list() self.current_project_path = None; self.stop_playback() self.project_fps = 25.0 + self.project_width = 1280 + self.project_height = 720 self.timeline_widget.set_project_fps(self.project_fps) self.timeline_widget.update() self.undo_stack = UndoStack() @@ -1929,7 +2005,13 @@ def save_project_as(self): project_data = { "media_pool": self.media_pool, "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "media_type": c.media_type, "group_id": c.group_id} for c in self.timeline.clips], - "settings": {"num_video_tracks": self.timeline.num_video_tracks, "num_audio_tracks": self.timeline.num_audio_tracks} + "settings": { + "num_video_tracks": self.timeline.num_video_tracks, + "num_audio_tracks": self.timeline.num_audio_tracks, + "project_width": self.project_width, + "project_height": self.project_height, + "project_fps": self.project_fps + } } try: with open(path, "w") as f: json.dump(project_data, f, indent=4) @@ -1946,6 +2028,14 @@ def _load_project_from_path(self, path): with open(path, "r") as f: project_data = json.load(f) self.new_project() + project_settings = project_data.get("settings", {}) + self.timeline.num_video_tracks = project_settings.get("num_video_tracks", 1) + self.timeline.num_audio_tracks = project_settings.get("num_audio_tracks", 1) + self.project_width = project_settings.get("project_width", 1280) + self.project_height = project_settings.get("project_height", 720) + self.project_fps = project_settings.get("project_fps", 25.0) + self.timeline_widget.set_project_fps(self.project_fps) + self.media_pool = project_data.get("media_pool", []) for p in self.media_pool: self._add_media_to_pool(p) @@ -1962,14 +2052,7 @@ def _load_project_from_path(self, path): self.timeline.add_clip(TimelineClip(**clip_data)) - project_settings = project_data.get("settings", {}) - self.timeline.num_video_tracks = project_settings.get("num_video_tracks", 1) - self.timeline.num_audio_tracks = project_settings.get("num_audio_tracks", 1) - self.current_project_path = path - video_clips = [c for c in self.timeline.clips if c.media_type == 'video'] - if video_clips: - self._set_project_properties_from_clip(video_clips[0].source_path) self.prune_empty_tracks() self.timeline_widget.update(); self.stop_playback() self.status_label.setText(f"Project '{os.path.basename(path)}' loaded.") @@ -2030,20 +2113,10 @@ def _add_media_files_to_project(self, file_paths): return [] self.media_dock.show() - added_files = [] - first_video_added = any(c.media_type == 'video' for c in self.timeline.clips) for file_path in file_paths: - if not self.timeline.clips and not self.media_pool and not first_video_added: - ext = os.path.splitext(file_path)[1].lower() - if ext not in ['.png', '.jpg', '.jpeg', '.mp3', '.wav']: - if self._set_project_properties_from_clip(file_path): - first_video_added = True - else: - self.status_label.setText("Error: Could not determine video properties from file.") - continue - + self._update_project_properties_from_clip(file_path) if self._add_media_to_pool(file_path): added_files.append(file_path) @@ -2118,6 +2191,9 @@ def add_media_files(self): self._add_media_files_to_project(file_paths) def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, media_type, clip_start_sec=0.0, video_track_index=None, audio_track_index=None): + if media_type in ['video', 'image']: + self._update_project_properties_from_clip(source_path) + old_state = self._get_current_timeline_state() group_id = str(uuid.uuid4()) From e6fd0aa99fe56d2381fc71df997d7db23eab15e6 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 14:02:38 +1100 Subject: [PATCH 40/67] fix scroll centering, overlapping track labels --- videoeditor.py | 120 ++++++++++++++++++++++++++++--------------------- 1 file changed, 70 insertions(+), 50 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index 15e5f0d7d..a05ff8506 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -106,7 +106,10 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.timeline = timeline_model self.settings = settings self.playhead_pos_sec = 0.0 - self.scroll_area = None + self.view_start_sec = 0.0 + self.panning = False + self.pan_start_pos = QPoint() + self.pan_start_view_sec = 0.0 self.pixels_per_second = 50.0 self.max_pixels_per_second = 1.0 @@ -165,19 +168,19 @@ def set_project_fps(self, fps): self.pixels_per_second = min(self.pixels_per_second, self.max_pixels_per_second) self.update() - def sec_to_x(self, sec): return self.HEADER_WIDTH + int(sec * self.pixels_per_second) - def x_to_sec(self, x): return float(x - self.HEADER_WIDTH) / self.pixels_per_second if x > self.HEADER_WIDTH and self.pixels_per_second > 0 else 0.0 + def sec_to_x(self, sec): return self.HEADER_WIDTH + int((sec - self.view_start_sec) * self.pixels_per_second) + def x_to_sec(self, x): return self.view_start_sec + float(x - self.HEADER_WIDTH) / self.pixels_per_second if x > self.HEADER_WIDTH and self.pixels_per_second > 0 else self.view_start_sec def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.fillRect(self.rect(), QColor("#333")) - h_offset = 0 - if self.scroll_area and self.scroll_area.horizontalScrollBar(): - h_offset = self.scroll_area.horizontalScrollBar().value() + self.draw_headers(painter) - self.draw_headers(painter, h_offset) + painter.save() + painter.setClipRect(self.HEADER_WIDTH, 0, self.width() - self.HEADER_WIDTH, self.height()) + self.draw_timescale(painter) self.draw_tracks_and_clips(painter) self.draw_selections(painter) @@ -201,17 +204,19 @@ def paintEvent(self, event): painter.drawRect(self.hover_preview_audio_rect) self.draw_playhead(painter) + + painter.restore() - total_width = self.sec_to_x(self.timeline.get_total_duration()) + 200 total_height = self.calculate_total_height() - self.setMinimumSize(max(self.parent().width(), total_width), total_height) + if self.minimumHeight() != total_height: + self.setMinimumHeight(total_height) def calculate_total_height(self): video_tracks_height = (self.timeline.num_video_tracks + 1) * self.TRACK_HEIGHT audio_tracks_height = (self.timeline.num_audio_tracks + 1) * self.TRACK_HEIGHT return self.TIMESCALE_HEIGHT + video_tracks_height + self.AUDIO_TRACKS_SEPARATOR_Y + audio_tracks_height + 20 - def draw_headers(self, painter, h_offset): + def draw_headers(self, painter): painter.save() painter.setPen(QColor("#AAA")) header_font = QFont("Arial", 9, QFont.Weight.Bold) @@ -220,9 +225,9 @@ def draw_headers(self, painter, h_offset): y_cursor = self.TIMESCALE_HEIGHT rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect.translated(h_offset, 0), QColor("#3a3a3a")) - painter.drawRect(rect.translated(h_offset, 0)) - self.add_video_track_btn_rect = QRect(h_offset + rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) + painter.fillRect(rect, QColor("#3a3a3a")) + painter.drawRect(rect) + self.add_video_track_btn_rect = QRect(rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) painter.setFont(button_font) painter.fillRect(self.add_video_track_btn_rect, QColor("#454")) painter.drawText(self.add_video_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "Add Track (+)") @@ -232,13 +237,13 @@ def draw_headers(self, painter, h_offset): for i in range(self.timeline.num_video_tracks): track_number = self.timeline.num_video_tracks - i rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect.translated(h_offset, 0), QColor("#444")) - painter.drawRect(rect.translated(h_offset, 0)) + painter.fillRect(rect, QColor("#444")) + painter.drawRect(rect) painter.setFont(header_font) - painter.drawText(rect.translated(h_offset, 0), Qt.AlignmentFlag.AlignCenter, f"Video {track_number}") + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, f"Video {track_number}") if track_number == self.timeline.num_video_tracks and self.timeline.num_video_tracks > 1: - self.remove_video_track_btn_rect = QRect(h_offset + rect.right() - 25, rect.top() + 5, 20, 20) + self.remove_video_track_btn_rect = QRect(rect.right() - 25, rect.top() + 5, 20, 20) painter.setFont(button_font) painter.fillRect(self.remove_video_track_btn_rect, QColor("#833")) painter.drawText(self.remove_video_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "-") @@ -250,22 +255,22 @@ def draw_headers(self, painter, h_offset): for i in range(self.timeline.num_audio_tracks): track_number = i + 1 rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect.translated(h_offset, 0), QColor("#444")) - painter.drawRect(rect.translated(h_offset, 0)) + painter.fillRect(rect, QColor("#444")) + painter.drawRect(rect) painter.setFont(header_font) - painter.drawText(rect.translated(h_offset, 0), Qt.AlignmentFlag.AlignCenter, f"Audio {track_number}") + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, f"Audio {track_number}") if track_number == self.timeline.num_audio_tracks and self.timeline.num_audio_tracks > 1: - self.remove_audio_track_btn_rect = QRect(h_offset + rect.right() - 25, rect.top() + 5, 20, 20) + self.remove_audio_track_btn_rect = QRect(rect.right() - 25, rect.top() + 5, 20, 20) painter.setFont(button_font) painter.fillRect(self.remove_audio_track_btn_rect, QColor("#833")) painter.drawText(self.remove_audio_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "-") y_cursor += self.TRACK_HEIGHT rect = QRect(0, y_cursor, self.HEADER_WIDTH, self.TRACK_HEIGHT) - painter.fillRect(rect.translated(h_offset, 0), QColor("#3a3a3a")) - painter.drawRect(rect.translated(h_offset, 0)) - self.add_audio_track_btn_rect = QRect(h_offset + rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) + painter.fillRect(rect, QColor("#3a3a3a")) + painter.drawRect(rect) + self.add_audio_track_btn_rect = QRect(rect.left() + 10, rect.top() + (rect.height() - 22)//2, self.HEADER_WIDTH - 20, 22) painter.setFont(button_font) painter.fillRect(self.add_audio_track_btn_rect, QColor("#454")) painter.drawText(self.add_audio_track_btn_rect, Qt.AlignmentFlag.AlignCenter, "Add Track (+)") @@ -476,39 +481,46 @@ def get_region_at_pos(self, pos: QPoint): return None def wheelEvent(self, event: QMouseEvent): - if not self.scroll_area or event.position().x() < self.HEADER_WIDTH: + if event.position().x() < self.HEADER_WIDTH: event.ignore() return - scrollbar = self.scroll_area.horizontalScrollBar() - mouse_x_abs = event.position().x() - - time_at_cursor = self.x_to_sec(mouse_x_abs) + mouse_x = event.position().x() + time_at_cursor = self.x_to_sec(mouse_x) delta = event.angleDelta().y() zoom_factor = 1.15 old_pps = self.pixels_per_second - if delta > 0: new_pps = old_pps * zoom_factor - else: new_pps = old_pps / zoom_factor - + if delta > 0: + new_pps = old_pps * zoom_factor + else: + new_pps = old_pps / zoom_factor + min_pps = 1 / (3600 * 10) new_pps = max(min_pps, min(new_pps, self.max_pixels_per_second)) - + if abs(new_pps - old_pps) < 1e-9: return - + self.pixels_per_second = new_pps - self.update() - new_mouse_x_abs = self.sec_to_x(time_at_cursor) - shift_amount = new_mouse_x_abs - mouse_x_abs - new_scroll_value = scrollbar.value() + shift_amount - scrollbar.setValue(int(new_scroll_value)) + new_view_start_sec = time_at_cursor - (mouse_x - self.HEADER_WIDTH) / self.pixels_per_second + self.view_start_sec = max(0.0, new_view_start_sec) + + self.update() event.accept() def mousePressEvent(self, event: QMouseEvent): - if event.pos().x() < self.HEADER_WIDTH + self.scroll_area.horizontalScrollBar().value(): + if event.button() == Qt.MouseButton.MiddleButton: + self.panning = True + self.pan_start_pos = event.pos() + self.pan_start_view_sec = self.view_start_sec + self.setCursor(Qt.CursorShape.ClosedHandCursor) + event.accept() + return + + if event.pos().x() < self.HEADER_WIDTH: if self.add_video_track_btn_rect.contains(event.pos()): self.add_track.emit('video') elif self.remove_video_track_btn_rect.contains(event.pos()): self.remove_track.emit('video') elif self.add_audio_track_btn_rect.contains(event.pos()): self.add_track.emit('audio') @@ -622,6 +634,14 @@ def mousePressEvent(self, event: QMouseEvent): self.update() def mouseMoveEvent(self, event: QMouseEvent): + if self.panning: + delta_x = event.pos().x() - self.pan_start_pos.x() + time_delta = delta_x / self.pixels_per_second + new_view_start = self.pan_start_view_sec - time_delta + self.view_start_sec = max(0.0, new_view_start) + self.update() + return + if self.resizing_selection_region: current_sec = max(0, self.x_to_sec(event.pos().x())) original_start, original_end = self.resize_selection_start_values @@ -842,6 +862,12 @@ def mouseMoveEvent(self, event: QMouseEvent): self.update() def mouseReleaseEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.MiddleButton and self.panning: + self.panning = False + self.unsetCursor() + event.accept() + return + if event.button() == Qt.MouseButton.LeftButton: if self.resizing_selection_region: self.resizing_selection_region = None @@ -1419,14 +1445,8 @@ def _setup_ui(self): self.splitter.addWidget(self.preview_scroll_area) self.timeline_widget = TimelineWidget(self.timeline, self.settings, self.project_fps, self) - self.timeline_scroll_area = QScrollArea() - self.timeline_widget.scroll_area = self.timeline_scroll_area - self.timeline_scroll_area.setWidgetResizable(False) - self.timeline_widget.setMinimumWidth(2000) - self.timeline_scroll_area.setWidget(self.timeline_widget) - self.timeline_scroll_area.setFrameShape(QFrame.Shape.NoFrame) - self.timeline_scroll_area.setMinimumHeight(250) - self.splitter.addWidget(self.timeline_scroll_area) + self.timeline_widget.setMinimumHeight(250) + self.splitter.addWidget(self.timeline_widget) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 0) @@ -1465,7 +1485,7 @@ def _setup_ui(self): self.managed_widgets = { 'preview': {'widget': self.preview_scroll_area, 'name': 'Video Preview', 'action': None}, - 'timeline': {'widget': self.timeline_scroll_area, 'name': 'Timeline', 'action': None}, + 'timeline': {'widget': self.timeline_widget, 'name': 'Timeline', 'action': None}, 'project_media': {'widget': self.media_dock, 'name': 'Project Media', 'action': None} } self.plugin_menu_actions = {} @@ -1676,7 +1696,7 @@ def _create_menu_bar(self): self.windows_menu = menu_bar.addMenu("&Windows") for key, data in self.managed_widgets.items(): - if data['widget'] is self.preview_scroll_area: continue + if data['widget'] is self.preview_scroll_area or data['widget'] is self.timeline_widget: continue action = QAction(data['name'], self, checkable=True) if hasattr(data['widget'], 'visibilityChanged'): action.toggled.connect(data['widget'].setVisible) From 46d2c6c4b45badcb10870eecdb7208dfeaee9c3c Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 14:09:17 +1100 Subject: [PATCH 41/67] fix 1st frame being rendered blank when adding video to timeline --- videoeditor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/videoeditor.py b/videoeditor.py index a05ff8506..885a58bef 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -1542,6 +1542,7 @@ def _toggle_scale_to_fit(self, checked): def on_timeline_changed_by_undo(self): self.prune_empty_tracks() self.timeline_widget.update() + self.seek_preview(self.timeline_widget.playhead_pos_sec) self.status_label.setText("Operation undone/redone.") def update_undo_redo_actions(self): From 0bd6a8d6c83ac2972c8eefd48475abf5f3c6a394 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 14:27:25 +1100 Subject: [PATCH 42/67] add zoom to 0 on timescale --- videoeditor.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index 885a58bef..a63d2ac99 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -481,13 +481,6 @@ def get_region_at_pos(self, pos: QPoint): return None def wheelEvent(self, event: QMouseEvent): - if event.position().x() < self.HEADER_WIDTH: - event.ignore() - return - - mouse_x = event.position().x() - time_at_cursor = self.x_to_sec(mouse_x) - delta = event.angleDelta().y() zoom_factor = 1.15 old_pps = self.pixels_per_second @@ -503,9 +496,16 @@ def wheelEvent(self, event: QMouseEvent): if abs(new_pps - old_pps) < 1e-9: return - self.pixels_per_second = new_pps + if event.position().x() < self.HEADER_WIDTH: + # Zoom centered on time 0. Keep screen position of time 0 constant. + new_view_start_sec = self.view_start_sec * (old_pps / new_pps) + else: + # Zoom centered on mouse cursor. + mouse_x = event.position().x() + time_at_cursor = self.x_to_sec(mouse_x) + new_view_start_sec = time_at_cursor - (mouse_x - self.HEADER_WIDTH) / new_pps - new_view_start_sec = time_at_cursor - (mouse_x - self.HEADER_WIDTH) / self.pixels_per_second + self.pixels_per_second = new_pps self.view_start_sec = max(0.0, new_view_start_sec) self.update() From 0ca8ba19cf7c40e275fca2e420c0cb18eb9a5c8f Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 14:33:07 +1100 Subject: [PATCH 43/67] fix track labels overlaying 0s --- videoeditor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index a63d2ac99..76a4ab495 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -349,7 +349,10 @@ def draw_ticks(interval, height): painter.drawLine(x, self.TIMESCALE_HEIGHT - 12, x, self.TIMESCALE_HEIGHT) label = self._format_timecode(t_sec) label_width = font_metrics.horizontalAdvance(label) - painter.drawText(x - label_width // 2, self.TIMESCALE_HEIGHT - 14, label) + label_x = x - label_width // 2 + if label_x < self.HEADER_WIDTH: + label_x = self.HEADER_WIDTH + painter.drawText(label_x, self.TIMESCALE_HEIGHT - 14, label) painter.restore() @@ -497,10 +500,8 @@ def wheelEvent(self, event: QMouseEvent): return if event.position().x() < self.HEADER_WIDTH: - # Zoom centered on time 0. Keep screen position of time 0 constant. new_view_start_sec = self.view_start_sec * (old_pps / new_pps) else: - # Zoom centered on mouse cursor. mouse_x = event.position().x() time_at_cursor = self.x_to_sec(mouse_x) new_view_start_sec = time_at_cursor - (mouse_x - self.HEADER_WIDTH) / new_pps From eef79e40fb20bd30f40271a2d51f28a0197527e8 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 14:53:22 +1100 Subject: [PATCH 44/67] fix add track button not working --- videoeditor.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index 76a4ab495..b4cdb2164 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -1626,12 +1626,18 @@ def add_track(self, track_type): self.timeline.num_video_tracks += 1 elif track_type == 'audio': self.timeline.num_audio_tracks += 1 + else: + return + new_state = self._get_current_timeline_state() - command = TimelineStateChangeCommand(f"Add {track_type.capitalize()} Track", self.timeline, *old_state, *new_state) + + self.undo_stack.blockSignals(True) self.undo_stack.push(command) - command.undo() - self.undo_stack.push(command) + self.undo_stack.blockSignals(False) + + self.update_undo_redo_actions() + self.timeline_widget.update() def remove_track(self, track_type): old_state = self._get_current_timeline_state() From 088940b0e526c1335b75b87a158ef52529fa7751 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 15:10:34 +1100 Subject: [PATCH 45/67] snap timehead to single frames --- videoeditor.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index b4cdb2164..6abb4091b 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -473,6 +473,13 @@ def y_to_track_info(self, y): return None + def _snap_time_if_needed(self, time_sec): + frame_duration = 1.0 / self.project_fps + if frame_duration > 0 and frame_duration * self.pixels_per_second > 4: + frame_number = round(time_sec / frame_duration) + return frame_number * frame_duration + return time_sec + def get_region_at_pos(self, pos: QPoint): if pos.y() <= self.TIMESCALE_HEIGHT or pos.x() <= self.HEADER_WIDTH: return None @@ -628,7 +635,8 @@ def mousePressEvent(self, event: QMouseEvent): self.selection_drag_start_sec = self.x_to_sec(event.pos().x()) self.selection_regions.append([self.selection_drag_start_sec, self.selection_drag_start_sec]) elif is_on_timescale: - self.playhead_pos_sec = max(0, self.x_to_sec(event.pos().x())) + time_sec = max(0, self.x_to_sec(event.pos().x())) + self.playhead_pos_sec = self._snap_time_if_needed(time_sec) self.playhead_moved.emit(self.playhead_pos_sec) self.dragging_playhead = True @@ -798,7 +806,8 @@ def mouseMoveEvent(self, event: QMouseEvent): return if self.dragging_playhead: - self.playhead_pos_sec = max(0, self.x_to_sec(event.pos().x())) + time_sec = max(0, self.x_to_sec(event.pos().x())) + self.playhead_pos_sec = self._snap_time_if_needed(time_sec) self.playhead_moved.emit(self.playhead_pos_sec) self.update() elif self.dragging_clip: From cc553e2e5a26f0e2fe35c3bc8645a5dbe774eb43 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 15:49:55 +1100 Subject: [PATCH 46/67] automatically set resolution from project resolution or clip resolution depending on mode --- plugins/wan2gp/main.py | 82 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 0b925227c..568c31fb9 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -1339,6 +1339,55 @@ def _on_resolution_group_changed(self): self.widgets['resolution'].setCurrentIndex(self.widgets['resolution'].findData(last_resolution)) self.widgets['resolution'].blockSignals(False) + def set_resolution_from_target(self, target_w, target_h): + if not self.full_resolution_choices: + print("Resolution choices not available for AI resolution matching.") + return + + target_pixels = target_w * target_h + target_ar = target_w / target_h if target_h > 0 else 1.0 + + best_res_value = None + min_dist = float('inf') + + for label, res_value in self.full_resolution_choices: + try: + w_str, h_str = res_value.split('x') + w, h = int(w_str), int(h_str) + except (ValueError, AttributeError): + continue + + pixels = w * h + ar = w / h if h > 0 else 1.0 + + pixel_dist = abs(target_pixels - pixels) / target_pixels + ar_dist = abs(target_ar - ar) / target_ar + + dist = pixel_dist * 0.8 + ar_dist * 0.2 + + if dist < min_dist: + min_dist = dist + best_res_value = res_value + + if best_res_value: + best_group = self.wgp.categorize_resolution(best_res_value) + + group_combo = self.widgets['resolution_group'] + group_combo.blockSignals(True) + group_index = group_combo.findText(best_group) + if group_index != -1: + group_combo.setCurrentIndex(group_index) + group_combo.blockSignals(False) + + self._on_resolution_group_changed() + + res_combo = self.widgets['resolution'] + res_index = res_combo.findData(best_res_value) + if res_index != -1: + res_combo.setCurrentIndex(res_index) + else: + print(f"Warning: Could not find resolution '{best_res_value}' in dropdown after group change.") + def collect_inputs(self): full_inputs = self.wgp.get_current_model_settings(self.state).copy() full_inputs['lset_name'] = "" @@ -1663,6 +1712,9 @@ def setup_generator_for_region(self, region, on_new_track=False): if not start_data or not end_data: QMessageBox.warning(self.app, "Frame Error", "Could not extract start and/or end frames.") return + + self.client_widget.set_resolution_from_target(w, h) + try: self.temp_dir = tempfile.mkdtemp(prefix="wgp_plugin_") self.start_frame_path = os.path.join(self.temp_dir, "start_frame.png") @@ -1675,13 +1727,25 @@ def setup_generator_for_region(self, region, on_new_track=False): model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) - - self.client_widget.widgets['video_length'].setValue(video_length_frames) - self.client_widget.widgets['mode_s'].setChecked(True) - self.client_widget.widgets['image_end_checkbox'].setChecked(True) - self.client_widget.widgets['image_start'].setText(self.start_frame_path) - self.client_widget.widgets['image_end'].setText(self.end_frame_path) - + widgets = self.client_widget.widgets + widgets['mode_s'].blockSignals(True) + widgets['mode_t'].blockSignals(True) + widgets['mode_v'].blockSignals(True) + widgets['mode_l'].blockSignals(True) + widgets['image_end_checkbox'].blockSignals(True) + + widgets['video_length'].setValue(video_length_frames) + widgets['mode_s'].setChecked(True) + widgets['image_end_checkbox'].setChecked(True) + widgets['image_start'].setText(self.start_frame_path) + widgets['image_end'].setText(self.end_frame_path) + + widgets['mode_s'].blockSignals(False) + widgets['mode_t'].blockSignals(False) + widgets['mode_v'].blockSignals(False) + widgets['mode_l'].blockSignals(False) + widgets['image_end_checkbox'].blockSignals(False) + self.client_widget._update_input_visibility() except Exception as e: @@ -1707,6 +1771,10 @@ def setup_creator_for_region(self, region, on_new_track=False): else: print(f"Warning: Default model '{model_to_set}' not found for AI Creator. Using current model.") + target_w = self.app.project_width + target_h = self.app.project_height + self.client_widget.set_resolution_from_target(target_w, target_h) + start_sec, end_sec = region duration_sec = end_sec - start_sec wgp = self.client_widget.wgp From 80ecba27c962b8e5cda8f7a562eb5fb061f934aa Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 16:09:54 +1100 Subject: [PATCH 47/67] add join to start frame with ai and join to end frame with ai modes --- plugins/wan2gp/main.py | 156 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 10 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 568c31fb9..2c38d30fa 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -1681,11 +1681,23 @@ def on_timeline_context_menu(self, menu, event): start_sec, end_sec = region start_data, _, _ = self.app.get_frame_data_at_time(start_sec) end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + if start_data and end_data: join_action = menu.addAction("Join Frames With AI") join_action.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=False)) join_action_new_track = menu.addAction("Join Frames With AI (New Track)") join_action_new_track.triggered.connect(lambda: self.setup_generator_for_region(region, on_new_track=True)) + elif start_data: + from_start_action = menu.addAction("Generate from Start Frame with AI") + from_start_action.triggered.connect(lambda: self.setup_generator_from_start(region, on_new_track=False)) + from_start_action_new_track = menu.addAction("Generate from Start Frame with AI (New Track)") + from_start_action_new_track.triggered.connect(lambda: self.setup_generator_from_start(region, on_new_track=True)) + elif end_data: + to_end_action = menu.addAction("Generate to End Frame with AI") + to_end_action.triggered.connect(lambda: self.setup_generator_to_end(region, on_new_track=False)) + to_end_action_new_track = menu.addAction("Generate to End Frame with AI (New Track)") + to_end_action_new_track.triggered.connect(lambda: self.setup_generator_to_end(region, on_new_track=True)) + create_action = menu.addAction("Create Frames With AI") create_action.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=False)) create_action_new_track = menu.addAction("Create Frames With AI (New Track)") @@ -1728,11 +1740,9 @@ def setup_generator_for_region(self, region, on_new_track=False): fps = wgp.get_model_fps(model_type) video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) widgets = self.client_widget.widgets - widgets['mode_s'].blockSignals(True) - widgets['mode_t'].blockSignals(True) - widgets['mode_v'].blockSignals(True) - widgets['mode_l'].blockSignals(True) - widgets['image_end_checkbox'].blockSignals(True) + + for w_name in ['mode_s', 'mode_t', 'mode_v', 'mode_l', 'image_end_checkbox']: + widgets[w_name].blockSignals(True) widgets['video_length'].setValue(video_length_frames) widgets['mode_s'].setChecked(True) @@ -1740,11 +1750,8 @@ def setup_generator_for_region(self, region, on_new_track=False): widgets['image_start'].setText(self.start_frame_path) widgets['image_end'].setText(self.end_frame_path) - widgets['mode_s'].blockSignals(False) - widgets['mode_t'].blockSignals(False) - widgets['mode_v'].blockSignals(False) - widgets['mode_l'].blockSignals(False) - widgets['image_end_checkbox'].blockSignals(False) + for w_name in ['mode_s', 'mode_t', 'mode_v', 'mode_l', 'image_end_checkbox']: + widgets[w_name].blockSignals(False) self.client_widget._update_input_visibility() @@ -1756,6 +1763,135 @@ def setup_generator_for_region(self, region, on_new_track=False): self.dock_widget.show() self.dock_widget.raise_() + def setup_generator_from_start(self, region, on_new_track=False): + self._reset_state() + self.active_region = region + self.insert_on_new_track = on_new_track + + model_to_set = 'i2v_2_2' + dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types + _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + if any(model_to_set == m[1] for m in all_models): + if self.client_widget.state.get('model_type') != model_to_set: + self.client_widget.update_model_dropdowns(model_to_set) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") + + start_sec, end_sec = region + start_data, w, h = self.app.get_frame_data_at_time(start_sec) + if not start_data: + QMessageBox.warning(self.app, "Frame Error", "Could not extract start frame.") + return + + self.client_widget.set_resolution_from_target(w, h) + + try: + self.temp_dir = tempfile.mkdtemp(prefix="wgp_plugin_") + self.start_frame_path = os.path.join(self.temp_dir, "start_frame.png") + QImage(start_data, w, h, QImage.Format.Format_RGB888).save(self.start_frame_path) + + duration_sec = end_sec - start_sec + wgp = self.client_widget.wgp + model_type = self.client_widget.state['model_type'] + fps = wgp.get_model_fps(model_type) + video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + widgets = self.client_widget.widgets + + widgets['video_length'].setValue(video_length_frames) + + for w_name in ['mode_s', 'mode_t', 'mode_v', 'mode_l', 'image_end_checkbox']: + widgets[w_name].blockSignals(True) + + widgets['mode_s'].setChecked(True) + widgets['image_end_checkbox'].setChecked(False) + widgets['image_start'].setText(self.start_frame_path) + widgets['image_end'].clear() + + for w_name in ['mode_s', 'mode_t', 'mode_v', 'mode_l', 'image_end_checkbox']: + widgets[w_name].blockSignals(False) + + self.client_widget._update_input_visibility() + + except Exception as e: + QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame image: {e}") + self._cleanup_temp_dir() + return + + self.app.status_label.setText(f"Ready to generate from frame at {start_sec:.2f}s.") + self.dock_widget.show() + self.dock_widget.raise_() + + def setup_generator_to_end(self, region, on_new_track=False): + self._reset_state() + self.active_region = region + self.insert_on_new_track = on_new_track + + model_to_set = 'i2v_2_2' + dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types + _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + if any(model_to_set == m[1] for m in all_models): + if self.client_widget.state.get('model_type') != model_to_set: + self.client_widget.update_model_dropdowns(model_to_set) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") + + start_sec, end_sec = region + end_data, w, h = self.app.get_frame_data_at_time(end_sec) + if not end_data: + QMessageBox.warning(self.app, "Frame Error", "Could not extract end frame.") + return + + self.client_widget.set_resolution_from_target(w, h) + + try: + self.temp_dir = tempfile.mkdtemp(prefix="wgp_plugin_") + self.end_frame_path = os.path.join(self.temp_dir, "end_frame.png") + QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) + + duration_sec = end_sec - start_sec + wgp = self.client_widget.wgp + model_type = self.client_widget.state['model_type'] + fps = wgp.get_model_fps(model_type) + video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + widgets = self.client_widget.widgets + + widgets['video_length'].setValue(video_length_frames) + + model_def = self.client_widget.wgp.get_model_def(self.client_widget.state['model_type']) + allowed_modes = model_def.get("image_prompt_types_allowed", "") + + if "E" not in allowed_modes: + QMessageBox.warning(self.app, "Model Incompatible", "The current model does not support generating to an end frame.") + return + + if "S" not in allowed_modes: + QMessageBox.warning(self.app, "Model Incompatible", "The current model supports end frames, but not in a way compatible with this UI feature (missing 'Start with Image' mode).") + return + + for w_name in ['mode_s', 'mode_t', 'mode_v', 'mode_l', 'image_end_checkbox']: + widgets[w_name].blockSignals(True) + + widgets['mode_s'].setChecked(True) + widgets['image_end_checkbox'].setChecked(True) + widgets['image_start'].clear() + widgets['image_end'].setText(self.end_frame_path) + + for w_name in ['mode_s', 'mode_t', 'mode_v', 'mode_l', 'image_end_checkbox']: + widgets[w_name].blockSignals(False) + + self.client_widget._update_input_visibility() + + except Exception as e: + QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame image: {e}") + self._cleanup_temp_dir() + return + + self.app.status_label.setText(f"Ready to generate to frame at {end_sec:.2f}s.") + self.dock_widget.show() + self.dock_widget.raise_() + def setup_creator_for_region(self, region, on_new_track=False): self._reset_state() self.active_region = region From bbe9df1eccd10c387e3e536a7aa09cc5fbb7460d Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 16:36:41 +1100 Subject: [PATCH 48/67] make sure advanced options tabs dont need scroll --- plugins/wan2gp/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 2c38d30fa..12a4ede6e 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -423,6 +423,7 @@ def setup_generator_tab(self): self.tabs.addTab(gen_tab, "Video Generator") gen_layout = QHBoxLayout(gen_tab) left_panel = QWidget() + left_panel.setMinimumWidth(628) left_layout = QVBoxLayout(left_panel) gen_layout.addWidget(left_panel, 1) right_panel = QWidget() From f2324d42424d5a584388a5978b8da43dd0fa29f1 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 16:44:29 +1100 Subject: [PATCH 49/67] allow sliders to be text editable --- plugins/wan2gp/main.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 12a4ede6e..41edebd80 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -296,11 +296,35 @@ def _create_slider_with_label(self, name, min_val, max_val, initial_val, scale=1 slider = self.create_widget(QSlider, name, Qt.Orientation.Horizontal) slider.setRange(min_val, max_val) slider.setValue(int(initial_val * scale)) - value_label = self.create_widget(QLabel, f"{name}_label", f"{initial_val:.{precision}f}") - value_label.setMinimumWidth(50) - slider.valueChanged.connect(lambda v, lbl=value_label, s=scale, p=precision: lbl.setText(f"{v/s:.{p}f}")) + + value_edit = self.create_widget(QLineEdit, f"{name}_label", f"{initial_val:.{precision}f}") + value_edit.setFixedWidth(60) + value_edit.setAlignment(Qt.AlignmentFlag.AlignCenter) + + def sync_slider_from_text(): + try: + text_value = float(value_edit.text()) + slider_value = int(round(text_value * scale)) + + slider.blockSignals(True) + slider.setValue(slider_value) + slider.blockSignals(False) + + actual_slider_value = slider.value() + if actual_slider_value != slider_value: + value_edit.setText(f"{actual_slider_value / scale:.{precision}f}") + except (ValueError, TypeError): + value_edit.setText(f"{slider.value() / scale:.{precision}f}") + + def sync_text_from_slider(value): + if not value_edit.hasFocus(): + value_edit.setText(f"{value / scale:.{precision}f}") + + value_edit.editingFinished.connect(sync_slider_from_text) + slider.valueChanged.connect(sync_text_from_slider) + hbox.addWidget(slider) - hbox.addWidget(value_label) + hbox.addWidget(value_edit) return container def _create_file_input(self, name, label_text): From 2a867c2bfb54c3a52c2a976d33d2bf12a35fc723 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 16:53:26 +1100 Subject: [PATCH 50/67] save selected regions to project --- videoeditor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/videoeditor.py b/videoeditor.py index 6abb4091b..ce49757b1 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -2030,6 +2030,7 @@ def new_project(self): self.project_width = 1280 self.project_height = 720 self.timeline_widget.set_project_fps(self.project_fps) + self.timeline_widget.clear_all_regions() self.timeline_widget.update() self.undo_stack = UndoStack() self.undo_stack.history_changed.connect(self.update_undo_redo_actions) @@ -2042,6 +2043,7 @@ def save_project_as(self): project_data = { "media_pool": self.media_pool, "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "media_type": c.media_type, "group_id": c.group_id} for c in self.timeline.clips], + "selection_regions": self.timeline_widget.selection_regions, "settings": { "num_video_tracks": self.timeline.num_video_tracks, "num_audio_tracks": self.timeline.num_audio_tracks, @@ -2073,6 +2075,8 @@ def _load_project_from_path(self, path): self.project_fps = project_settings.get("project_fps", 25.0) self.timeline_widget.set_project_fps(self.project_fps) + self.timeline_widget.selection_regions = project_data.get("selection_regions", []) + self.media_pool = project_data.get("media_pool", []) for p in self.media_pool: self._add_media_to_pool(p) From 4d79760e83e9579cd3eb2ec65c493c62a42fd02d Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 17:30:20 +1100 Subject: [PATCH 51/67] set minheight --- plugins/wan2gp/main.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 41edebd80..623d4afa7 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -469,11 +469,13 @@ def setup_generator_tab(self): model_layout.addWidget(self.widgets['model_choice'], 3) options_layout.addLayout(model_layout) options_layout.addWidget(QLabel("Prompt:")) - self.create_widget(QTextEdit, 'prompt').setMinimumHeight(100) - options_layout.addWidget(self.widgets['prompt']) + prompt_edit = self.create_widget(QTextEdit, 'prompt') + prompt_edit.setMaximumHeight(prompt_edit.fontMetrics().lineSpacing() * 5 + 15) + options_layout.addWidget(prompt_edit) options_layout.addWidget(QLabel("Negative Prompt:")) - self.create_widget(QTextEdit, 'negative_prompt').setMinimumHeight(60) - options_layout.addWidget(self.widgets['negative_prompt']) + neg_prompt_edit = self.create_widget(QTextEdit, 'negative_prompt') + neg_prompt_edit.setMaximumHeight(neg_prompt_edit.fontMetrics().lineSpacing() * 3 + 15) + options_layout.addWidget(neg_prompt_edit) basic_group = QGroupBox("Basic Options") basic_layout = QFormLayout(basic_group) res_container = QWidget() @@ -528,7 +530,7 @@ def setup_generator_tab(self): self._setup_adv_tab_sliding_window(advanced_tabs) self._setup_adv_tab_misc(advanced_tabs) options_layout.addWidget(self.advanced_group) - + scroll_area.setMinimumHeight(options_widget.sizeHint().height()-260) btn_layout = QHBoxLayout() self.generate_btn = self.create_widget(QPushButton, 'generate_btn', "Generate") self.add_to_queue_btn = self.create_widget(QPushButton, 'add_to_queue_btn', "Add to Queue") From a8fd923e49d3e246e6c050b483061a35a2f5f160 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 19:09:10 +1100 Subject: [PATCH 52/67] add entire ffmpeg library of containers and formats to export options --- videoeditor.py | 470 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 430 insertions(+), 40 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index ce49757b1..b6673c879 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -10,7 +10,9 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QFileDialog, QLabel, QScrollArea, QFrame, QProgressBar, QDialog, - QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget, QListWidget, QListWidgetItem, QMessageBox) + QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget, + QListWidget, QListWidgetItem, QMessageBox, QComboBox, + QFormLayout, QGroupBox, QLineEdit) from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, QPixmap, QImage, QDrag, QCursor, QKeyEvent) from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, @@ -19,6 +21,165 @@ from plugins import PluginManager, ManagePluginsDialog from undo import UndoStack, TimelineStateChangeCommand, MoveClipsCommand +CONTAINER_PRESETS = { + 'mp4': { + 'vcodec': 'libx264', 'acodec': 'aac', + 'allowed_vcodecs': ['libx264', 'libx265', 'mpeg4'], + 'allowed_acodecs': ['aac', 'libmp3lame'], + 'v_bitrate': '5M', 'a_bitrate': '192k' + }, + 'matroska': { + 'vcodec': 'libx264', 'acodec': 'aac', + 'allowed_vcodecs': ['libx264', 'libx265', 'libvpx-vp9'], + 'allowed_acodecs': ['aac', 'libopus', 'libvorbis', 'flac'], + 'v_bitrate': '5M', 'a_bitrate': '192k' + }, + 'mov': { + 'vcodec': 'libx264', 'acodec': 'aac', + 'allowed_vcodecs': ['libx264', 'prores_ks', 'mpeg4'], + 'allowed_acodecs': ['aac', 'pcm_s16le'], + 'v_bitrate': '8M', 'a_bitrate': '256k' + }, + 'avi': { + 'vcodec': 'mpeg4', 'acodec': 'libmp3lame', + 'allowed_vcodecs': ['mpeg4', 'msmpeg4'], + 'allowed_acodecs': ['libmp3lame'], + 'v_bitrate': '5M', 'a_bitrate': '192k' + }, + 'webm': { + 'vcodec': 'libvpx-vp9', 'acodec': 'libopus', + 'allowed_vcodecs': ['libvpx-vp9'], + 'allowed_acodecs': ['libopus', 'libvorbis'], + 'v_bitrate': '4M', 'a_bitrate': '192k' + }, + 'wav': { + 'vcodec': None, 'acodec': 'pcm_s16le', + 'allowed_vcodecs': [], 'allowed_acodecs': ['pcm_s16le', 'pcm_s24le'], + 'v_bitrate': None, 'a_bitrate': None + }, + 'mp3': { + 'vcodec': None, 'acodec': 'libmp3lame', + 'allowed_vcodecs': [], 'allowed_acodecs': ['libmp3lame'], + 'v_bitrate': None, 'a_bitrate': '192k' + }, + 'flac': { + 'vcodec': None, 'acodec': 'flac', + 'allowed_vcodecs': [], 'allowed_acodecs': ['flac'], + 'v_bitrate': None, 'a_bitrate': None + }, + 'gif': { + 'vcodec': 'gif', 'acodec': None, + 'allowed_vcodecs': ['gif'], 'allowed_acodecs': [], + 'v_bitrate': None, 'a_bitrate': None + }, + 'oga': { # Using oga for ogg audio + 'vcodec': None, 'acodec': 'libvorbis', + 'allowed_vcodecs': [], 'allowed_acodecs': ['libvorbis', 'libopus'], + 'v_bitrate': None, 'a_bitrate': '192k' + } +} + +_cached_formats = None +_cached_video_codecs = None +_cached_audio_codecs = None + +def run_ffmpeg_command(args): + try: + startupinfo = None + if hasattr(subprocess, 'STARTUPINFO'): + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + result = subprocess.run( + ['ffmpeg'] + args, + capture_output=True, text=True, encoding='utf-8', + errors='ignore', startupinfo=startupinfo + ) + if result.returncode != 0 and "Unrecognized option" not in result.stderr: + print(f"FFmpeg command failed: {' '.join(args)}\n{result.stderr}") + return "" + return result.stdout + except FileNotFoundError: + print("Error: ffmpeg not found. Please ensure it is in your system's PATH.") + return None + except Exception as e: + print(f"An error occurred while running ffmpeg: {e}") + return None + +def get_available_formats(): + global _cached_formats + if _cached_formats is not None: + return _cached_formats + + output = run_ffmpeg_command(['-formats']) + if not output: + _cached_formats = {} + return {} + + formats = {} + lines = output.split('\n') + header_found = False + for line in lines: + if "---" in line: + header_found = True + continue + if not header_found or not line.strip(): + continue + + if line[2] == 'E': + parts = line[4:].strip().split(None, 1) + if len(parts) == 2: + names, description = parts + primary_name = names.split(',')[0].strip() + formats[primary_name] = description.strip() + + _cached_formats = dict(sorted(formats.items())) + return _cached_formats + +def get_available_codecs(codec_type='video'): + global _cached_video_codecs, _cached_audio_codecs + + if codec_type == 'video' and _cached_video_codecs is not None: return _cached_video_codecs + if codec_type == 'audio' and _cached_audio_codecs is not None: return _cached_audio_codecs + + output = run_ffmpeg_command(['-encoders']) + if not output: + if codec_type == 'video': _cached_video_codecs = {} + else: _cached_audio_codecs = {} + return {} + + video_codecs = {} + audio_codecs = {} + lines = output.split('\n') + + header_found = False + for line in lines: + if "------" in line: + header_found = True + continue + + if not header_found or not line.strip(): + continue + + parts = line.strip().split(None, 2) + if len(parts) < 3: + continue + + flags, name, description = parts + type_flag = flags[0] + clean_description = re.sub(r'\s*\(codec .*\)$', '', description).strip() + + if type_flag == 'V': + video_codecs[name] = clean_description + elif type_flag == 'A': + audio_codecs[name] = clean_description + + _cached_video_codecs = dict(sorted(video_codecs.items())) + _cached_audio_codecs = dict(sorted(audio_codecs.items())) + + return _cached_video_codecs if codec_type == 'video' else _cached_audio_codecs + class TimelineClip: def __init__(self, source_path, timeline_start_sec, clip_start_sec, duration_sec, track_index, track_type, media_type, group_id): self.id = str(uuid.uuid4()) @@ -1259,19 +1420,235 @@ class SettingsDialog(QDialog): def __init__(self, parent_settings, parent=None): super().__init__(parent) self.setWindowTitle("Settings") - self.setMinimumWidth(350) + self.setMinimumWidth(450) layout = QVBoxLayout(self) self.confirm_on_exit_checkbox = QCheckBox("Confirm before exiting") self.confirm_on_exit_checkbox.setChecked(parent_settings.get("confirm_on_exit", True)) layout.addWidget(self.confirm_on_exit_checkbox) + + export_path_group = QGroupBox("Default Export Path (for new projects)") + export_path_layout = QHBoxLayout() + self.default_export_path_edit = QLineEdit() + self.default_export_path_edit.setPlaceholderText("Optional: e.g., C:/Users/YourUser/Videos/Exports") + self.default_export_path_edit.setText(parent_settings.get("default_export_path", "")) + browse_button = QPushButton("Browse...") + browse_button.clicked.connect(self.browse_default_export_path) + export_path_layout.addWidget(self.default_export_path_edit) + export_path_layout.addWidget(browse_button) + export_path_group.setLayout(export_path_layout) + layout.addWidget(export_path_group) + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) + def browse_default_export_path(self): + path = QFileDialog.getExistingDirectory(self, "Select Default Export Folder", self.default_export_path_edit.text()) + if path: + self.default_export_path_edit.setText(path) + def get_settings(self): return { - "confirm_on_exit": self.confirm_on_exit_checkbox.isChecked() + "confirm_on_exit": self.confirm_on_exit_checkbox.isChecked(), + "default_export_path": self.default_export_path_edit.text(), + } + +class ExportDialog(QDialog): + def __init__(self, default_path, parent=None): + super().__init__(parent) + self.setWindowTitle("Export Settings") + self.setMinimumWidth(550) + + self.video_bitrate_options = ["500k", "1M", "2.5M", "5M", "8M", "15M", "Custom..."] + self.audio_bitrate_options = ["96k", "128k", "192k", "256k", "320k", "Custom..."] + self.display_ext_map = {'matroska': 'mkv', 'oga': 'ogg'} + + self.layout = QVBoxLayout(self) + self.formats = get_available_formats() + self.video_codecs = get_available_codecs('video') + self.audio_codecs = get_available_codecs('audio') + + self._setup_ui() + + self.path_edit.setText(default_path) + self.on_advanced_toggled(False) + + def _setup_ui(self): + output_group = QGroupBox("Output File") + output_layout = QHBoxLayout() + self.path_edit = QLineEdit() + browse_button = QPushButton("Browse...") + browse_button.clicked.connect(self.browse_output_path) + output_layout.addWidget(self.path_edit) + output_layout.addWidget(browse_button) + output_group.setLayout(output_layout) + self.layout.addWidget(output_group) + + self.container_combo = QComboBox() + self.container_combo.currentIndexChanged.connect(self.on_container_changed) + + self.advanced_formats_checkbox = QCheckBox("Show Advanced Options") + self.advanced_formats_checkbox.toggled.connect(self.on_advanced_toggled) + + container_layout = QFormLayout() + container_layout.addRow("Format Preset:", self.container_combo) + container_layout.addRow(self.advanced_formats_checkbox) + self.layout.addLayout(container_layout) + + self.video_group = QGroupBox("Video Settings") + video_layout = QFormLayout() + self.video_codec_combo = QComboBox() + video_layout.addRow("Video Codec:", self.video_codec_combo) + self.v_bitrate_combo = QComboBox() + self.v_bitrate_combo.addItems(self.video_bitrate_options) + self.v_bitrate_custom_edit = QLineEdit() + self.v_bitrate_custom_edit.setPlaceholderText("e.g., 6500k") + self.v_bitrate_custom_edit.hide() + self.v_bitrate_combo.currentTextChanged.connect(self.on_v_bitrate_changed) + video_layout.addRow("Video Bitrate:", self.v_bitrate_combo) + video_layout.addRow(self.v_bitrate_custom_edit) + self.video_group.setLayout(video_layout) + self.layout.addWidget(self.video_group) + + self.audio_group = QGroupBox("Audio Settings") + audio_layout = QFormLayout() + self.audio_codec_combo = QComboBox() + audio_layout.addRow("Audio Codec:", self.audio_codec_combo) + self.a_bitrate_combo = QComboBox() + self.a_bitrate_combo.addItems(self.audio_bitrate_options) + self.a_bitrate_custom_edit = QLineEdit() + self.a_bitrate_custom_edit.setPlaceholderText("e.g., 256k") + self.a_bitrate_custom_edit.hide() + self.a_bitrate_combo.currentTextChanged.connect(self.on_a_bitrate_changed) + audio_layout.addRow("Audio Bitrate:", self.a_bitrate_combo) + audio_layout.addRow(self.a_bitrate_custom_edit) + self.audio_group.setLayout(audio_layout) + self.layout.addWidget(self.audio_group) + + self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + self.layout.addWidget(self.button_box) + + def _populate_combo(self, combo, data_dict, filter_keys=None): + current_selection = combo.currentData() + combo.blockSignals(True) + combo.clear() + + keys_to_show = filter_keys if filter_keys is not None else data_dict.keys() + for codename in keys_to_show: + if codename in data_dict: + desc = data_dict[codename] + display_name = self.display_ext_map.get(codename, codename) if combo is self.container_combo else codename + combo.addItem(f"{desc} ({display_name})", codename) + + new_index = combo.findData(current_selection) + combo.setCurrentIndex(new_index if new_index != -1 else 0) + combo.blockSignals(False) + + def on_advanced_toggled(self, checked): + self._populate_combo(self.container_combo, self.formats, None if checked else CONTAINER_PRESETS.keys()) + + if not checked: + mp4_index = self.container_combo.findData("mp4") + if mp4_index != -1: self.container_combo.setCurrentIndex(mp4_index) + + self.on_container_changed(self.container_combo.currentIndex()) + + def apply_preset(self, container_codename): + preset = CONTAINER_PRESETS.get(container_codename, {}) + + vcodec = preset.get('vcodec') + self.video_group.setEnabled(vcodec is not None) + if vcodec: + vcodec_idx = self.video_codec_combo.findData(vcodec) + self.video_codec_combo.setCurrentIndex(vcodec_idx if vcodec_idx != -1 else 0) + + v_bitrate = preset.get('v_bitrate') + self.v_bitrate_combo.setEnabled(v_bitrate is not None) + if v_bitrate: + v_bitrate_idx = self.v_bitrate_combo.findText(v_bitrate) + if v_bitrate_idx != -1: self.v_bitrate_combo.setCurrentIndex(v_bitrate_idx) + else: + self.v_bitrate_combo.setCurrentText("Custom...") + self.v_bitrate_custom_edit.setText(v_bitrate) + + acodec = preset.get('acodec') + self.audio_group.setEnabled(acodec is not None) + if acodec: + acodec_idx = self.audio_codec_combo.findData(acodec) + self.audio_codec_combo.setCurrentIndex(acodec_idx if acodec_idx != -1 else 0) + + a_bitrate = preset.get('a_bitrate') + self.a_bitrate_combo.setEnabled(a_bitrate is not None) + if a_bitrate: + a_bitrate_idx = self.a_bitrate_combo.findText(a_bitrate) + if a_bitrate_idx != -1: self.a_bitrate_combo.setCurrentIndex(a_bitrate_idx) + else: + self.a_bitrate_combo.setCurrentText("Custom...") + self.a_bitrate_custom_edit.setText(a_bitrate) + + def on_v_bitrate_changed(self, text): + self.v_bitrate_custom_edit.setVisible(text == "Custom...") + + def on_a_bitrate_changed(self, text): + self.a_bitrate_custom_edit.setVisible(text == "Custom...") + + def browse_output_path(self): + container_codename = self.container_combo.currentData() + all_formats_desc = [f"{desc} (*.{name})" for name, desc in self.formats.items()] + filter_str = ";;".join(all_formats_desc) + current_desc = self.formats.get(container_codename, "Custom Format") + specific_filter = f"{current_desc} (*.{container_codename})" + final_filter = f"{specific_filter};;{filter_str};;All Files (*)" + + path, _ = QFileDialog.getSaveFileName(self, "Save Video As", self.path_edit.text(), final_filter) + if path: self.path_edit.setText(path) + + def on_container_changed(self, index): + if index == -1: return + new_container_codename = self.container_combo.itemData(index) + + is_advanced = self.advanced_formats_checkbox.isChecked() + preset = CONTAINER_PRESETS.get(new_container_codename) + + if not is_advanced and preset: + v_filter = preset.get('allowed_vcodecs') + a_filter = preset.get('allowed_acodecs') + self._populate_combo(self.video_codec_combo, self.video_codecs, v_filter) + self._populate_combo(self.audio_codec_combo, self.audio_codecs, a_filter) + else: + self._populate_combo(self.video_codec_combo, self.video_codecs) + self._populate_combo(self.audio_codec_combo, self.audio_codecs) + + if new_container_codename: + self.update_output_path_extension(new_container_codename) + self.apply_preset(new_container_codename) + + def update_output_path_extension(self, new_container_codename): + current_path = self.path_edit.text() + if not current_path: return + directory, filename = os.path.split(current_path) + basename, _ = os.path.splitext(filename) + ext = self.display_ext_map.get(new_container_codename, new_container_codename) + new_path = os.path.join(directory, f"{basename}.{ext}") + self.path_edit.setText(new_path) + + def get_export_settings(self): + v_bitrate = self.v_bitrate_combo.currentText() + if v_bitrate == "Custom...": v_bitrate = self.v_bitrate_custom_edit.text() + + a_bitrate = self.a_bitrate_combo.currentText() + if a_bitrate == "Custom...": a_bitrate = self.a_bitrate_custom_edit.text() + + return { + "output_path": self.path_edit.text(), + "container": self.container_combo.currentData(), + "vcodec": self.video_codec_combo.currentData() if self.video_group.isEnabled() else None, + "v_bitrate": v_bitrate if self.v_bitrate_combo.isEnabled() else None, + "acodec": self.audio_codec_combo.currentData() if self.audio_group.isEnabled() else None, + "a_bitrate": a_bitrate if self.a_bitrate_combo.isEnabled() else None, } class MediaListWidget(QListWidget): @@ -1391,6 +1768,7 @@ def __init__(self, project_to_load=None): self.export_thread = None self.export_worker = None self.current_project_path = None + self.last_export_path = None self.settings = {} self.settings_file = "settings.json" self.is_shutting_down = False @@ -1975,7 +2353,7 @@ def advance_playback_frame(self): def _load_settings(self): self.settings_file_was_loaded = False - defaults = {"window_visibility": {"project_media": False}, "splitter_state": None, "enabled_plugins": [], "recent_files": [], "confirm_on_exit": True} + defaults = {"window_visibility": {"project_media": False}, "splitter_state": None, "enabled_plugins": [], "recent_files": [], "confirm_on_exit": True, "default_export_path": ""} if os.path.exists(self.settings_file): try: with open(self.settings_file, "r") as f: self.settings = json.load(f) @@ -2026,6 +2404,7 @@ def new_project(self): self.timeline.clips.clear(); self.timeline.num_video_tracks = 1; self.timeline.num_audio_tracks = 1 self.media_pool.clear(); self.media_properties.clear(); self.project_media_widget.clear_list() self.current_project_path = None; self.stop_playback() + self.last_export_path = None self.project_fps = 25.0 self.project_width = 1280 self.project_height = 720 @@ -2044,6 +2423,7 @@ def save_project_as(self): "media_pool": self.media_pool, "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "media_type": c.media_type, "group_id": c.group_id} for c in self.timeline.clips], "selection_regions": self.timeline_widget.selection_regions, + "last_export_path": self.last_export_path, "settings": { "num_video_tracks": self.timeline.num_video_tracks, "num_audio_tracks": self.timeline.num_audio_tracks, @@ -2074,11 +2454,11 @@ def _load_project_from_path(self, path): self.project_height = project_settings.get("project_height", 720) self.project_fps = project_settings.get("project_fps", 25.0) self.timeline_widget.set_project_fps(self.project_fps) - + self.last_export_path = project_data.get("last_export_path") self.timeline_widget.selection_regions = project_data.get("selection_regions", []) - self.media_pool = project_data.get("media_pool", []) - for p in self.media_pool: self._add_media_to_pool(p) + media_pool_paths = project_data.get("media_pool", []) + for p in media_pool_paths: self._add_media_to_pool(p) for clip_data in project_data["clips"]: if not os.path.exists(clip_data["source_path"]): @@ -2520,9 +2900,41 @@ def action(): def export_video(self): - if not self.timeline.clips: self.status_label.setText("Timeline is empty."); return - output_path, _ = QFileDialog.getSaveFileName(self, "Save Video As", "", "MP4 Files (*.mp4)") - if not output_path: return + if not self.timeline.clips: + self.status_label.setText("Timeline is empty.") + return + + default_path = "" + if self.last_export_path and os.path.isdir(os.path.dirname(self.last_export_path)): + default_path = self.last_export_path + elif self.settings.get("default_export_path") and os.path.isdir(self.settings.get("default_export_path")): + proj_basename = "output" + if self.current_project_path: + _, proj_file = os.path.split(self.current_project_path) + proj_basename, _ = os.path.splitext(proj_file) + + default_path = os.path.join(self.settings["default_export_path"], f"{proj_basename}_export.mp4") + elif self.current_project_path: + proj_dir, proj_file = os.path.split(self.current_project_path) + proj_basename, _ = os.path.splitext(proj_file) + default_path = os.path.join(proj_dir, f"{proj_basename}_export.mp4") + else: + default_path = "output.mp4" + + default_path = os.path.normpath(default_path) + + dialog = ExportDialog(default_path, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + self.status_label.setText("Export canceled.") + return + + settings = dialog.get_export_settings() + output_path = settings["output_path"] + if not output_path: + self.status_label.setText("Export failed: No output path specified.") + return + + self.last_export_path = output_path w, h, fr_str, total_dur = self.project_width, self.project_height, str(self.project_fps), self.timeline.get_total_duration() sample_rate, channel_layout = '44100', 'stereo' @@ -2533,9 +2945,7 @@ def export_video(self): [c for c in self.timeline.clips if c.track_type == 'video'], key=lambda c: c.track_index ) - input_nodes = {} - for clip in all_video_clips: if clip.source_path not in input_nodes: if clip.media_type == 'image': @@ -2544,31 +2954,13 @@ def export_video(self): input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) clip_source_node = input_nodes[clip.source_path] - if clip.media_type == 'image': - segment_stream = ( - clip_source_node.video - .trim(duration=clip.duration_sec) - .setpts('PTS-STARTPTS') - ) + segment_stream = (clip_source_node.video.trim(duration=clip.duration_sec).setpts('PTS-STARTPTS')) else: - segment_stream = ( - clip_source_node.video - .trim(start=clip.clip_start_sec, duration=clip.duration_sec) - .setpts('PTS-STARTPTS') - ) + segment_stream = (clip_source_node.video.trim(start=clip.clip_start_sec, duration=clip.duration_sec).setpts('PTS-STARTPTS')) - processed_segment = ( - segment_stream - .filter('scale', w, h, force_original_aspect_ratio='decrease') - .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') - ) - - video_stream = ffmpeg.overlay( - video_stream, - processed_segment, - enable=f'between(t,{clip.timeline_start_sec},{clip.timeline_end_sec})' - ) + processed_segment = (segment_stream.filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black')) + video_stream = ffmpeg.overlay(video_stream, processed_segment, enable=f'between(t,{clip.timeline_start_sec},{clip.timeline_end_sec})') final_video = video_stream.filter('format', pix_fmts='yuv420p').filter('fps', fps=self.project_fps) @@ -2576,22 +2968,17 @@ def export_video(self): for i in range(self.timeline.num_audio_tracks): track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == i + 1], key=lambda c: c.timeline_start_sec) if not track_clips: continue - track_segments = [] last_end = track_clips[0].timeline_start_sec - for clip in track_clips: if clip.source_path not in input_nodes: input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) - gap = clip.timeline_start_sec - last_end if gap > 0.01: track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap}', f='lavfi')) - a_seg = input_nodes[clip.source_path].audio.filter('atrim', start=clip.clip_start_sec, duration=clip.duration_sec).filter('asetpts', 'PTS-STARTPTS') track_segments.append(a_seg) last_end = clip.timeline_end_sec - track_audio_streams.append(ffmpeg.concat(*track_segments, v=0, a=1).filter('adelay', f'{int(track_clips[0].timeline_start_sec * 1000)}ms', all=True)) if track_audio_streams: @@ -2599,7 +2986,10 @@ def export_video(self): else: final_audio = ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={total_dur}', f='lavfi') - output_args = {'vcodec': 'libx264', 'acodec': 'aac', 'pix_fmt': 'yuv420p', 'b:v': '5M'} + output_args = {'vcodec': settings['vcodec'], 'acodec': settings['acodec'], 'pix_fmt': 'yuv420p'} + if settings['v_bitrate']: output_args['b:v'] = settings['v_bitrate'] + if settings['a_bitrate']: output_args['b:a'] = settings['a_bitrate'] + try: ffmpeg_cmd = ffmpeg.output(final_video, final_audio, output_path, **output_args).overwrite_output().compile() self.progress_bar.setVisible(True); self.progress_bar.setValue(0); self.status_label.setText("Exporting...") From cdcde06778a07ee55be95f6bc64dd9d1e9e4bfd3 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 21:26:42 +1100 Subject: [PATCH 53/67] use ms instead of sec globally --- plugins/wan2gp/main.py | 67 ++--- videoeditor.py | 612 +++++++++++++++++++++-------------------- 2 files changed, 347 insertions(+), 332 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 623d4afa7..473baf2e4 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -124,11 +124,12 @@ def enterEvent(self, event): super().enterEvent(event) self.media_player.play() if not self.plugin.active_region or self.duration == 0: return - start_sec, _ = self.plugin.active_region + start_ms, _ = self.plugin.active_region timeline = self.app.timeline_widget video_rect, audio_rect = None, None - x = timeline.sec_to_x(start_sec) - w = int(self.duration * timeline.pixels_per_second) + x = timeline.ms_to_x(start_ms) + duration_ms = int(self.duration * 1000) + w = int(duration_ms * timeline.pixels_per_ms) if self.plugin.insert_on_new_track: video_y = timeline.TIMESCALE_HEIGHT video_rect = QRectF(x, video_y, w, timeline.TRACK_HEIGHT) @@ -1705,9 +1706,9 @@ def on_timeline_context_menu(self, menu, event): region = self.app.timeline_widget.get_region_at_pos(event.pos()) if region: menu.addSeparator() - start_sec, end_sec = region - start_data, _, _ = self.app.get_frame_data_at_time(start_sec) - end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + start_ms, end_ms = region + start_data, _, _ = self.app.get_frame_data_at_time(start_ms) + end_data, _, _ = self.app.get_frame_data_at_time(end_ms) if start_data and end_data: join_action = menu.addAction("Join Frames With AI") @@ -1745,9 +1746,9 @@ def setup_generator_for_region(self, region, on_new_track=False): else: print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") - start_sec, end_sec = region - start_data, w, h = self.app.get_frame_data_at_time(start_sec) - end_data, _, _ = self.app.get_frame_data_at_time(end_sec) + start_ms, end_ms = region + start_data, w, h = self.app.get_frame_data_at_time(start_ms) + end_data, _, _ = self.app.get_frame_data_at_time(end_ms) if not start_data or not end_data: QMessageBox.warning(self.app, "Frame Error", "Could not extract start and/or end frames.") return @@ -1761,11 +1762,11 @@ def setup_generator_for_region(self, region, on_new_track=False): QImage(start_data, w, h, QImage.Format.Format_RGB888).save(self.start_frame_path) QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) - duration_sec = end_sec - start_sec + duration_ms = end_ms - start_ms wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) - video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) widgets = self.client_widget.widgets for w_name in ['mode_s', 'mode_t', 'mode_v', 'mode_l', 'image_end_checkbox']: @@ -1786,7 +1787,7 @@ def setup_generator_for_region(self, region, on_new_track=False): QMessageBox.critical(self.app, "File Error", f"Could not save temporary frame images: {e}") self._cleanup_temp_dir() return - self.app.status_label.setText(f"Ready to join frames from {start_sec:.2f}s to {end_sec:.2f}s.") + self.app.status_label.setText(f"Ready to join frames from {start_ms / 1000.0:.2f}s to {end_ms / 1000.0:.2f}s.") self.dock_widget.show() self.dock_widget.raise_() @@ -1805,8 +1806,8 @@ def setup_generator_from_start(self, region, on_new_track=False): else: print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") - start_sec, end_sec = region - start_data, w, h = self.app.get_frame_data_at_time(start_sec) + start_ms, end_ms = region + start_data, w, h = self.app.get_frame_data_at_time(start_ms) if not start_data: QMessageBox.warning(self.app, "Frame Error", "Could not extract start frame.") return @@ -1818,11 +1819,11 @@ def setup_generator_from_start(self, region, on_new_track=False): self.start_frame_path = os.path.join(self.temp_dir, "start_frame.png") QImage(start_data, w, h, QImage.Format.Format_RGB888).save(self.start_frame_path) - duration_sec = end_sec - start_sec + duration_ms = end_ms - start_ms wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) - video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) widgets = self.client_widget.widgets widgets['video_length'].setValue(video_length_frames) @@ -1845,7 +1846,7 @@ def setup_generator_from_start(self, region, on_new_track=False): self._cleanup_temp_dir() return - self.app.status_label.setText(f"Ready to generate from frame at {start_sec:.2f}s.") + self.app.status_label.setText(f"Ready to generate from frame at {start_ms / 1000.0:.2f}s.") self.dock_widget.show() self.dock_widget.raise_() @@ -1864,8 +1865,8 @@ def setup_generator_to_end(self, region, on_new_track=False): else: print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") - start_sec, end_sec = region - end_data, w, h = self.app.get_frame_data_at_time(end_sec) + start_ms, end_ms = region + end_data, w, h = self.app.get_frame_data_at_time(end_ms) if not end_data: QMessageBox.warning(self.app, "Frame Error", "Could not extract end frame.") return @@ -1877,11 +1878,11 @@ def setup_generator_to_end(self, region, on_new_track=False): self.end_frame_path = os.path.join(self.temp_dir, "end_frame.png") QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) - duration_sec = end_sec - start_sec + duration_ms = end_ms - start_ms wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) - video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) widgets = self.client_widget.widgets widgets['video_length'].setValue(video_length_frames) @@ -1915,7 +1916,7 @@ def setup_generator_to_end(self, region, on_new_track=False): self._cleanup_temp_dir() return - self.app.status_label.setText(f"Ready to generate to frame at {end_sec:.2f}s.") + self.app.status_label.setText(f"Ready to generate to frame at {end_ms / 1000.0:.2f}s.") self.dock_widget.show() self.dock_widget.raise_() @@ -1938,17 +1939,17 @@ def setup_creator_for_region(self, region, on_new_track=False): target_h = self.app.project_height self.client_widget.set_resolution_from_target(target_w, target_h) - start_sec, end_sec = region - duration_sec = end_sec - start_sec + start_ms, end_ms = region + duration_ms = end_ms - start_ms wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) - video_length_frames = int(duration_sec * fps) if fps > 0 else int(duration_sec * 16) + video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) self.client_widget.widgets['video_length'].setValue(video_length_frames) self.client_widget.widgets['mode_t'].setChecked(True) - self.app.status_label.setText(f"Ready to create video from {start_sec:.2f}s to {end_sec:.2f}s.") + self.app.status_label.setText(f"Ready to create video from {start_ms / 1000.0:.2f}s to {end_ms / 1000.0:.2f}s.") self.dock_widget.show() self.dock_widget.raise_() @@ -1958,29 +1959,29 @@ def insert_generated_clip(self, video_path): self.app.status_label.setText("Error: No active region to insert into."); return if not os.path.exists(video_path): self.app.status_label.setText(f"Error: Output file not found: {video_path}"); return - start_sec, end_sec = self.active_region + start_ms, end_ms = self.active_region def complex_insertion_action(): self.app._add_media_files_to_project([video_path]) media_info = self.app.media_properties.get(video_path) if not media_info: raise ValueError("Could not probe inserted clip.") - actual_duration, has_audio = media_info['duration'], media_info['has_audio'] + actual_duration_ms, has_audio = media_info['duration_ms'], media_info['has_audio'] if self.insert_on_new_track: self.app.timeline.num_video_tracks += 1 video_track_index = self.app.timeline.num_video_tracks audio_track_index = self.app.timeline.num_audio_tracks + 1 if has_audio else None else: - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_sec) - for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_sec) - clips_to_remove = [c for c in self.app.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_end_sec <= end_sec] + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, start_ms) + for clip in list(self.app.timeline.clips): self.app._split_at_time(clip, end_ms) + clips_to_remove = [c for c in self.app.timeline.clips if c.timeline_start_ms >= start_ms and c.timeline_end_ms <= end_ms] for clip in clips_to_remove: if clip in self.app.timeline.clips: self.app.timeline.clips.remove(clip) video_track_index, audio_track_index = 1, 1 if has_audio else None group_id = str(uuid.uuid4()) - new_clip = TimelineClip(video_path, start_sec, 0, actual_duration, video_track_index, 'video', 'video', group_id) + new_clip = TimelineClip(video_path, start_ms, 0, actual_duration_ms, video_track_index, 'video', 'video', group_id) self.app.timeline.add_clip(new_clip) if audio_track_index: if audio_track_index > self.app.timeline.num_audio_tracks: self.app.timeline.num_audio_tracks = audio_track_index - audio_clip = TimelineClip(video_path, start_sec, 0, actual_duration, audio_track_index, 'audio', 'video', group_id) + audio_clip = TimelineClip(video_path, start_ms, 0, actual_duration_ms, audio_track_index, 'audio', 'video', group_id) self.app.timeline.add_clip(audio_clip) try: self.app._perform_complex_timeline_change("Insert AI Clip", complex_insertion_action) diff --git a/videoeditor.py b/videoeditor.py index b6673c879..5126b79b5 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -181,20 +181,20 @@ def get_available_codecs(codec_type='video'): return _cached_video_codecs if codec_type == 'video' else _cached_audio_codecs class TimelineClip: - def __init__(self, source_path, timeline_start_sec, clip_start_sec, duration_sec, track_index, track_type, media_type, group_id): + def __init__(self, source_path, timeline_start_ms, clip_start_ms, duration_ms, track_index, track_type, media_type, group_id): self.id = str(uuid.uuid4()) self.source_path = source_path - self.timeline_start_sec = timeline_start_sec - self.clip_start_sec = clip_start_sec - self.duration_sec = duration_sec + self.timeline_start_ms = int(timeline_start_ms) + self.clip_start_ms = int(clip_start_ms) + self.duration_ms = int(duration_ms) self.track_index = track_index self.track_type = track_type self.media_type = media_type self.group_id = group_id @property - def timeline_end_sec(self): - return self.timeline_start_sec + self.duration_sec + def timeline_end_ms(self): + return self.timeline_start_ms + self.duration_ms class Timeline: def __init__(self): @@ -204,19 +204,20 @@ def __init__(self): def add_clip(self, clip): self.clips.append(clip) - self.clips.sort(key=lambda c: c.timeline_start_sec) + self.clips.sort(key=lambda c: c.timeline_start_ms) def get_total_duration(self): if not self.clips: return 0 - return max(c.timeline_end_sec for c in self.clips) + return max(c.timeline_end_ms for c in self.clips) class ExportWorker(QObject): progress = pyqtSignal(int) finished = pyqtSignal(str) - def __init__(self, ffmpeg_cmd, total_duration): + def __init__(self, ffmpeg_cmd, total_duration_ms): super().__init__() self.ffmpeg_cmd = ffmpeg_cmd - self.total_duration_secs = total_duration + self.total_duration_ms = total_duration_ms + def run_export(self): try: process = subprocess.Popen(self.ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, encoding="utf-8") @@ -224,10 +225,10 @@ def run_export(self): for line in iter(process.stdout.readline, ""): match = time_pattern.search(line) if match: - hours, minutes, seconds = [int(g) for g in match.groups()[:3]] - processed_time = hours * 3600 + minutes * 60 + seconds - if self.total_duration_secs > 0: - percentage = int((processed_time / self.total_duration_secs) * 100) + h, m, s, cs = [int(g) for g in match.groups()] + processed_ms = (h * 3600 + m * 60 + s) * 1000 + cs * 10 + if self.total_duration_ms > 0: + percentage = int((processed_ms / self.total_duration_ms) * 100) self.progress.emit(percentage) process.stdout.close() return_code = process.wait() @@ -250,7 +251,7 @@ class TimelineWidget(QWidget): split_requested = pyqtSignal(object) delete_clip_requested = pyqtSignal(object) delete_clips_requested = pyqtSignal(list) - playhead_moved = pyqtSignal(float) + playhead_moved = pyqtSignal(int) split_region_requested = pyqtSignal(list) split_all_regions_requested = pyqtSignal(list) join_region_requested = pyqtSignal(list) @@ -266,14 +267,14 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): super().__init__(parent) self.timeline = timeline_model self.settings = settings - self.playhead_pos_sec = 0.0 - self.view_start_sec = 0.0 + self.playhead_pos_ms = 0 + self.view_start_ms = 0 self.panning = False self.pan_start_pos = QPoint() - self.pan_start_view_sec = 0.0 + self.pan_start_view_ms = 0 - self.pixels_per_second = 50.0 - self.max_pixels_per_second = 1.0 + self.pixels_per_ms = 0.05 + self.max_pixels_per_ms = 1.0 self.project_fps = 25.0 self.set_project_fps(project_fps) @@ -290,7 +291,7 @@ def __init__(self, timeline_model, settings, project_fps, parent=None): self.dragging_selection_region = None self.drag_start_pos = QPoint() self.drag_original_clip_states = {} - self.selection_drag_start_sec = 0.0 + self.selection_drag_start_ms = 0 self.drag_selection_start_values = None self.drag_start_state = None @@ -325,12 +326,12 @@ def set_hover_preview_rects(self, video_rect, audio_rect): def set_project_fps(self, fps): self.project_fps = fps if fps > 0 else 25.0 - self.max_pixels_per_second = self.project_fps * 20 - self.pixels_per_second = min(self.pixels_per_second, self.max_pixels_per_second) + self.max_pixels_per_ms = (self.project_fps * 20) / 1000.0 + self.pixels_per_ms = min(self.pixels_per_ms, self.max_pixels_per_ms) self.update() - def sec_to_x(self, sec): return self.HEADER_WIDTH + int((sec - self.view_start_sec) * self.pixels_per_second) - def x_to_sec(self, x): return self.view_start_sec + float(x - self.HEADER_WIDTH) / self.pixels_per_second if x > self.HEADER_WIDTH and self.pixels_per_second > 0 else self.view_start_sec + def ms_to_x(self, ms): return self.HEADER_WIDTH + int((ms - self.view_start_ms) * self.pixels_per_ms) + def x_to_ms(self, x): return self.view_start_ms + int(float(x - self.HEADER_WIDTH) / self.pixels_per_ms) if x > self.HEADER_WIDTH and self.pixels_per_ms > 0 else self.view_start_ms def paintEvent(self, event): painter = QPainter(self) @@ -438,11 +439,12 @@ def draw_headers(self, painter): painter.restore() - def _format_timecode(self, seconds): - if abs(seconds) < 1e-9: seconds = 0 - sign = "-" if seconds < 0 else "" - seconds = abs(seconds) + def _format_timecode(self, total_ms): + if abs(total_ms) < 1: total_ms = 0 + sign = "-" if total_ms < 0 else "" + total_ms = abs(total_ms) + seconds = total_ms / 1000.0 h = int(seconds / 3600) m = int((seconds % 3600) / 60) s = seconds % 60 @@ -464,51 +466,51 @@ def draw_timescale(self, painter): painter.fillRect(QRect(self.HEADER_WIDTH, 0, self.width() - self.HEADER_WIDTH, self.TIMESCALE_HEIGHT), QColor("#222")) painter.drawLine(self.HEADER_WIDTH, self.TIMESCALE_HEIGHT - 1, self.width(), self.TIMESCALE_HEIGHT - 1) - frame_dur = 1.0 / self.project_fps - intervals = [ - frame_dur, 2*frame_dur, 5*frame_dur, 10*frame_dur, - 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, - 60, 120, 300, 600, 900, 1800, - 3600, 2*3600, 5*3600, 10*3600 + frame_dur_ms = 1000.0 / self.project_fps + intervals_ms = [ + int(frame_dur_ms), int(2*frame_dur_ms), int(5*frame_dur_ms), int(10*frame_dur_ms), + 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, + 60000, 120000, 300000, 600000, 900000, 1800000, + 3600000, 2*3600000, 5*3600000, 10*3600000 ] min_pixel_dist = 70 - major_interval = next((i for i in intervals if i * self.pixels_per_second > min_pixel_dist), intervals[-1]) + major_interval = next((i for i in intervals_ms if i * self.pixels_per_ms > min_pixel_dist), intervals_ms[-1]) minor_interval = 0 for divisor in [5, 4, 2]: - if (major_interval / divisor) * self.pixels_per_second > 10: + if (major_interval / divisor) * self.pixels_per_ms > 10: minor_interval = major_interval / divisor break - start_sec = self.x_to_sec(self.HEADER_WIDTH) - end_sec = self.x_to_sec(self.width()) + start_ms = self.x_to_ms(self.HEADER_WIDTH) + end_ms = self.x_to_ms(self.width()) - def draw_ticks(interval, height): - if interval <= 1e-6: return - start_tick_num = int(start_sec / interval) - end_tick_num = int(end_sec / interval) + 1 + def draw_ticks(interval_ms, height): + if interval_ms < 1: return + start_tick_num = int(start_ms / interval_ms) + end_tick_num = int(end_ms / interval_ms) + 1 for i in range(start_tick_num, end_tick_num + 1): - t_sec = i * interval - x = self.sec_to_x(t_sec) + t_ms = i * interval_ms + x = self.ms_to_x(t_ms) if x > self.width(): break if x >= self.HEADER_WIDTH: painter.drawLine(x, self.TIMESCALE_HEIGHT - height, x, self.TIMESCALE_HEIGHT) - if frame_dur * self.pixels_per_second > 4: - draw_ticks(frame_dur, 3) + if frame_dur_ms * self.pixels_per_ms > 4: + draw_ticks(frame_dur_ms, 3) if minor_interval > 0: draw_ticks(minor_interval, 6) - start_major_tick = int(start_sec / major_interval) - end_major_tick = int(end_sec / major_interval) + 1 + start_major_tick = int(start_ms / major_interval) + end_major_tick = int(end_ms / major_interval) + 1 for i in range(start_major_tick, end_major_tick + 1): - t_sec = i * major_interval - x = self.sec_to_x(t_sec) + t_ms = i * major_interval + x = self.ms_to_x(t_ms) if x > self.width() + 50: break if x >= self.HEADER_WIDTH - 50: painter.drawLine(x, self.TIMESCALE_HEIGHT - 12, x, self.TIMESCALE_HEIGHT) - label = self._format_timecode(t_sec) + label = self._format_timecode(t_ms) label_width = font_metrics.horizontalAdvance(label) label_x = x - label_width // 2 if label_x < self.HEADER_WIDTH: @@ -525,8 +527,8 @@ def get_clip_rect(self, clip): visual_index = clip.track_index - 1 y = self.audio_tracks_y_start + visual_index * self.TRACK_HEIGHT - x = self.sec_to_x(clip.timeline_start_sec) - w = int(clip.duration_sec * self.pixels_per_second) + x = self.ms_to_x(clip.timeline_start_ms) + w = int(clip.duration_ms * self.pixels_per_ms) clip_height = self.TRACK_HEIGHT - 10 y += (self.TRACK_HEIGHT - clip_height) / 2 return QRectF(x, y, w, clip_height) @@ -598,16 +600,16 @@ def draw_tracks_and_clips(self, painter): painter.restore() def draw_selections(self, painter): - for start_sec, end_sec in self.selection_regions: - x = self.sec_to_x(start_sec) - w = int((end_sec - start_sec) * self.pixels_per_second) + for start_ms, end_ms in self.selection_regions: + x = self.ms_to_x(start_ms) + w = int((end_ms - start_ms) * self.pixels_per_ms) selection_rect = QRectF(x, self.TIMESCALE_HEIGHT, w, self.height() - self.TIMESCALE_HEIGHT) painter.fillRect(selection_rect, QColor(100, 100, 255, 80)) painter.setPen(QColor(150, 150, 255, 150)) painter.drawRect(selection_rect) def draw_playhead(self, painter): - playhead_x = self.sec_to_x(self.playhead_pos_sec) + playhead_x = self.ms_to_x(self.playhead_pos_ms) painter.setPen(QPen(QColor("red"), 2)) painter.drawLine(playhead_x, 0, playhead_x, self.height()) @@ -634,48 +636,48 @@ def y_to_track_info(self, y): return None - def _snap_time_if_needed(self, time_sec): - frame_duration = 1.0 / self.project_fps - if frame_duration > 0 and frame_duration * self.pixels_per_second > 4: - frame_number = round(time_sec / frame_duration) - return frame_number * frame_duration - return time_sec + def _snap_time_if_needed(self, time_ms): + frame_duration_ms = 1000.0 / self.project_fps + if frame_duration_ms > 0 and frame_duration_ms * self.pixels_per_ms > 4: + frame_number = round(time_ms / frame_duration_ms) + return int(frame_number * frame_duration_ms) + return int(time_ms) def get_region_at_pos(self, pos: QPoint): if pos.y() <= self.TIMESCALE_HEIGHT or pos.x() <= self.HEADER_WIDTH: return None - clicked_sec = self.x_to_sec(pos.x()) + clicked_ms = self.x_to_ms(pos.x()) for region in reversed(self.selection_regions): - if region[0] <= clicked_sec <= region[1]: + if region[0] <= clicked_ms <= region[1]: return region return None def wheelEvent(self, event: QMouseEvent): delta = event.angleDelta().y() zoom_factor = 1.15 - old_pps = self.pixels_per_second + old_pps = self.pixels_per_ms if delta > 0: new_pps = old_pps * zoom_factor else: new_pps = old_pps / zoom_factor - min_pps = 1 / (3600 * 10) - new_pps = max(min_pps, min(new_pps, self.max_pixels_per_second)) + min_pps = 1 / (3600 * 10 * 1000) + new_pps = max(min_pps, min(new_pps, self.max_pixels_per_ms)) if abs(new_pps - old_pps) < 1e-9: return if event.position().x() < self.HEADER_WIDTH: - new_view_start_sec = self.view_start_sec * (old_pps / new_pps) + new_view_start_ms = self.view_start_ms * (old_pps / new_pps) else: mouse_x = event.position().x() - time_at_cursor = self.x_to_sec(mouse_x) - new_view_start_sec = time_at_cursor - (mouse_x - self.HEADER_WIDTH) / new_pps + time_at_cursor = self.x_to_ms(mouse_x) + new_view_start_ms = time_at_cursor - (mouse_x - self.HEADER_WIDTH) / new_pps - self.pixels_per_second = new_pps - self.view_start_sec = max(0.0, new_view_start_sec) + self.pixels_per_ms = new_pps + self.view_start_ms = int(max(0, new_view_start_ms)) self.update() event.accept() @@ -684,7 +686,7 @@ def mousePressEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.MiddleButton: self.panning = True self.pan_start_pos = event.pos() - self.pan_start_view_sec = self.view_start_sec + self.pan_start_view_ms = self.view_start_ms self.setCursor(Qt.CursorShape.ClosedHandCursor) event.accept() return @@ -713,8 +715,8 @@ def mousePressEvent(self, event: QMouseEvent): for region in self.selection_regions: if not region: continue - x_start = self.sec_to_x(region[0]) - x_end = self.sec_to_x(region[1]) + x_start = self.ms_to_x(region[0]) + x_end = self.ms_to_x(region[1]) if event.pos().y() > self.TIMESCALE_HEIGHT: if abs(event.pos().x() - x_start) < self.RESIZE_HANDLE_WIDTH: self.resizing_selection_region = region @@ -768,12 +770,12 @@ def mousePressEvent(self, event: QMouseEvent): if clicked_clip.id in self.selected_clips: self.dragging_clip = clicked_clip self.drag_start_state = self.window()._get_current_timeline_state() - self.drag_original_clip_states[clicked_clip.id] = (clicked_clip.timeline_start_sec, clicked_clip.track_index) + self.drag_original_clip_states[clicked_clip.id] = (clicked_clip.timeline_start_ms, clicked_clip.track_index) self.dragging_linked_clip = next((c for c in self.timeline.clips if c.group_id == clicked_clip.group_id and c.id != clicked_clip.id), None) if self.dragging_linked_clip: self.drag_original_clip_states[self.dragging_linked_clip.id] = \ - (self.dragging_linked_clip.timeline_start_sec, self.dragging_linked_clip.track_index) + (self.dragging_linked_clip.timeline_start_ms, self.dragging_linked_clip.track_index) self.drag_start_pos = event.pos() else: @@ -789,16 +791,16 @@ def mousePressEvent(self, event: QMouseEvent): if is_in_track_area: self.creating_selection_region = True - playhead_x = self.sec_to_x(self.playhead_pos_sec) + playhead_x = self.ms_to_x(self.playhead_pos_ms) if abs(event.pos().x() - playhead_x) < self.SNAP_THRESHOLD_PIXELS: - self.selection_drag_start_sec = self.playhead_pos_sec + self.selection_drag_start_ms = self.playhead_pos_ms else: - self.selection_drag_start_sec = self.x_to_sec(event.pos().x()) - self.selection_regions.append([self.selection_drag_start_sec, self.selection_drag_start_sec]) + self.selection_drag_start_ms = self.x_to_ms(event.pos().x()) + self.selection_regions.append([self.selection_drag_start_ms, self.selection_drag_start_ms]) elif is_on_timescale: - time_sec = max(0, self.x_to_sec(event.pos().x())) - self.playhead_pos_sec = self._snap_time_if_needed(time_sec) - self.playhead_moved.emit(self.playhead_pos_sec) + time_ms = max(0, self.x_to_ms(event.pos().x())) + self.playhead_pos_ms = self._snap_time_if_needed(time_ms) + self.playhead_moved.emit(self.playhead_pos_ms) self.dragging_playhead = True self.update() @@ -806,22 +808,22 @@ def mousePressEvent(self, event: QMouseEvent): def mouseMoveEvent(self, event: QMouseEvent): if self.panning: delta_x = event.pos().x() - self.pan_start_pos.x() - time_delta = delta_x / self.pixels_per_second - new_view_start = self.pan_start_view_sec - time_delta - self.view_start_sec = max(0.0, new_view_start) + time_delta = delta_x / self.pixels_per_ms + new_view_start = self.pan_start_view_ms - time_delta + self.view_start_ms = int(max(0, new_view_start)) self.update() return if self.resizing_selection_region: - current_sec = max(0, self.x_to_sec(event.pos().x())) + current_ms = max(0, self.x_to_ms(event.pos().x())) original_start, original_end = self.resize_selection_start_values if self.resize_selection_edge == 'left': - new_start = current_sec + new_start = current_ms new_end = original_end else: # right new_start = original_start - new_end = current_sec + new_end = current_ms self.resizing_selection_region[0] = min(new_start, new_end) self.resizing_selection_region[1] = max(new_start, new_end) @@ -836,60 +838,60 @@ def mouseMoveEvent(self, event: QMouseEvent): if self.resizing_clip: linked_clip = next((c for c in self.timeline.clips if c.group_id == self.resizing_clip.group_id and c.id != self.resizing_clip.id), None) delta_x = event.pos().x() - self.resize_start_pos.x() - time_delta = delta_x / self.pixels_per_second - min_duration = 1.0 / self.project_fps - snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_second + time_delta = delta_x / self.pixels_per_ms + min_duration_ms = int(1000 / self.project_fps) + snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_ms - snap_points = [self.playhead_pos_sec] + snap_points = [self.playhead_pos_ms] for clip in self.timeline.clips: if clip.id == self.resizing_clip.id: continue if linked_clip and clip.id == linked_clip.id: continue - snap_points.append(clip.timeline_start_sec) - snap_points.append(clip.timeline_end_sec) + snap_points.append(clip.timeline_start_ms) + snap_points.append(clip.timeline_end_ms) media_props = self.window().media_properties.get(self.resizing_clip.source_path) - source_duration = media_props['duration'] if media_props else float('inf') + source_duration_ms = media_props['duration_ms'] if media_props else float('inf') if self.resize_edge == 'left': - original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_sec - original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_sec - original_clip_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].clip_start_sec - true_new_start_sec = original_start + time_delta + original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_ms + original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_ms + original_clip_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].clip_start_ms + true_new_start_ms = original_start + time_delta - new_start_sec = true_new_start_sec + new_start_ms = true_new_start_ms for snap_point in snap_points: - if abs(true_new_start_sec - snap_point) < snap_time_delta: - new_start_sec = snap_point + if abs(true_new_start_ms - snap_point) < snap_time_delta: + new_start_ms = snap_point break - if new_start_sec > original_start + original_duration - min_duration: - new_start_sec = original_start + original_duration - min_duration + if new_start_ms > original_start + original_duration - min_duration_ms: + new_start_ms = original_start + original_duration - min_duration_ms - new_start_sec = max(0, new_start_sec) + new_start_ms = max(0, new_start_ms) if self.resizing_clip.media_type != 'image': - if new_start_sec < original_start - original_clip_start: - new_start_sec = original_start - original_clip_start + if new_start_ms < original_start - original_clip_start: + new_start_ms = original_start - original_clip_start - new_duration = (original_start + original_duration) - new_start_sec - new_clip_start = original_clip_start + (new_start_sec - original_start) + new_duration = (original_start + original_duration) - new_start_ms + new_clip_start = original_clip_start + (new_start_ms - original_start) - if new_duration < min_duration: - new_duration = min_duration - new_start_sec = (original_start + original_duration) - new_duration - new_clip_start = original_clip_start + (new_start_sec - original_start) - - self.resizing_clip.timeline_start_sec = new_start_sec - self.resizing_clip.duration_sec = new_duration - self.resizing_clip.clip_start_sec = new_clip_start + if new_duration < min_duration_ms: + new_duration = min_duration_ms + new_start_ms = (original_start + original_duration) - new_duration + new_clip_start = original_clip_start + (new_start_ms - original_start) + + self.resizing_clip.timeline_start_ms = int(new_start_ms) + self.resizing_clip.duration_ms = int(new_duration) + self.resizing_clip.clip_start_ms = int(new_clip_start) if linked_clip: - linked_clip.timeline_start_sec = new_start_sec - linked_clip.duration_sec = new_duration - linked_clip.clip_start_sec = new_clip_start + linked_clip.timeline_start_ms = int(new_start_ms) + linked_clip.duration_ms = int(new_duration) + linked_clip.clip_start_ms = int(new_clip_start) elif self.resize_edge == 'right': - original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_sec - original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_sec + original_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].timeline_start_ms + original_duration = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].duration_ms true_new_duration = original_duration + time_delta true_new_end_time = original_start + true_new_duration @@ -902,23 +904,23 @@ def mouseMoveEvent(self, event: QMouseEvent): new_duration = new_end_time - original_start - if new_duration < min_duration: - new_duration = min_duration + if new_duration < min_duration_ms: + new_duration = min_duration_ms if self.resizing_clip.media_type != 'image': - if self.resizing_clip.clip_start_sec + new_duration > source_duration: - new_duration = source_duration - self.resizing_clip.clip_start_sec + if self.resizing_clip.clip_start_ms + new_duration > source_duration_ms: + new_duration = source_duration_ms - self.resizing_clip.clip_start_ms - self.resizing_clip.duration_sec = new_duration + self.resizing_clip.duration_ms = int(new_duration) if linked_clip: - linked_clip.duration_sec = new_duration + linked_clip.duration_ms = int(new_duration) self.update() return if not self.dragging_clip and not self.dragging_playhead and not self.creating_selection_region: cursor_set = False - playhead_x = self.sec_to_x(self.playhead_pos_sec) + playhead_x = self.ms_to_x(self.playhead_pos_ms) is_in_track_area = event.pos().y() > self.TIMESCALE_HEIGHT and event.pos().x() > self.HEADER_WIDTH if is_in_track_area and abs(event.pos().x() - playhead_x) < self.SNAP_THRESHOLD_PIXELS: self.setCursor(Qt.CursorShape.SizeHorCursor) @@ -926,8 +928,8 @@ def mouseMoveEvent(self, event: QMouseEvent): if not cursor_set and is_in_track_area: for region in self.selection_regions: - x_start = self.sec_to_x(region[0]) - x_end = self.sec_to_x(region[1]) + x_start = self.ms_to_x(region[0]) + x_end = self.ms_to_x(region[1]) if abs(event.pos().x() - x_start) < self.RESIZE_HANDLE_WIDTH or \ abs(event.pos().x() - x_end) < self.RESIZE_HANDLE_WIDTH: self.setCursor(Qt.CursorShape.SizeHorCursor) @@ -945,16 +947,16 @@ def mouseMoveEvent(self, event: QMouseEvent): self.unsetCursor() if self.creating_selection_region: - current_sec = self.x_to_sec(event.pos().x()) - start = min(self.selection_drag_start_sec, current_sec) - end = max(self.selection_drag_start_sec, current_sec) + current_ms = self.x_to_ms(event.pos().x()) + start = min(self.selection_drag_start_ms, current_ms) + end = max(self.selection_drag_start_ms, current_ms) self.selection_regions[-1] = [start, end] self.update() return if self.dragging_selection_region: delta_x = event.pos().x() - self.drag_start_pos.x() - time_delta = delta_x / self.pixels_per_second + time_delta = int(delta_x / self.pixels_per_ms) original_start, original_end = self.drag_selection_start_values duration = original_end - original_start @@ -967,16 +969,16 @@ def mouseMoveEvent(self, event: QMouseEvent): return if self.dragging_playhead: - time_sec = max(0, self.x_to_sec(event.pos().x())) - self.playhead_pos_sec = self._snap_time_if_needed(time_sec) - self.playhead_moved.emit(self.playhead_pos_sec) + time_ms = max(0, self.x_to_ms(event.pos().x())) + self.playhead_pos_ms = self._snap_time_if_needed(time_ms) + self.playhead_moved.emit(self.playhead_pos_ms) self.update() elif self.dragging_clip: self.highlighted_track_info = None self.highlighted_ghost_track_info = None new_track_info = self.y_to_track_info(event.pos().y()) - original_start_sec, _ = self.drag_original_clip_states[self.dragging_clip.id] + original_start_ms, _ = self.drag_original_clip_states[self.dragging_clip.id] if new_track_info: new_track_type, new_track_index = new_track_info @@ -993,19 +995,19 @@ def mouseMoveEvent(self, event: QMouseEvent): self.dragging_clip.track_index = new_track_index delta_x = event.pos().x() - self.drag_start_pos.x() - time_delta = delta_x / self.pixels_per_second - true_new_start_time = original_start_sec + time_delta + time_delta = delta_x / self.pixels_per_ms + true_new_start_time = original_start_ms + time_delta - playhead_time = self.playhead_pos_sec - snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_second + playhead_time = self.playhead_pos_ms + snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_ms new_start_time = true_new_start_time - true_new_end_time = true_new_start_time + self.dragging_clip.duration_sec + true_new_end_time = true_new_start_time + self.dragging_clip.duration_ms if abs(true_new_start_time - playhead_time) < snap_time_delta: new_start_time = playhead_time elif abs(true_new_end_time - playhead_time) < snap_time_delta: - new_start_time = playhead_time - self.dragging_clip.duration_sec + new_start_time = playhead_time - self.dragging_clip.duration_ms for other_clip in self.timeline.clips: if other_clip.id == self.dragging_clip.id: continue @@ -1014,21 +1016,21 @@ def mouseMoveEvent(self, event: QMouseEvent): other_clip.track_index != self.dragging_clip.track_index): continue - is_overlapping = (new_start_time < other_clip.timeline_end_sec and - new_start_time + self.dragging_clip.duration_sec > other_clip.timeline_start_sec) + is_overlapping = (new_start_time < other_clip.timeline_end_ms and + new_start_time + self.dragging_clip.duration_ms > other_clip.timeline_start_ms) if is_overlapping: - movement_direction = true_new_start_time - original_start_sec + movement_direction = true_new_start_time - original_start_ms if movement_direction > 0: - new_start_time = other_clip.timeline_start_sec - self.dragging_clip.duration_sec + new_start_time = other_clip.timeline_start_ms - self.dragging_clip.duration_ms else: - new_start_time = other_clip.timeline_end_sec + new_start_time = other_clip.timeline_end_ms break final_start_time = max(0, new_start_time) - self.dragging_clip.timeline_start_sec = final_start_time + self.dragging_clip.timeline_start_ms = int(final_start_time) if self.dragging_linked_clip: - self.dragging_linked_clip.timeline_start_sec = final_start_time + self.dragging_linked_clip.timeline_start_ms = int(final_start_time) self.update() @@ -1061,7 +1063,7 @@ def mouseReleaseEvent(self, event: QMouseEvent): self.creating_selection_region = False if self.selection_regions: start, end = self.selection_regions[-1] - if (end - start) * self.pixels_per_second < 2: + if (end - start) * self.pixels_per_ms < 2: self.clear_all_regions() if self.dragging_selection_region: @@ -1071,18 +1073,18 @@ def mouseReleaseEvent(self, event: QMouseEvent): self.dragging_playhead = False if self.dragging_clip: orig_start, orig_track = self.drag_original_clip_states[self.dragging_clip.id] - moved = (orig_start != self.dragging_clip.timeline_start_sec or + moved = (orig_start != self.dragging_clip.timeline_start_ms or orig_track != self.dragging_clip.track_index) if self.dragging_linked_clip: orig_start_link, orig_track_link = self.drag_original_clip_states[self.dragging_linked_clip.id] - moved = moved or (orig_start_link != self.dragging_linked_clip.timeline_start_sec or + moved = moved or (orig_start_link != self.dragging_linked_clip.timeline_start_ms or orig_track_link != self.dragging_linked_clip.track_index) if moved: self.window().finalize_clip_drag(self.drag_start_state) - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline.clips.sort(key=lambda c: c.timeline_start_ms) self.highlighted_track_info = None self.highlighted_ghost_track_info = None self.operation_finished.emit() @@ -1162,12 +1164,12 @@ def dragMoveEvent(self, event): event.acceptProposedAction() - duration = media_props['duration'] + duration_ms = media_props['duration_ms'] media_type = media_props['media_type'] has_audio = media_props['has_audio'] pos = event.position() - start_sec = self.x_to_sec(pos.x()) + start_ms = self.x_to_ms(pos.x()) track_info = self.y_to_track_info(pos.y()) self.drag_over_rect = QRectF() @@ -1187,8 +1189,8 @@ def dragMoveEvent(self, event): else: self.highlighted_track_info = track_info - width = int(duration * self.pixels_per_second) - x = self.sec_to_x(start_sec) + width = int(duration_ms * self.pixels_per_ms) + x = self.ms_to_x(start_ms) video_y, audio_y = -1, -1 @@ -1232,19 +1234,19 @@ def dropEvent(self, event): return pos = event.position() - start_sec = self.x_to_sec(pos.x()) + start_ms = self.x_to_ms(pos.x()) track_info = self.y_to_track_info(pos.y()) if not track_info: event.ignore() return - current_timeline_pos = start_sec + current_timeline_pos = start_ms for file_path in added_files: media_info = main_window.media_properties.get(file_path) if not media_info: continue - duration = media_info['duration'] + duration_ms = media_info['duration_ms'] has_audio = media_info['has_audio'] media_type = media_info['media_type'] @@ -1269,14 +1271,14 @@ def dropEvent(self, event): main_window._add_clip_to_timeline( source_path=file_path, - timeline_start_sec=current_timeline_pos, - duration_sec=duration, + timeline_start_ms=current_timeline_pos, + duration_ms=duration_ms, media_type=media_type, - clip_start_sec=0, + clip_start_ms=0, video_track_index=video_track_idx, audio_track_index=audio_track_idx ) - current_timeline_pos += duration + current_timeline_pos += duration_ms event.acceptProposedAction() return @@ -1286,12 +1288,12 @@ def dropEvent(self, event): json_data = json.loads(mime_data.data('application/x-vnd.video.filepath').data().decode('utf-8')) file_path = json_data['path'] - duration = json_data['duration'] + duration_ms = json_data['duration_ms'] has_audio = json_data['has_audio'] media_type = json_data['media_type'] pos = event.position() - start_sec = self.x_to_sec(pos.x()) + start_ms = self.x_to_ms(pos.x()) track_info = self.y_to_track_info(pos.y()) if not track_info: @@ -1321,10 +1323,10 @@ def dropEvent(self, event): main_window = self.window() main_window._add_clip_to_timeline( source_path=file_path, - timeline_start_sec=start_sec, - duration_sec=duration, + timeline_start_ms=start_ms, + duration_ms=duration_ms, media_type=media_type, - clip_start_sec=0, + clip_start_ms=0, video_track_index=video_track_idx, audio_track_index=audio_track_idx ) @@ -1382,8 +1384,8 @@ def contextMenuEvent(self, event: 'QContextMenuEvent'): split_action = menu.addAction("Split Clip") delete_action = menu.addAction("Delete Clip") - playhead_time = self.playhead_pos_sec - is_playhead_over_clip = (clip_at_pos.timeline_start_sec < playhead_time < clip_at_pos.timeline_end_sec) + playhead_time = self.playhead_pos_ms + is_playhead_over_clip = (clip_at_pos.timeline_start_ms < playhead_time < clip_at_pos.timeline_end_ms) split_action.setEnabled(is_playhead_over_clip) split_action.triggered.connect(lambda: self.split_requested.emit(clip_at_pos)) delete_action.triggered.connect(lambda: self.delete_clip_requested.emit(clip_at_pos)) @@ -1672,7 +1674,7 @@ def startDrag(self, supportedActions): payload = { "path": path, - "duration": media_info['duration'], + "duration_ms": media_info['duration_ms'], "has_audio": media_info['has_audio'], "media_type": media_info['media_type'] } @@ -1930,7 +1932,7 @@ def _toggle_scale_to_fit(self, checked): def on_timeline_changed_by_undo(self): self.prune_empty_tracks() self.timeline_widget.update() - self.seek_preview(self.timeline_widget.playhead_pos_sec) + self.seek_preview(self.timeline_widget.playhead_pos_ms) self.status_label.setText("Operation undone/redone.") def update_undo_redo_actions(self): @@ -1964,8 +1966,8 @@ def on_add_to_timeline_at_playhead(self, file_path): self.status_label.setText(f"Error: Could not find properties for {os.path.basename(file_path)}") return - playhead_pos = self.timeline_widget.playhead_pos_sec - duration = media_info['duration'] + playhead_pos = self.timeline_widget.playhead_pos_ms + duration_ms = media_info['duration_ms'] has_audio = media_info['has_audio'] media_type = media_info['media_type'] @@ -1974,10 +1976,10 @@ def on_add_to_timeline_at_playhead(self, file_path): self._add_clip_to_timeline( source_path=file_path, - timeline_start_sec=playhead_pos, - duration_sec=duration, + timeline_start_ms=playhead_pos, + duration_ms=duration_ms, media_type=media_type, - clip_start_sec=0.0, + clip_start_ms=0, video_track_index=video_track, audio_track_index=audio_track ) @@ -2102,25 +2104,25 @@ def _create_menu_bar(self): data['action'] = action self.windows_menu.addAction(action) - def _get_topmost_video_clip_at(self, time_sec): + def _get_topmost_video_clip_at(self, time_ms): """Finds the video clip on the highest track at a specific time.""" top_clip = None for c in self.timeline.clips: - if c.track_type == 'video' and c.timeline_start_sec <= time_sec < c.timeline_end_sec: + if c.track_type == 'video' and c.timeline_start_ms <= time_ms < c.timeline_end_ms: if top_clip is None or c.track_index > top_clip.track_index: top_clip = c return top_clip - def _start_playback_stream_at(self, time_sec): + def _start_playback_stream_at(self, time_ms): self._stop_playback_stream() - clip = self._get_topmost_video_clip_at(time_sec) + clip = self._get_topmost_video_clip_at(time_ms) if not clip: return self.playback_clip = clip - clip_time = time_sec - clip.timeline_start_sec + clip.clip_start_sec + clip_time_sec = (time_ms - clip.timeline_start_ms + clip.clip_start_ms) / 1000.0 w, h = self.project_width, self.project_height try: - args = (ffmpeg.input(self.playback_clip.source_path, ss=clip_time) + args = (ffmpeg.input(self.playback_clip.source_path, ss=f"{clip_time_sec:.6f}") .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=self.project_fps).compile()) @@ -2149,7 +2151,7 @@ def _get_media_properties(self, file_path): if file_ext in ['.png', '.jpg', '.jpeg']: img = QImage(file_path) media_info['media_type'] = 'image' - media_info['duration'] = 5.0 + media_info['duration_ms'] = 5000 media_info['has_audio'] = False media_info['width'] = img.width() media_info['height'] = img.height() @@ -2160,7 +2162,8 @@ def _get_media_properties(self, file_path): if video_stream: media_info['media_type'] = 'video' - media_info['duration'] = float(video_stream.get('duration', probe['format'].get('duration', 0))) + duration_sec = float(video_stream.get('duration', probe['format'].get('duration', 0))) + media_info['duration_ms'] = int(duration_sec * 1000) media_info['has_audio'] = audio_stream is not None media_info['width'] = int(video_stream['width']) media_info['height'] = int(video_stream['height']) @@ -2169,7 +2172,8 @@ def _get_media_properties(self, file_path): if den > 0: media_info['fps'] = num / den elif audio_stream: media_info['media_type'] = 'audio' - media_info['duration'] = float(audio_stream.get('duration', probe['format'].get('duration', 0))) + duration_sec = float(audio_stream.get('duration', probe['format'].get('duration', 0))) + media_info['duration_ms'] = int(duration_sec * 1000) media_info['has_audio'] = True else: return None @@ -2215,8 +2219,8 @@ def _update_project_properties_from_clip(self, source_path): def _probe_for_drag(self, file_path): return self._get_media_properties(file_path) - def get_frame_data_at_time(self, time_sec): - clip_at_time = self._get_topmost_video_clip_at(time_sec) + def get_frame_data_at_time(self, time_ms): + clip_at_time = self._get_topmost_video_clip_at(time_ms) if not clip_at_time: return (None, 0, 0) try: @@ -2231,10 +2235,10 @@ def get_frame_data_at_time(self, time_sec): .run(capture_stdout=True, quiet=True) ) else: - clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec + clip_time_sec = (time_ms - clip_at_time.timeline_start_ms + clip_at_time.clip_start_ms) / 1000.0 out, _ = ( ffmpeg - .input(clip_at_time.source_path, ss=clip_time) + .input(clip_at_time.source_path, ss=f"{clip_time_sec:.6f}") .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') @@ -2245,8 +2249,8 @@ def get_frame_data_at_time(self, time_sec): print(f"Error extracting frame data: {e.stderr}") return (None, 0, 0) - def get_frame_at_time(self, time_sec): - clip_at_time = self._get_topmost_video_clip_at(time_sec) + def get_frame_at_time(self, time_ms): + clip_at_time = self._get_topmost_video_clip_at(time_ms) if not clip_at_time: return None try: w, h = self.project_width, self.project_height @@ -2259,8 +2263,8 @@ def get_frame_at_time(self, time_sec): .run(capture_stdout=True, quiet=True) ) else: - clip_time = time_sec - clip_at_time.timeline_start_sec + clip_at_time.clip_start_sec - out, _ = (ffmpeg.input(clip_at_time.source_path, ss=clip_time) + clip_time_sec = (time_ms - clip_at_time.timeline_start_ms + clip_at_time.clip_start_ms) / 1000.0 + out, _ = (ffmpeg.input(clip_at_time.source_path, ss=f"{clip_time_sec:.6f}") .filter('scale', w, h, force_original_aspect_ratio='decrease') .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') @@ -2270,11 +2274,11 @@ def get_frame_at_time(self, time_sec): return QPixmap.fromImage(image) except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return None - def seek_preview(self, time_sec): + def seek_preview(self, time_ms): self._stop_playback_stream() - self.timeline_widget.playhead_pos_sec = time_sec + self.timeline_widget.playhead_pos_ms = int(time_ms) self.timeline_widget.update() - self.current_preview_pixmap = self.get_frame_at_time(time_sec) + self.current_preview_pixmap = self.get_frame_at_time(time_ms) self._update_preview_display() def _update_preview_display(self): @@ -2303,26 +2307,26 @@ def toggle_playback(self): if self.playback_timer.isActive(): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play") else: if not self.timeline.clips: return - if self.timeline_widget.playhead_pos_sec >= self.timeline.get_total_duration(): self.timeline_widget.playhead_pos_sec = 0.0 + if self.timeline_widget.playhead_pos_ms >= self.timeline.get_total_duration(): self.timeline_widget.playhead_pos_ms = 0 self.playback_timer.start(int(1000 / self.project_fps)); self.play_pause_button.setText("Pause") - def stop_playback(self): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play"); self.seek_preview(0.0) + def stop_playback(self): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play"); self.seek_preview(0) def step_frame(self, direction): if not self.timeline.clips: return self.playback_timer.stop(); self.play_pause_button.setText("Play"); self._stop_playback_stream() - frame_duration = 1.0 / self.project_fps - new_time = self.timeline_widget.playhead_pos_sec + (direction * frame_duration) - self.seek_preview(max(0, min(new_time, self.timeline.get_total_duration()))) + frame_duration_ms = 1000.0 / self.project_fps + new_time = self.timeline_widget.playhead_pos_ms + (direction * frame_duration_ms) + self.seek_preview(int(max(0, min(new_time, self.timeline.get_total_duration())))) def advance_playback_frame(self): - frame_duration = 1.0 / self.project_fps - new_time = self.timeline_widget.playhead_pos_sec + frame_duration - if new_time > self.timeline.get_total_duration(): self.stop_playback(); return + frame_duration_ms = 1000.0 / self.project_fps + new_time_ms = self.timeline_widget.playhead_pos_ms + frame_duration_ms + if new_time_ms > self.timeline.get_total_duration(): self.stop_playback(); return - self.timeline_widget.playhead_pos_sec = new_time + self.timeline_widget.playhead_pos_ms = round(new_time_ms) self.timeline_widget.update() - clip_at_new_time = self._get_topmost_video_clip_at(new_time) + clip_at_new_time = self._get_topmost_video_clip_at(new_time_ms) if not clip_at_new_time: self._stop_playback_stream() @@ -2334,12 +2338,12 @@ def advance_playback_frame(self): if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: self._stop_playback_stream() self.playback_clip = clip_at_new_time - self.current_preview_pixmap = self.get_frame_at_time(new_time) + self.current_preview_pixmap = self.get_frame_at_time(new_time_ms) self._update_preview_display() return if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: - self._start_playback_stream_at(new_time) + self._start_playback_stream_at(new_time_ms) if self.playback_process: frame_size = self.project_width * self.project_height * 3 @@ -2421,7 +2425,7 @@ def save_project_as(self): if not path: return project_data = { "media_pool": self.media_pool, - "clips": [{"source_path": c.source_path, "timeline_start_sec": c.timeline_start_sec, "clip_start_sec": c.clip_start_sec, "duration_sec": c.duration_sec, "track_index": c.track_index, "track_type": c.track_type, "media_type": c.media_type, "group_id": c.group_id} for c in self.timeline.clips], + "clips": [{"source_path": c.source_path, "timeline_start_ms": c.timeline_start_ms, "clip_start_ms": c.clip_start_ms, "duration_ms": c.duration_ms, "track_index": c.track_index, "track_type": c.track_type, "media_type": c.media_type, "group_id": c.group_id} for c in self.timeline.clips], "selection_regions": self.timeline_widget.selection_regions, "last_export_path": self.last_export_path, "settings": { @@ -2463,7 +2467,7 @@ def _load_project_from_path(self, path): for clip_data in project_data["clips"]: if not os.path.exists(clip_data["source_path"]): self.status_label.setText(f"Error: Missing media file {clip_data['source_path']}"); self.new_project(); return - + if 'media_type' not in clip_data: ext = os.path.splitext(clip_data['source_path'])[1].lower() if ext in ['.mp3', '.wav', '.m4a', '.aac']: @@ -2552,19 +2556,19 @@ def add_media_to_timeline(self): if not added_files: return - playhead_pos = self.timeline_widget.playhead_pos_sec + playhead_pos = self.timeline_widget.playhead_pos_ms def add_clips_action(): for file_path in added_files: media_info = self.media_properties.get(file_path) if not media_info: continue - duration = media_info['duration'] + duration_ms = media_info['duration_ms'] media_type = media_info['media_type'] has_audio = media_info['has_audio'] clip_start_time = playhead_pos - clip_end_time = playhead_pos + duration + clip_end_time = playhead_pos + duration_ms video_track_index = None audio_track_index = None @@ -2572,7 +2576,7 @@ def add_clips_action(): if media_type in ['video', 'image']: for i in range(1, self.timeline.num_video_tracks + 2): is_occupied = any( - c.timeline_start_sec < clip_end_time and c.timeline_end_sec > clip_start_time + c.timeline_start_ms < clip_end_time and c.timeline_end_ms > clip_start_time for c in self.timeline.clips if c.track_type == 'video' and c.track_index == i ) if not is_occupied: @@ -2582,7 +2586,7 @@ def add_clips_action(): if has_audio: for i in range(1, self.timeline.num_audio_tracks + 2): is_occupied = any( - c.timeline_start_sec < clip_end_time and c.timeline_end_sec > clip_start_time + c.timeline_start_ms < clip_end_time and c.timeline_end_ms > clip_start_time for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == i ) if not is_occupied: @@ -2593,13 +2597,13 @@ def add_clips_action(): if video_track_index is not None: if video_track_index > self.timeline.num_video_tracks: self.timeline.num_video_tracks = video_track_index - video_clip = TimelineClip(file_path, clip_start_time, 0.0, duration, video_track_index, 'video', media_type, group_id) + video_clip = TimelineClip(file_path, clip_start_time, 0, duration_ms, video_track_index, 'video', media_type, group_id) self.timeline.add_clip(video_clip) if audio_track_index is not None: if audio_track_index > self.timeline.num_audio_tracks: self.timeline.num_audio_tracks = audio_track_index - audio_clip = TimelineClip(file_path, clip_start_time, 0.0, duration, audio_track_index, 'audio', media_type, group_id) + audio_clip = TimelineClip(file_path, clip_start_time, 0, duration_ms, audio_track_index, 'audio', media_type, group_id) self.timeline.add_clip(audio_clip) self.status_label.setText(f"Added {len(added_files)} file(s) to timeline.") @@ -2611,7 +2615,7 @@ def add_media_files(self): if file_paths: self._add_media_files_to_project(file_paths) - def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, media_type, clip_start_sec=0.0, video_track_index=None, audio_track_index=None): + def _add_clip_to_timeline(self, source_path, timeline_start_ms, duration_ms, media_type, clip_start_ms=0, video_track_index=None, audio_track_index=None): if media_type in ['video', 'image']: self._update_project_properties_from_clip(source_path) @@ -2621,13 +2625,13 @@ def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, m if video_track_index is not None: if video_track_index > self.timeline.num_video_tracks: self.timeline.num_video_tracks = video_track_index - video_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, video_track_index, 'video', media_type, group_id) + video_clip = TimelineClip(source_path, timeline_start_ms, clip_start_ms, duration_ms, video_track_index, 'video', media_type, group_id) self.timeline.add_clip(video_clip) if audio_track_index is not None: if audio_track_index > self.timeline.num_audio_tracks: self.timeline.num_audio_tracks = audio_track_index - audio_clip = TimelineClip(source_path, timeline_start_sec, clip_start_sec, duration_sec, audio_track_index, 'audio', media_type, group_id) + audio_clip = TimelineClip(source_path, timeline_start_ms, clip_start_ms, duration_ms, audio_track_index, 'audio', media_type, group_id) self.timeline.add_clip(audio_clip) new_state = self._get_current_timeline_state() @@ -2635,21 +2639,21 @@ def _add_clip_to_timeline(self, source_path, timeline_start_sec, duration_sec, m command.undo() self.undo_stack.push(command) - def _split_at_time(self, clip_to_split, time_sec, new_group_id=None): - if not (clip_to_split.timeline_start_sec < time_sec < clip_to_split.timeline_end_sec): return False - split_point = time_sec - clip_to_split.timeline_start_sec - orig_dur = clip_to_split.duration_sec + def _split_at_time(self, clip_to_split, time_ms, new_group_id=None): + if not (clip_to_split.timeline_start_ms < time_ms < clip_to_split.timeline_end_ms): return False + split_point = time_ms - clip_to_split.timeline_start_ms + orig_dur = clip_to_split.duration_ms group_id_for_new_clip = new_group_id if new_group_id is not None else clip_to_split.group_id - new_clip = TimelineClip(clip_to_split.source_path, time_sec, clip_to_split.clip_start_sec + split_point, orig_dur - split_point, clip_to_split.track_index, clip_to_split.track_type, clip_to_split.media_type, group_id_for_new_clip) - clip_to_split.duration_sec = split_point + new_clip = TimelineClip(clip_to_split.source_path, time_ms, clip_to_split.clip_start_ms + split_point, orig_dur - split_point, clip_to_split.track_index, clip_to_split.track_type, clip_to_split.media_type, group_id_for_new_clip) + clip_to_split.duration_ms = split_point self.timeline.add_clip(new_clip) return True def split_clip_at_playhead(self, clip_to_split=None): - playhead_time = self.timeline_widget.playhead_pos_sec + playhead_time = self.timeline_widget.playhead_pos_ms if not clip_to_split: - clips_at_playhead = [c for c in self.timeline.clips if c.timeline_start_sec < playhead_time < c.timeline_end_sec] + clips_at_playhead = [c for c in self.timeline.clips if c.timeline_start_ms < playhead_time < c.timeline_end_ms] if not clips_at_playhead: self.status_label.setText("Playhead is not over a clip to split.") return @@ -2723,20 +2727,20 @@ def action(): return target_audio_track = 1 - new_audio_start = video_clip.timeline_start_sec - new_audio_end = video_clip.timeline_end_sec + new_audio_start = video_clip.timeline_start_ms + new_audio_end = video_clip.timeline_end_ms conflicting_clips = [ c for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == target_audio_track and - c.timeline_start_sec < new_audio_end and c.timeline_end_sec > new_audio_start + c.timeline_start_ms < new_audio_end and c.timeline_end_ms > new_audio_start ] for conflict_clip in conflicting_clips: found_spot = False for check_track_idx in range(target_audio_track + 1, self.timeline.num_audio_tracks + 2): is_occupied = any( - other.timeline_start_sec < conflict_clip.timeline_end_sec and other.timeline_end_sec > conflict_clip.timeline_start_sec + other.timeline_start_ms < conflict_clip.timeline_end_ms and other.timeline_end_ms > conflict_clip.timeline_start_ms for other in self.timeline.clips if other.id != conflict_clip.id and other.track_type == 'audio' and other.track_index == check_track_idx ) @@ -2751,9 +2755,9 @@ def action(): new_audio_clip = TimelineClip( source_path=video_clip.source_path, - timeline_start_sec=video_clip.timeline_start_sec, - clip_start_sec=video_clip.clip_start_sec, - duration_sec=video_clip.duration_sec, + timeline_start_ms=video_clip.timeline_start_ms, + clip_start_ms=video_clip.clip_start_ms, + duration_ms=video_clip.duration_ms, track_index=target_audio_track, track_type='audio', media_type=video_clip.media_type, @@ -2778,10 +2782,10 @@ def _perform_complex_timeline_change(self, description, change_function): def on_split_region(self, region): def action(): - start_sec, end_sec = region + start_ms, end_ms = region clips = list(self.timeline.clips) - for clip in clips: self._split_at_time(clip, end_sec) - for clip in clips: self._split_at_time(clip, start_sec) + for clip in clips: self._split_at_time(clip, end_ms) + for clip in clips: self._split_at_time(clip, start_ms) self.timeline_widget.clear_region(region) self._perform_complex_timeline_change("Split Region", action) @@ -2793,7 +2797,7 @@ def action(): split_points.add(end) for point in sorted(list(split_points)): - group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_ms < point < c.timeline_end_ms} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: @@ -2803,98 +2807,98 @@ def action(): def on_join_region(self, region): def action(): - start_sec, end_sec = region - duration_to_remove = end_sec - start_sec - if duration_to_remove <= 0.01: return + start_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: return - for point in [start_sec, end_sec]: - group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + for point in [start_ms, end_ms]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_ms < point < c.timeline_end_ms} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_ms >= start_ms and c.timeline_start_ms < end_ms] for clip in clips_to_remove: self.timeline.clips.remove(clip) for clip in self.timeline.clips: - if clip.timeline_start_sec >= end_sec: - clip.timeline_start_sec -= duration_to_remove + if clip.timeline_start_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline.clips.sort(key=lambda c: c.timeline_start_ms) self.timeline_widget.clear_region(region) self._perform_complex_timeline_change("Join Region", action) def on_join_all_regions(self, regions): def action(): for region in sorted(regions, key=lambda r: r[0], reverse=True): - start_sec, end_sec = region - duration_to_remove = end_sec - start_sec - if duration_to_remove <= 0.01: continue + start_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: continue - for point in [start_sec, end_sec]: - group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + for point in [start_ms, end_ms]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_ms < point < c.timeline_end_ms} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_ms >= start_ms and c.timeline_start_ms < end_ms] for clip in clips_to_remove: try: self.timeline.clips.remove(clip) except ValueError: pass for clip in self.timeline.clips: - if clip.timeline_start_sec >= end_sec: - clip.timeline_start_sec -= duration_to_remove + if clip.timeline_start_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline.clips.sort(key=lambda c: c.timeline_start_ms) self.timeline_widget.clear_all_regions() self._perform_complex_timeline_change("Join All Regions", action) def on_delete_region(self, region): def action(): - start_sec, end_sec = region - duration_to_remove = end_sec - start_sec - if duration_to_remove <= 0.01: return + start_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: return - for point in [start_sec, end_sec]: - group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + for point in [start_ms, end_ms]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_ms < point < c.timeline_end_ms} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_ms >= start_ms and c.timeline_start_ms < end_ms] for clip in clips_to_remove: self.timeline.clips.remove(clip) for clip in self.timeline.clips: - if clip.timeline_start_sec >= end_sec: - clip.timeline_start_sec -= duration_to_remove + if clip.timeline_start_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline.clips.sort(key=lambda c: c.timeline_start_ms) self.timeline_widget.clear_region(region) self._perform_complex_timeline_change("Delete Region", action) def on_delete_all_regions(self, regions): def action(): for region in sorted(regions, key=lambda r: r[0], reverse=True): - start_sec, end_sec = region - duration_to_remove = end_sec - start_sec - if duration_to_remove <= 0.01: continue + start_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: continue - for point in [start_sec, end_sec]: - group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_sec < point < c.timeline_end_sec} + for point in [start_ms, end_ms]: + group_ids_at_point = {c.group_id for c in self.timeline.clips if c.timeline_start_ms < point < c.timeline_end_ms} new_group_ids = {gid: str(uuid.uuid4()) for gid in group_ids_at_point} for clip in list(self.timeline.clips): if clip.group_id in new_group_ids: self._split_at_time(clip, point, new_group_ids[clip.group_id]) - clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_sec >= start_sec and c.timeline_start_sec < end_sec] + clips_to_remove = [c for c in self.timeline.clips if c.timeline_start_ms >= start_ms and c.timeline_start_ms < end_ms] for clip in clips_to_remove: try: self.timeline.clips.remove(clip) except ValueError: pass for clip in self.timeline.clips: - if clip.timeline_start_sec >= end_sec: - clip.timeline_start_sec -= duration_to_remove + if clip.timeline_start_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove - self.timeline.clips.sort(key=lambda c: c.timeline_start_sec) + self.timeline.clips.sort(key=lambda c: c.timeline_start_ms) self.timeline_widget.clear_all_regions() self._perform_complex_timeline_change("Delete All Regions", action) @@ -2936,10 +2940,12 @@ def export_video(self): self.last_export_path = output_path - w, h, fr_str, total_dur = self.project_width, self.project_height, str(self.project_fps), self.timeline.get_total_duration() + total_dur_ms = self.timeline.get_total_duration() + total_dur_sec = total_dur_ms / 1000.0 + w, h, fr_str = self.project_width, self.project_height, str(self.project_fps) sample_rate, channel_layout = '44100', 'stereo' - video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr_str}:d={total_dur}', f='lavfi') + video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr_str}:d={total_dur_sec}', f='lavfi') all_video_clips = sorted( [c for c in self.timeline.clips if c.track_type == 'video'], @@ -2954,37 +2960,45 @@ def export_video(self): input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) clip_source_node = input_nodes[clip.source_path] + clip_duration_sec = clip.duration_ms / 1000.0 + clip_start_sec = clip.clip_start_ms / 1000.0 + timeline_start_sec = clip.timeline_start_ms / 1000.0 + timeline_end_sec = clip.timeline_end_ms / 1000.0 + if clip.media_type == 'image': - segment_stream = (clip_source_node.video.trim(duration=clip.duration_sec).setpts('PTS-STARTPTS')) + segment_stream = (clip_source_node.video.trim(duration=clip_duration_sec).setpts('PTS-STARTPTS')) else: - segment_stream = (clip_source_node.video.trim(start=clip.clip_start_sec, duration=clip.duration_sec).setpts('PTS-STARTPTS')) + segment_stream = (clip_source_node.video.trim(start=clip_start_sec, duration=clip_duration_sec).setpts('PTS-STARTPTS')) processed_segment = (segment_stream.filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black')) - video_stream = ffmpeg.overlay(video_stream, processed_segment, enable=f'between(t,{clip.timeline_start_sec},{clip.timeline_end_sec})') + video_stream = ffmpeg.overlay(video_stream, processed_segment, enable=f'between(t,{timeline_start_sec},{timeline_end_sec})') final_video = video_stream.filter('format', pix_fmts='yuv420p').filter('fps', fps=self.project_fps) track_audio_streams = [] for i in range(self.timeline.num_audio_tracks): - track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == i + 1], key=lambda c: c.timeline_start_sec) + track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == i + 1], key=lambda c: c.timeline_start_ms) if not track_clips: continue track_segments = [] - last_end = track_clips[0].timeline_start_sec + last_end_ms = track_clips[0].timeline_start_ms for clip in track_clips: if clip.source_path not in input_nodes: input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) - gap = clip.timeline_start_sec - last_end - if gap > 0.01: - track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap}', f='lavfi')) - a_seg = input_nodes[clip.source_path].audio.filter('atrim', start=clip.clip_start_sec, duration=clip.duration_sec).filter('asetpts', 'PTS-STARTPTS') + gap_ms = clip.timeline_start_ms - last_end_ms + if gap_ms > 10: + track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap_ms/1000.0}', f='lavfi')) + + clip_start_sec = clip.clip_start_ms / 1000.0 + clip_duration_sec = clip.duration_ms / 1000.0 + a_seg = input_nodes[clip.source_path].audio.filter('atrim', start=clip_start_sec, duration=clip_duration_sec).filter('asetpts', 'PTS-STARTPTS') track_segments.append(a_seg) - last_end = clip.timeline_end_sec - track_audio_streams.append(ffmpeg.concat(*track_segments, v=0, a=1).filter('adelay', f'{int(track_clips[0].timeline_start_sec * 1000)}ms', all=True)) + last_end_ms = clip.timeline_end_ms + track_audio_streams.append(ffmpeg.concat(*track_segments, v=0, a=1).filter('adelay', f'{int(track_clips[0].timeline_start_ms)}ms', all=True)) if track_audio_streams: final_audio = ffmpeg.filter(track_audio_streams, 'amix', inputs=len(track_audio_streams), duration='longest') else: - final_audio = ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={total_dur}', f='lavfi') + final_audio = ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={total_dur_sec}', f='lavfi') output_args = {'vcodec': settings['vcodec'], 'acodec': settings['acodec'], 'pix_fmt': 'yuv420p'} if settings['v_bitrate']: output_args['b:v'] = settings['v_bitrate'] @@ -2994,7 +3008,7 @@ def export_video(self): ffmpeg_cmd = ffmpeg.output(final_video, final_audio, output_path, **output_args).overwrite_output().compile() self.progress_bar.setVisible(True); self.progress_bar.setValue(0); self.status_label.setText("Exporting...") self.export_thread = QThread() - self.export_worker = ExportWorker(ffmpeg_cmd, total_dur) + self.export_worker = ExportWorker(ffmpeg_cmd, total_dur_ms) self.export_worker.moveToThread(self.export_thread) self.export_thread.started.connect(self.export_worker.run_export) self.export_worker.finished.connect(self.on_export_finished) From 75d29f9b48bec16b0e3468af28d37d52676a98be Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 21:35:16 +1100 Subject: [PATCH 54/67] fix timescale shifts --- videoeditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/videoeditor.py b/videoeditor.py index 5126b79b5..632ca4069 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -468,7 +468,7 @@ def draw_timescale(self, painter): frame_dur_ms = 1000.0 / self.project_fps intervals_ms = [ - int(frame_dur_ms), int(2*frame_dur_ms), int(5*frame_dur_ms), int(10*frame_dur_ms), + frame_dur_ms, 2*frame_dur_ms, 5*frame_dur_ms, 10*frame_dur_ms, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000, 900000, 1800000, 3600000, 2*3600000, 5*3600000, 10*3600000 From fde093f128f408b0b3827f0f0a562df8dcd9272c Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 21:50:44 +1100 Subject: [PATCH 55/67] add buttons to snap to nearest clip collision --- videoeditor.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/videoeditor.py b/videoeditor.py index 632ca4069..98d908477 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -1853,11 +1853,15 @@ def _setup_ui(self): self.stop_button = QPushButton("Stop") self.frame_back_button = QPushButton("<") self.frame_forward_button = QPushButton(">") + self.snap_back_button = QPushButton("|<") + self.snap_forward_button = QPushButton(">|") controls_layout.addStretch() + controls_layout.addWidget(self.snap_back_button) controls_layout.addWidget(self.frame_back_button) controls_layout.addWidget(self.play_pause_button) controls_layout.addWidget(self.stop_button) controls_layout.addWidget(self.frame_forward_button) + controls_layout.addWidget(self.snap_forward_button) controls_layout.addStretch() main_layout.addWidget(controls_widget) @@ -1908,6 +1912,8 @@ def _connect_signals(self): self.stop_button.clicked.connect(self.stop_playback) self.frame_back_button.clicked.connect(lambda: self.step_frame(-1)) self.frame_forward_button.clicked.connect(lambda: self.step_frame(1)) + self.snap_back_button.clicked.connect(lambda: self.snap_playhead(-1)) + self.snap_forward_button.clicked.connect(lambda: self.snap_playhead(1)) self.playback_timer.timeout.connect(self.advance_playback_frame) self.project_media_widget.add_media_requested.connect(self.add_media_files) @@ -2318,6 +2324,32 @@ def step_frame(self, direction): new_time = self.timeline_widget.playhead_pos_ms + (direction * frame_duration_ms) self.seek_preview(int(max(0, min(new_time, self.timeline.get_total_duration())))) + def snap_playhead(self, direction): + if not self.timeline.clips: + return + + current_time_ms = self.timeline_widget.playhead_pos_ms + + snap_points = set() + for clip in self.timeline.clips: + snap_points.add(clip.timeline_start_ms) + snap_points.add(clip.timeline_end_ms) + + sorted_points = sorted(list(snap_points)) + + TOLERANCE_MS = 1 + + if direction == 1: # Forward + next_points = [p for p in sorted_points if p > current_time_ms + TOLERANCE_MS] + if next_points: + self.seek_preview(next_points[0]) + elif direction == -1: # Backward + prev_points = [p for p in sorted_points if p < current_time_ms - TOLERANCE_MS] + if prev_points: + self.seek_preview(prev_points[-1]) + elif current_time_ms > 0: + self.seek_preview(0) + def advance_playback_frame(self): frame_duration_ms = 1000.0 / self.project_fps new_time_ms = self.timeline_widget.playhead_pos_ms + frame_duration_ms From 78f2a959ab12d56a24bcd1a51f916ee5057b01bc Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Sun, 12 Oct 2025 22:13:19 +1100 Subject: [PATCH 56/67] hold shift for universal frame by frame snapping on all operations --- videoeditor.py | 91 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index 98d908477..53f3baa85 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -636,11 +636,17 @@ def y_to_track_info(self, y): return None + def _snap_to_frame(self, time_ms): + frame_duration_ms = 1000.0 / self.project_fps + if frame_duration_ms <= 0: + return int(time_ms) + frame_number = round(time_ms / frame_duration_ms) + return int(frame_number * frame_duration_ms) + def _snap_time_if_needed(self, time_ms): frame_duration_ms = 1000.0 / self.project_fps if frame_duration_ms > 0 and frame_duration_ms * self.pixels_per_ms > 4: - frame_number = round(time_ms / frame_duration_ms) - return int(frame_number * frame_duration_ms) + return self._snap_to_frame(time_ms) return int(time_ms) def get_region_at_pos(self, pos: QPoint): @@ -791,15 +797,24 @@ def mousePressEvent(self, event: QMouseEvent): if is_in_track_area: self.creating_selection_region = True - playhead_x = self.ms_to_x(self.playhead_pos_ms) - if abs(event.pos().x() - playhead_x) < self.SNAP_THRESHOLD_PIXELS: - self.selection_drag_start_ms = self.playhead_pos_ms + is_shift_pressed = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier) + start_ms = self.x_to_ms(event.pos().x()) + + if is_shift_pressed: + self.selection_drag_start_ms = self._snap_to_frame(start_ms) else: - self.selection_drag_start_ms = self.x_to_ms(event.pos().x()) + playhead_x = self.ms_to_x(self.playhead_pos_ms) + if abs(event.pos().x() - playhead_x) < self.SNAP_THRESHOLD_PIXELS: + self.selection_drag_start_ms = self.playhead_pos_ms + else: + self.selection_drag_start_ms = start_ms self.selection_regions.append([self.selection_drag_start_ms, self.selection_drag_start_ms]) elif is_on_timescale: time_ms = max(0, self.x_to_ms(event.pos().x())) - self.playhead_pos_ms = self._snap_time_if_needed(time_ms) + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + self.playhead_pos_ms = self._snap_to_frame(time_ms) + else: + self.playhead_pos_ms = self._snap_time_if_needed(time_ms) self.playhead_moved.emit(self.playhead_pos_ms) self.dragging_playhead = True @@ -816,6 +831,8 @@ def mouseMoveEvent(self, event: QMouseEvent): if self.resizing_selection_region: current_ms = max(0, self.x_to_ms(event.pos().x())) + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + current_ms = self._snap_to_frame(current_ms) original_start, original_end = self.resize_selection_start_values if self.resize_selection_edge == 'left': @@ -836,6 +853,7 @@ def mouseMoveEvent(self, event: QMouseEvent): self.update() return if self.resizing_clip: + is_shift_pressed = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier) linked_clip = next((c for c in self.timeline.clips if c.group_id == self.resizing_clip.group_id and c.id != self.resizing_clip.id), None) delta_x = event.pos().x() - self.resize_start_pos.x() time_delta = delta_x / self.pixels_per_ms @@ -858,11 +876,14 @@ def mouseMoveEvent(self, event: QMouseEvent): original_clip_start = self.drag_start_state[0][[c.id for c in self.drag_start_state[0]].index(self.resizing_clip.id)].clip_start_ms true_new_start_ms = original_start + time_delta - new_start_ms = true_new_start_ms - for snap_point in snap_points: - if abs(true_new_start_ms - snap_point) < snap_time_delta: - new_start_ms = snap_point - break + if is_shift_pressed: + new_start_ms = self._snap_to_frame(true_new_start_ms) + else: + new_start_ms = true_new_start_ms + for snap_point in snap_points: + if abs(true_new_start_ms - snap_point) < snap_time_delta: + new_start_ms = snap_point + break if new_start_ms > original_start + original_duration - min_duration_ms: new_start_ms = original_start + original_duration - min_duration_ms @@ -896,11 +917,14 @@ def mouseMoveEvent(self, event: QMouseEvent): true_new_duration = original_duration + time_delta true_new_end_time = original_start + true_new_duration - new_end_time = true_new_end_time - for snap_point in snap_points: - if abs(true_new_end_time - snap_point) < snap_time_delta: - new_end_time = snap_point - break + if is_shift_pressed: + new_end_time = self._snap_to_frame(true_new_end_time) + else: + new_end_time = true_new_end_time + for snap_point in snap_points: + if abs(true_new_end_time - snap_point) < snap_time_delta: + new_end_time = snap_point + break new_duration = new_end_time - original_start @@ -948,6 +972,8 @@ def mouseMoveEvent(self, event: QMouseEvent): if self.creating_selection_region: current_ms = self.x_to_ms(event.pos().x()) + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + current_ms = self._snap_to_frame(current_ms) start = min(self.selection_drag_start_ms, current_ms) end = max(self.selection_drag_start_ms, current_ms) self.selection_regions[-1] = [start, end] @@ -962,6 +988,9 @@ def mouseMoveEvent(self, event: QMouseEvent): duration = original_end - original_start new_start = max(0, original_start + time_delta) + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + new_start = self._snap_to_frame(new_start) + self.dragging_selection_region[0] = new_start self.dragging_selection_region[1] = new_start + duration @@ -970,7 +999,10 @@ def mouseMoveEvent(self, event: QMouseEvent): if self.dragging_playhead: time_ms = max(0, self.x_to_ms(event.pos().x())) - self.playhead_pos_ms = self._snap_time_if_needed(time_ms) + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + self.playhead_pos_ms = self._snap_to_frame(time_ms) + else: + self.playhead_pos_ms = self._snap_time_if_needed(time_ms) self.playhead_moved.emit(self.playhead_pos_ms) self.update() elif self.dragging_clip: @@ -997,17 +1029,20 @@ def mouseMoveEvent(self, event: QMouseEvent): delta_x = event.pos().x() - self.drag_start_pos.x() time_delta = delta_x / self.pixels_per_ms true_new_start_time = original_start_ms + time_delta - - playhead_time = self.playhead_pos_ms - snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_ms - new_start_time = true_new_start_time - true_new_end_time = true_new_start_time + self.dragging_clip.duration_ms - - if abs(true_new_start_time - playhead_time) < snap_time_delta: - new_start_time = playhead_time - elif abs(true_new_end_time - playhead_time) < snap_time_delta: - new_start_time = playhead_time - self.dragging_clip.duration_ms + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + new_start_time = self._snap_to_frame(true_new_start_time) + else: + playhead_time = self.playhead_pos_ms + snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_ms + + new_start_time = true_new_start_time + true_new_end_time = true_new_start_time + self.dragging_clip.duration_ms + + if abs(true_new_start_time - playhead_time) < snap_time_delta: + new_start_time = playhead_time + elif abs(true_new_end_time - playhead_time) < snap_time_delta: + new_start_time = playhead_time - self.dragging_clip.duration_ms for other_clip in self.timeline.clips: if other_clip.id == self.dragging_clip.id: continue From 19b6ae344e4c15c7d08eccbc3745ead094bba080 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Mon, 13 Oct 2025 00:09:11 +1100 Subject: [PATCH 57/67] improve timescale --- videoeditor.py | 64 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index 53f3baa85..e6176bec9 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -439,23 +439,69 @@ def draw_headers(self, painter): painter.restore() - def _format_timecode(self, total_ms): + + def _format_timecode(self, total_ms, interval_ms): if abs(total_ms) < 1: total_ms = 0 sign = "-" if total_ms < 0 else "" total_ms = abs(total_ms) seconds = total_ms / 1000.0 + + # Frame-based formatting for high zoom + is_frame_based = interval_ms < (1000.0 / self.project_fps) * 5 + if is_frame_based: + total_frames = int(round(seconds * self.project_fps)) + fps_int = int(round(self.project_fps)) + if fps_int == 0: fps_int = 25 # Avoid division by zero + s_frames = total_frames % fps_int + total_seconds_from_frames = total_frames // fps_int + h_fr = total_seconds_from_frames // 3600 + m_fr = (total_seconds_from_frames % 3600) // 60 + s_fr = total_seconds_from_frames % 60 + + if h_fr > 0: return f"{sign}{h_fr}:{m_fr:02d}:{s_fr:02d}:{s_frames:02d}" + if m_fr > 0: return f"{sign}{m_fr}:{s_fr:02d}:{s_frames:02d}" + return f"{sign}{s_fr}:{s_frames:02d}" + + # Sub-second formatting + if interval_ms < 1000: + precision = 2 if interval_ms < 100 else 1 + s_float = seconds % 60 + m = int((seconds % 3600) / 60) + h = int(seconds / 3600) + + if h > 0: return f"{sign}{h}:{m:02d}:{s_float:0{4+precision}.{precision}f}" + if m > 0: return f"{sign}{m}:{s_float:0{4+precision}.{precision}f}" + + val = f"{s_float:.{precision}f}" + if '.' in val: val = val.rstrip('0').rstrip('.') + return f"{sign}{val}s" + + # Seconds and M:SS formatting + if interval_ms < 60000: + rounded_seconds = int(round(seconds)) + h = rounded_seconds // 3600 + m = (rounded_seconds % 3600) // 60 + s = rounded_seconds % 60 + + if h > 0: + return f"{sign}{h}:{m:02d}:{s:02d}" + + # Use "Xs" format for times under a minute + if rounded_seconds < 60: + return f"{sign}{rounded_seconds}s" + + # Use "M:SS" format for times over a minute + return f"{sign}{m}:{s:02d}" + + # Minute and Hh:MMm formatting for low zoom h = int(seconds / 3600) m = int((seconds % 3600) / 60) - s = seconds % 60 - + if h > 0: return f"{sign}{h}h:{m:02d}m" - if m > 0 or seconds >= 59.99: return f"{sign}{m}m:{int(round(s)):02d}s" - precision = 2 if s < 1 else 1 if s < 10 else 0 - val = f"{s:.{precision}f}" - if '.' in val: val = val.rstrip('0').rstrip('.') - return f"{sign}{val}s" + total_minutes = int(seconds / 60) + return f"{sign}{total_minutes}m" def draw_timescale(self, painter): painter.save() @@ -510,7 +556,7 @@ def draw_ticks(interval_ms, height): if x > self.width() + 50: break if x >= self.HEADER_WIDTH - 50: painter.drawLine(x, self.TIMESCALE_HEIGHT - 12, x, self.TIMESCALE_HEIGHT) - label = self._format_timecode(t_ms) + label = self._format_timecode(t_ms, major_interval) label_width = font_metrics.horizontalAdvance(label) label_x = x - label_width // 2 if label_x < self.HEADER_WIDTH: From 4452582d6eb8d22b3b21ca91a5bda4d7ff01ec3e Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Mon, 13 Oct 2025 01:23:09 +1100 Subject: [PATCH 58/67] clamp timescale conversions --- videoeditor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/videoeditor.py b/videoeditor.py index e6176bec9..d6e744ebe 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -330,7 +330,7 @@ def set_project_fps(self, fps): self.pixels_per_ms = min(self.pixels_per_ms, self.max_pixels_per_ms) self.update() - def ms_to_x(self, ms): return self.HEADER_WIDTH + int((ms - self.view_start_ms) * self.pixels_per_ms) + def ms_to_x(self, ms): return self.HEADER_WIDTH + int(max(-500_000_000, min((ms - self.view_start_ms) * self.pixels_per_ms, 500_000_000))) def x_to_ms(self, x): return self.view_start_ms + int(float(x - self.HEADER_WIDTH) / self.pixels_per_ms) if x > self.HEADER_WIDTH and self.pixels_per_ms > 0 else self.view_start_ms def paintEvent(self, event): @@ -1861,7 +1861,7 @@ def __init__(self, project_to_load=None): self.plugin_manager.discover_and_load_plugins() # Project settings with defaults - self.project_fps = 25.0 + self.project_fps = 50.0 self.project_width = 1280 self.project_height = 720 From baad78fd629f4bb5cce39625c5b5d1f1051fb0bc Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Tue, 14 Oct 2025 19:00:24 +1100 Subject: [PATCH 59/67] added audio for playback, major overhaul of encoder and playback system to use multithreading and better layering, move wgp import out of video player and into plugin --- encoding.py | 210 +++++++++++++++ playback.py | 531 +++++++++++++++++++++++++++++++++++++ plugins.py | 53 ++-- plugins/wan2gp/main.py | 179 ++++++++----- plugins/wan2gp/plugin.json | 5 + videoeditor.py | 380 +++++++++----------------- 6 files changed, 1022 insertions(+), 336 deletions(-) create mode 100644 encoding.py create mode 100644 playback.py create mode 100644 plugins/wan2gp/plugin.json diff --git a/encoding.py b/encoding.py new file mode 100644 index 000000000..22f67489c --- /dev/null +++ b/encoding.py @@ -0,0 +1,210 @@ +import ffmpeg +import subprocess +import re +from PyQt6.QtCore import QObject, pyqtSignal, QThread + +class _ExportRunner(QObject): + progress = pyqtSignal(int) + finished = pyqtSignal(bool, str) + + def __init__(self, ffmpeg_cmd, total_duration_ms, parent=None): + super().__init__(parent) + self.ffmpeg_cmd = ffmpeg_cmd + self.total_duration_ms = total_duration_ms + self.process = None + + def run(self): + try: + startupinfo = None + if hasattr(subprocess, 'STARTUPINFO'): + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + self.process = subprocess.Popen( + self.ffmpeg_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + encoding="utf-8", + errors='ignore', + startupinfo=startupinfo + ) + + full_output = [] + time_pattern = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})") + + for line in iter(self.process.stdout.readline, ""): + full_output.append(line) + match = time_pattern.search(line) + if match: + h, m, s, cs = [int(g) for g in match.groups()] + processed_ms = (h * 3600 + m * 60 + s) * 1000 + cs * 10 + if self.total_duration_ms > 0: + percentage = int((processed_ms / self.total_duration_ms) * 100) + self.progress.emit(min(100, percentage)) + + self.process.stdout.close() + return_code = self.process.wait() + + if return_code == 0: + self.progress.emit(100) + self.finished.emit(True, "Export completed successfully!") + else: + print("--- FFmpeg Export FAILED ---") + print("Command: " + " ".join(self.ffmpeg_cmd)) + print("".join(full_output)) + self.finished.emit(False, f"Export failed with code {return_code}. Check console.") + + except FileNotFoundError: + self.finished.emit(False, "Export failed: ffmpeg.exe not found in your system's PATH.") + except Exception as e: + self.finished.emit(False, f"An exception occurred during export: {e}") + + def get_process(self): + return self.process + +class Encoder(QObject): + progress = pyqtSignal(int) + finished = pyqtSignal(bool, str) + + def __init__(self, parent=None): + super().__init__(parent) + self.worker_thread = None + self.worker = None + self._is_running = False + + def start_export(self, timeline, project_settings, export_settings): + if self._is_running: + self.finished.emit(False, "An export is already in progress.") + return + + self._is_running = True + + try: + total_dur_ms = timeline.get_total_duration() + total_dur_sec = total_dur_ms / 1000.0 + w, h, fps = project_settings['width'], project_settings['height'], project_settings['fps'] + sample_rate, channel_layout = '44100', 'stereo' + + # --- VIDEO GRAPH CONSTRUCTION (Definitive Solution) --- + + all_video_clips = sorted( + [c for c in timeline.clips if c.track_type == 'video'], + key=lambda c: c.track_index + ) + + # 1. Start with a base black canvas that defines the project's duration. + # This is our master clock and bottom layer. + final_video = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fps}:d={total_dur_sec}', f='lavfi') + + # 2. Process each clip individually and overlay it. + for clip in all_video_clips: + # A new, separate input for every single clip guarantees no conflicts. + if clip.media_type == 'image': + clip_input = ffmpeg.input(clip.source_path, loop=1, framerate=fps) + else: + clip_input = ffmpeg.input(clip.source_path) + + # a) Calculate the time shift to align the clip's content with the timeline. + # This ensures the correct frame of the source is shown at the start of the clip. + timeline_start_sec = clip.timeline_start_ms / 1000.0 + clip_start_sec = clip.clip_start_ms / 1000.0 + time_shift_sec = timeline_start_sec - clip_start_sec + + # b) Prepare the layer: apply the time shift, then scale and pad. + timed_layer = ( + clip_input.video + .setpts(f'PTS+{time_shift_sec}/TB') + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + ) + + # c) Define the visibility window for the overlay on the master timeline. + timeline_end_sec = (clip.timeline_start_ms + clip.duration_ms) / 1000.0 + enable_expression = f'between(t,{timeline_start_sec:.6f},{timeline_end_sec:.6f})' + + # d) Overlay the prepared layer onto the composition, enabling it only during its time window. + # eof_action='pass' handles finite streams gracefully. + final_video = ffmpeg.overlay(final_video, timed_layer, enable=enable_expression, eof_action='pass') + + # 3. Set final output format and framerate. + final_video = final_video.filter('format', pix_fmts='yuv420p').filter('fps', fps=fps) + + + # --- AUDIO GRAPH CONSTRUCTION (UNCHANGED and CORRECT) --- + track_audio_streams = [] + for i in range(1, timeline.num_audio_tracks + 1): + track_clips = sorted([c for c in timeline.clips if c.track_type == 'audio' and c.track_index == i], key=lambda c: c.timeline_start_ms) + if not track_clips: + continue + + track_segments = [] + last_end_ms = 0 + for clip in track_clips: + gap_ms = clip.timeline_start_ms - last_end_ms + if gap_ms > 10: + track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap_ms/1000.0}', f='lavfi')) + + clip_start_sec = clip.clip_start_ms / 1000.0 + clip_duration_sec = clip.duration_ms / 1000.0 + audio_source_node = ffmpeg.input(clip.source_path) + a_seg = audio_source_node.audio.filter('atrim', start=clip_start_sec, duration=clip_duration_sec).filter('asetpts', 'PTS-STARTPTS') + track_segments.append(a_seg) + last_end_ms = clip.timeline_start_ms + clip.duration_ms + + if track_segments: + track_audio_streams.append(ffmpeg.concat(*track_segments, v=0, a=1)) + + # --- FINAL OUTPUT ASSEMBLY --- + output_args = {} + stream_args = [] + has_audio = bool(track_audio_streams) and export_settings.get('acodec') + + if export_settings.get('vcodec'): + stream_args.append(final_video) + output_args['vcodec'] = export_settings['vcodec'] + if export_settings.get('v_bitrate'): output_args['b:v'] = export_settings['v_bitrate'] + + if has_audio: + final_audio = ffmpeg.filter(track_audio_streams, 'amix', inputs=len(track_audio_streams), duration='longest') + stream_args.append(final_audio) + output_args['acodec'] = export_settings['acodec'] + if export_settings.get('a_bitrate'): output_args['b:a'] = export_settings['a_bitrate'] + + if not has_audio: + output_args['an'] = None + + if not stream_args: + raise ValueError("No streams to output. Check export settings.") + + ffmpeg_cmd = ffmpeg.output(*stream_args, export_settings['output_path'], **output_args).overwrite_output().compile() + + except Exception as e: + self.finished.emit(False, f"Error building FFmpeg command: {e}") + self._is_running = False + return + + self.worker_thread = QThread() + self.worker = _ExportRunner(ffmpeg_cmd, total_dur_ms) + self.worker.moveToThread(self.worker_thread) + + self.worker.progress.connect(self.progress.emit) + self.worker.finished.connect(self._on_export_runner_finished) + + self.worker_thread.started.connect(self.worker.run) + self.worker_thread.start() + + def _on_export_runner_finished(self, success, message): + self._is_running = False + self.finished.emit(success, message) + + if self.worker_thread: + self.worker_thread.quit() + self.worker_thread.wait() + self.worker_thread = None + self.worker = None + + def cancel_export(self): + if self.worker and self.worker.get_process() and self.worker.get_process().poll() is None: + self.worker.get_process().terminate() + print("Export cancelled by user.") \ No newline at end of file diff --git a/playback.py b/playback.py new file mode 100644 index 000000000..d8da682e8 --- /dev/null +++ b/playback.py @@ -0,0 +1,531 @@ +import ffmpeg +import numpy as np +import sounddevice as sd +import threading +import time +from queue import Queue, Empty +import subprocess +from PyQt6.QtCore import QObject, pyqtSignal, QTimer +from PyQt6.QtGui import QImage, QPixmap, QColor + +AUDIO_BUFFER_SECONDS = 1.0 +VIDEO_BUFFER_SECONDS = 1.0 +AUDIO_CHUNK_SAMPLES = 1024 +DEFAULT_SAMPLE_RATE = 44100 +DEFAULT_CHANNELS = 2 + +class PlaybackManager(QObject): + new_frame = pyqtSignal(QPixmap) + playback_pos_changed = pyqtSignal(int) + stopped = pyqtSignal() + started = pyqtSignal() + paused = pyqtSignal() + stats_updated = pyqtSignal(str) + + def __init__(self, get_timeline_data_func, parent=None): + super().__init__(parent) + self.get_timeline_data = get_timeline_data_func + + self.is_playing = False + self.is_muted = False + self.volume = 1.0 + self.playback_start_time_ms = 0 + self.pause_time_ms = 0 + self.is_paused = False + self.seeking = False + self.stop_flag = threading.Event() + self._seek_thread = None + self._seek_request_ms = -1 + self._seek_lock = threading.Lock() + + self.video_process = None + self.audio_process = None + self.video_reader_thread = None + self.audio_reader_thread = None + self.audio_stream = None + + self.video_queue = None + self.audio_queue = None + + self.stream_start_time_monotonic = 0 + self.last_emitted_pos = -1 + + self.total_samples_played = 0 + self.audio_underruns = 0 + self.last_video_pts_ms = 0 + self.debug = False + + self.sync_lock = threading.Lock() + self.audio_clock_sec = 0.0 + self.audio_clock_update_time = 0.0 + + self.update_timer = QTimer(self) + self.update_timer.timeout.connect(self._update_loop) + self.update_timer.setInterval(16) + + def _emit_playhead_pos(self, time_ms, source): + if time_ms != self.last_emitted_pos: + if self.debug: + print(f"[PLAYHEAD DEBUG @ {time.monotonic():.4f}] pos={time_ms}ms, source='{source}'") + self.playback_pos_changed.emit(time_ms) + self.last_emitted_pos = time_ms + + def _cleanup_resources(self): + self.stop_flag.set() + + if self.update_timer.isActive(): + self.update_timer.stop() + + if self.audio_stream: + try: + self.audio_stream.stop() + self.audio_stream.close(ignore_errors=True) + except Exception as e: + print(f"Error closing audio stream: {e}") + self.audio_stream = None + + for p in [self.video_process, self.audio_process]: + if p and p.poll() is None: + try: + p.terminate() + p.wait(timeout=1.0) # Wait a bit for graceful termination + except Exception as e: + print(f"Error terminating process: {e}") + try: + p.kill() # Force kill if terminate fails + except Exception as ke: + print(f"Error killing process: {ke}") + + + self.video_reader_thread = None + self.audio_reader_thread = None + self.video_process = None + self.audio_process = None + self.video_queue = None + self.audio_queue = None + + def _video_reader_thread(self, process, width, height, fps): + frame_size = width * height * 3 + frame_duration_ms = 1000.0 / fps + frame_pts_ms = self.playback_start_time_ms + + while not self.stop_flag.is_set(): + try: + if self.video_queue and self.video_queue.full(): + time.sleep(0.01) + continue + + chunk = process.stdout.read(frame_size) + if len(chunk) < frame_size: + break + + if self.video_queue: self.video_queue.put((chunk, frame_pts_ms)) + frame_pts_ms += frame_duration_ms + + except (IOError, ValueError): + break + except Exception as e: + print(f"Video reader error: {e}") + break + if self.debug: print("Video reader thread finished.") + if self.video_queue: self.video_queue.put(None) + + def _audio_reader_thread(self, process): + chunk_size = AUDIO_CHUNK_SAMPLES * DEFAULT_CHANNELS * 4 + while not self.stop_flag.is_set(): + try: + if self.audio_queue and self.audio_queue.full(): + time.sleep(0.01) + continue + + chunk = process.stdout.read(chunk_size) + if not chunk: + break + + np_chunk = np.frombuffer(chunk, dtype=np.float32) + + if self.audio_queue: + self.audio_queue.put(np_chunk) + + except (IOError, ValueError): + break + except Exception as e: + print(f"Audio reader error: {e}") + break + if self.debug: print("Audio reader thread finished.") + if self.audio_queue: self.audio_queue.put(None) + + def _audio_callback(self, outdata, frames, time_info, status): + if status: + self.audio_underruns += 1 + try: + if self.audio_queue is None: raise Empty + chunk = self.audio_queue.get_nowait() + if chunk is None: + raise sd.CallbackStop + + chunk_len = len(chunk) + outdata_len = outdata.shape[0] * outdata.shape[1] + + if chunk_len < outdata_len: + outdata.fill(0) + outdata.flat[:chunk_len] = chunk + else: + outdata[:] = chunk[:outdata.size].reshape(outdata.shape) + + if self.is_muted: + outdata.fill(0) + else: + outdata *= self.volume + + with self.sync_lock: + self.audio_clock_sec = self.total_samples_played / DEFAULT_SAMPLE_RATE + self.audio_clock_update_time = time_info.outputBufferDacTime + + self.total_samples_played += frames + except Empty: + outdata.fill(0) + + def _seek_worker(self): + while True: + with self._seek_lock: + time_ms = self._seek_request_ms + self._seek_request_ms = -1 + + timeline, clips, proj_settings = self.get_timeline_data() + w, h, fps = proj_settings['width'], proj_settings['height'], proj_settings['fps'] + + top_clip = next((c for c in sorted(clips, key=lambda x: x.track_index, reverse=True) + if c.track_type == 'video' and c.timeline_start_ms <= time_ms < (c.timeline_start_ms + c.duration_ms)), None) + + pixmap = QPixmap(w, h) + pixmap.fill(QColor("black")) + + if top_clip: + try: + if top_clip.media_type == 'image': + out, _ = (ffmpeg.input(top_clip.source_path) + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') + .run(capture_stdout=True, quiet=True)) + else: + clip_time_sec = (time_ms - top_clip.timeline_start_ms + top_clip.clip_start_ms) / 1000.0 + out, _ = (ffmpeg.input(top_clip.source_path, ss=f"{clip_time_sec:.6f}") + .filter('scale', w, h, force_original_aspect_ratio='decrease') + .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') + .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') + .run(capture_stdout=True, quiet=True)) + + if out: + image = QImage(out, w, h, QImage.Format.Format_RGB888) + pixmap = QPixmap.fromImage(image) + + except ffmpeg.Error as e: + print(f"Error seeking to frame: {e.stderr.decode('utf-8') if e.stderr else str(e)}") + + self.new_frame.emit(pixmap) + + with self._seek_lock: + if self._seek_request_ms == -1: + self.seeking = False + break + + def seek_to_frame(self, time_ms): + self.stop() + self._emit_playhead_pos(time_ms, "seek_to_frame") + + with self._seek_lock: + self._seek_request_ms = time_ms + if self.seeking: + return + self.seeking = True + self._seek_thread = threading.Thread(target=self._seek_worker, daemon=True) + self._seek_thread.start() + + def _build_video_graph(self, start_ms, timeline, clips, proj_settings): + w, h, fps = proj_settings['width'], proj_settings['height'], proj_settings['fps'] + + video_clips = [c for c in clips if c.track_type == 'video' and c.timeline_end_ms > start_ms] + if not video_clips: + return None + video_clips = sorted( + [c for c in clips if c.track_type == 'video' and c.timeline_end_ms > start_ms], + key=lambda c: c.track_index, + reverse=True + ) + if not video_clips: + return None + + class VSegment: + def __init__(self, c, t_start, t_end): + self.source_path = c.source_path + self.duration_ms = t_end - t_start + self.timeline_start_ms = t_start + self.media_type = c.media_type + self.clip_start_ms = c.clip_start_ms + (t_start - c.timeline_start_ms) + + @property + def timeline_end_ms(self): + return self.timeline_start_ms + self.duration_ms + + event_points = {start_ms} + for c in video_clips: + event_points.update({c.timeline_start_ms, c.timeline_end_ms}) + + total_duration = timeline.get_total_duration() + if total_duration > start_ms: + event_points.add(total_duration) + + sorted_points = sorted([p for p in list(event_points) if p >= start_ms]) + + visible_segments = [] + for t_start, t_end in zip(sorted_points[:-1], sorted_points[1:]): + if t_end <= t_start: continue + + midpoint = t_start + 1 + top_clip = next((c for c in video_clips if c.timeline_start_ms <= midpoint < c.timeline_end_ms), None) + + if top_clip: + visible_segments.append(VSegment(top_clip, t_start, t_end)) + + top_track_clips = visible_segments + + concat_inputs = [] + last_end_time_ms = start_ms + for clip in top_track_clips: + clip_read_start_ms = clip.clip_start_ms + max(0, start_ms - clip.timeline_start_ms) + clip_play_start_ms = max(start_ms, clip.timeline_start_ms) + clip_remaining_duration_ms = clip.timeline_end_ms - clip_play_start_ms + if clip_remaining_duration_ms <= 0: + last_end_time_ms = max(last_end_time_ms, clip.timeline_end_ms) + continue + input_stream = ffmpeg.input(clip.source_path, ss=clip_read_start_ms / 1000.0, t=clip_remaining_duration_ms / 1000.0, re=None) + if clip.media_type == 'image': + segment = input_stream.video.filter('loop', loop=-1, size=1, start=0).filter('setpts', 'N/(FRAME_RATE*TB)').filter('trim', duration=clip_remaining_duration_ms / 1000.0) + else: + segment = input_stream.video + gap_start_ms = max(start_ms, last_end_time_ms) + if clip.timeline_start_ms > gap_start_ms: + gap_duration_sec = (clip.timeline_start_ms - gap_start_ms) / 1000.0 + segment = segment.filter('tpad', start_duration=f'{gap_duration_sec:.6f}', color='black') + concat_inputs.append(segment) + last_end_time_ms = clip.timeline_end_ms + if not concat_inputs: + return None + return ffmpeg.concat(*concat_inputs, v=1, a=0) + + def _build_audio_graph(self, start_ms, timeline, clips, proj_settings): + active_clips = [c for c in clips if c.track_type == 'audio' and c.timeline_end_ms > start_ms] + if not active_clips: + return None + + tracks = {} + for clip in active_clips: + tracks.setdefault(clip.track_index, []).append(clip) + + track_streams = [] + for track_index, track_clips in sorted(tracks.items()): + track_clips.sort(key=lambda c: c.timeline_start_ms) + + concat_inputs = [] + last_end_time_ms = start_ms + + for clip in track_clips: + gap_start_ms = max(start_ms, last_end_time_ms) + if clip.timeline_start_ms > gap_start_ms: + gap_duration_sec = (clip.timeline_start_ms - gap_start_ms) / 1000.0 + concat_inputs.append(ffmpeg.input(f'anullsrc=r={DEFAULT_SAMPLE_RATE}:cl={DEFAULT_CHANNELS}:d={gap_duration_sec}', f='lavfi')) + + clip_read_start_ms = clip.clip_start_ms + max(0, start_ms - clip.timeline_start_ms) + clip_play_start_ms = max(start_ms, clip.timeline_start_ms) + clip_remaining_duration_ms = clip.timeline_end_ms - clip_play_start_ms + + if clip_remaining_duration_ms > 0: + segment = ffmpeg.input(clip.source_path, ss=clip_read_start_ms/1000.0, t=clip_remaining_duration_ms/1000.0, re=None).audio + concat_inputs.append(segment) + + last_end_time_ms = clip.timeline_end_ms + + if concat_inputs: + track_streams.append(ffmpeg.concat(*concat_inputs, v=0, a=1)) + + if not track_streams: + return None + + return ffmpeg.filter(track_streams, 'amix', inputs=len(track_streams), duration='longest') + + def play(self, time_ms): + if self.is_playing: + self.stop() + + if self.debug: print(f"Play requested from {time_ms}ms.") + + self.stop_flag.clear() + self.playback_start_time_ms = time_ms + self.pause_time_ms = time_ms + self.is_paused = False + + self.total_samples_played = 0 + self.audio_underruns = 0 + self.last_video_pts_ms = time_ms + with self.sync_lock: + self.audio_clock_sec = 0.0 + self.audio_clock_update_time = 0.0 + + timeline, clips, proj_settings = self.get_timeline_data() + w, h, fps = proj_settings['width'], proj_settings['height'], proj_settings['fps'] + + video_buffer_size = int(fps * VIDEO_BUFFER_SECONDS) + audio_buffer_size = int((DEFAULT_SAMPLE_RATE / AUDIO_CHUNK_SAMPLES) * AUDIO_BUFFER_SECONDS) + self.video_queue = Queue(maxsize=video_buffer_size) + self.audio_queue = Queue(maxsize=audio_buffer_size) + + video_graph = self._build_video_graph(time_ms, timeline, clips, proj_settings) + if video_graph: + try: + args = (video_graph + .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=fps).compile()) + self.video_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + except Exception as e: + print(f"Failed to start video process: {e}") + self.video_process = None + + audio_graph = self._build_audio_graph(time_ms, timeline, clips, proj_settings) + if audio_graph: + try: + args = ffmpeg.output(audio_graph, 'pipe:', format='f32le', ac=DEFAULT_CHANNELS, ar=DEFAULT_SAMPLE_RATE).compile() + self.audio_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + except Exception as e: + print(f"Failed to start audio process: {e}") + self.audio_process = None + + if self.video_process: + self.video_reader_thread = threading.Thread(target=self._video_reader_thread, args=(self.video_process, w, h, fps), daemon=True) + self.video_reader_thread.start() + if self.audio_process: + self.audio_reader_thread = threading.Thread(target=self._audio_reader_thread, args=(self.audio_process,), daemon=True) + self.audio_reader_thread.start() + self.audio_stream = sd.OutputStream(samplerate=DEFAULT_SAMPLE_RATE, channels=DEFAULT_CHANNELS, callback=self._audio_callback, blocksize=AUDIO_CHUNK_SAMPLES) + self.audio_stream.start() + + self.stream_start_time_monotonic = time.monotonic() + self.is_playing = True + self.update_timer.start() + self.started.emit() + + def pause(self): + if not self.is_playing or self.is_paused: + return + if self.debug: print("Playback paused.") + self.is_paused = True + if self.audio_stream: + self.audio_stream.stop() + self.pause_time_ms = self._get_current_time_ms() + self.update_timer.stop() + self.paused.emit() + + def resume(self): + if not self.is_playing or not self.is_paused: + return + if self.debug: print("Playback resumed.") + self.is_paused = False + + time_since_start_sec = (self.pause_time_ms - self.playback_start_time_ms) / 1000.0 + self.stream_start_time_monotonic = time.monotonic() - time_since_start_sec + + if self.audio_stream: + self.audio_stream.start() + + self.update_timer.start() + self.started.emit() + + def stop(self): + was_playing = self.is_playing + if was_playing and self.debug: print("Playback stopped.") + self._cleanup_resources() + self.is_playing = False + self.is_paused = False + if was_playing: + self.stopped.emit() + + def set_volume(self, value): + self.volume = max(0.0, min(1.0, value)) + + def set_muted(self, muted): + self.is_muted = bool(muted) + + def _get_current_time_ms(self): + with self.sync_lock: + audio_clk_sec = self.audio_clock_sec + audio_clk_update_time = self.audio_clock_update_time + + if self.audio_stream and self.audio_stream.active and audio_clk_update_time > 0: + time_since_dac_start = max(0, time.monotonic() - audio_clk_update_time) + current_audio_time_sec = audio_clk_sec + time_since_dac_start + current_pos_ms = self.playback_start_time_ms + int(current_audio_time_sec * 1000.0) + else: + elapsed_sec = time.monotonic() - self.stream_start_time_monotonic + current_pos_ms = self.playback_start_time_ms + int(elapsed_sec * 1000) + + return current_pos_ms + + def _update_loop(self): + if not self.is_playing or self.is_paused: + return + + current_pos_ms = self._get_current_time_ms() + + clock_source = "SYSTEM" + with self.sync_lock: + if self.audio_stream and self.audio_stream.active and self.audio_clock_update_time > 0: + clock_source = "AUDIO" + + if abs(current_pos_ms - self.last_emitted_pos) >= 20: + source_str = f"_update_loop (clock:{clock_source})" + self._emit_playhead_pos(current_pos_ms, source_str) + + if self.video_queue: + while not self.video_queue.empty(): + try: + # Peek at the next frame + if self.video_queue.queue[0] is None: + self.stop() # End of stream + break + _, frame_pts = self.video_queue.queue[0] + + if frame_pts <= current_pos_ms: + frame_bytes, frame_pts = self.video_queue.get_nowait() + if frame_bytes is None: # Should be caught by peek, but for safety + self.stop() + break + self.last_video_pts_ms = frame_pts + _, _, proj_settings = self.get_timeline_data() + w, h = proj_settings['width'], proj_settings['height'] + img = QImage(frame_bytes, w, h, QImage.Format.Format_RGB888) + self.new_frame.emit(QPixmap.fromImage(img)) + else: + # Frame is in the future, wait for next loop + break + except Empty: + break + except IndexError: + break # Queue might be empty between check and access + + # --- Update Stats --- + vq_size = self.video_queue.qsize() if self.video_queue else 0 + vq_max = self.video_queue.maxsize if self.video_queue else 0 + aq_size = self.audio_queue.qsize() if self.audio_queue else 0 + aq_max = self.audio_queue.maxsize if self.audio_queue else 0 + + video_audio_sync_ms = int(self.last_video_pts_ms - current_pos_ms) + + stats_str = (f"AQ: {aq_size}/{aq_max} | VQ: {vq_size}/{vq_max} | " + f"V-A Δ: {video_audio_sync_ms}ms | Clock: {clock_source} | " + f"Underruns: {self.audio_underruns}") + self.stats_updated.emit(stats_str) + + total_duration = self.get_timeline_data()[0].get_total_duration() + if total_duration > 0 and current_pos_ms >= total_duration: + self.stop() + self._emit_playhead_pos(int(total_duration), "_update_loop.end_of_timeline") \ No newline at end of file diff --git a/plugins.py b/plugins.py index f40f6adb1..a4e0699dd 100644 --- a/plugins.py +++ b/plugins.py @@ -5,47 +5,59 @@ import shutil import git import json +import importlib from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QLabel, QLineEdit, QMessageBox, QProgressBar, QDialogButtonBox, QWidget, QCheckBox) from PyQt6.QtCore import Qt, QObject, pyqtSignal, QThread class VideoEditorPlugin: - """ - Base class for all plugins. - Plugins should inherit from this class and be located in 'plugins/plugin_name/main.py'. - The main class in main.py must be named 'Plugin'. - """ - def __init__(self, app_instance, wgp_module=None): + def __init__(self, app_instance): self.app = app_instance - self.wgp = wgp_module self.name = "Unnamed Plugin" self.description = "No description provided." def initialize(self): - """Called once when the plugin is loaded by the PluginManager.""" pass def enable(self): - """Called when the plugin is enabled by the user (e.g., checking the box in the menu).""" pass def disable(self): - """Called when the plugin is disabled by the user.""" pass class PluginManager: - """Manages the discovery, loading, and lifecycle of plugins.""" - def __init__(self, main_app, wgp_module=None): + def __init__(self, main_app): self.app = main_app - self.wgp = wgp_module self.plugins_dir = "plugins" - self.plugins = {} # { 'plugin_name': {'instance': plugin_instance, 'enabled': False, 'module_path': path} } + self.plugins = {} if not os.path.exists(self.plugins_dir): os.makedirs(self.plugins_dir) + def run_preloads(plugins_dir="plugins"): + print("Running plugin pre-load scan...") + if not os.path.isdir(plugins_dir): + return + + for plugin_name in os.listdir(plugins_dir): + plugin_path = os.path.join(plugins_dir, plugin_name) + manifest_path = os.path.join(plugin_path, 'plugin.json') + if os.path.isfile(manifest_path): + try: + with open(manifest_path, 'r') as f: + manifest = json.load(f) + + preload_modules = manifest.get("preload_modules", []) + if preload_modules: + for module_name in preload_modules: + try: + importlib.import_module(module_name) + except ImportError as e: + print(f" - WARNING: Could not pre-load '{module_name}'. Error: {e}") + except Exception as e: + print(f" - WARNING: Could not read or parse manifest for plugin '{plugin_name}': {e}") + def discover_and_load_plugins(self): - """Scans the plugins directory, loads valid plugins, and calls their initialize method.""" for plugin_name in os.listdir(self.plugins_dir): plugin_path = os.path.join(self.plugins_dir, plugin_name) main_py_path = os.path.join(plugin_path, 'main.py') @@ -60,7 +72,7 @@ def discover_and_load_plugins(self): if hasattr(module, 'Plugin'): plugin_class = getattr(module, 'Plugin') - instance = plugin_class(self.app, self.wgp) + instance = plugin_class(self.app) instance.initialize() self.plugins[instance.name] = { 'instance': instance, @@ -76,17 +88,14 @@ def discover_and_load_plugins(self): traceback.print_exc() def load_enabled_plugins_from_settings(self, enabled_plugins_list): - """Enables plugins based on the loaded settings.""" for name in enabled_plugins_list: if name in self.plugins: self.enable_plugin(name) def get_enabled_plugin_names(self): - """Returns a list of names of all enabled plugins.""" return [name for name, data in self.plugins.items() if data['enabled']] def enable_plugin(self, name): - """Enables a specific plugin by name and updates the UI.""" if name in self.plugins and not self.plugins[name]['enabled']: self.plugins[name]['instance'].enable() self.plugins[name]['enabled'] = True @@ -95,7 +104,6 @@ def enable_plugin(self, name): self.app.update_plugin_ui_visibility(name, True) def disable_plugin(self, name): - """Disables a specific plugin by name and updates the UI.""" if name in self.plugins and self.plugins[name]['enabled']: self.plugins[name]['instance'].disable() self.plugins[name]['enabled'] = False @@ -104,7 +112,6 @@ def disable_plugin(self, name): self.app.update_plugin_ui_visibility(name, False) def uninstall_plugin(self, name): - """Uninstalls (deletes) a plugin by name.""" if name in self.plugins: path = self.plugins[name]['module_path'] if self.plugins[name]['enabled']: @@ -174,12 +181,10 @@ def __init__(self, plugin_manager, parent=None): self.status_label = QLabel("Ready.") layout.addWidget(self.status_label) - - # Dialog buttons + self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) layout.addWidget(self.button_box) - # Connections self.install_btn.clicked.connect(self.install_plugin) self.button_box.accepted.connect(self.save_changes) self.button_box.rejected.connect(self.reject) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 473baf2e4..2a38230dd 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -50,10 +50,11 @@ def __getattr__(self, name): sys.modules['shared.gradio.gallery'] = MockGradioModule() # --- End of Gradio Hijacking --- +wgp = None import ffmpeg from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, + QApplication, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QPushButton, QLabel, QLineEdit, QTextEdit, QSlider, QCheckBox, QComboBox, QFileDialog, QGroupBox, QFormLayout, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QScrollArea, QListWidget, QListWidgetItem, @@ -213,7 +214,6 @@ class Worker(QObject): def __init__(self, plugin, state): super().__init__() self.plugin = plugin - self.wgp = plugin.wgp self.state = state self._is_running = True self._last_progress_phase = None @@ -222,7 +222,7 @@ def __init__(self, plugin, state): def run(self): def generation_target(): try: - for _ in self.wgp.process_tasks(self.state): + for _ in wgp.process_tasks(self.state): if self._is_running: self.output.emit() else: break except Exception as e: @@ -243,7 +243,7 @@ def generation_target(): phase_name, step = current_phase total_steps = gen.get("num_inference_steps", 1) high_level_status = gen.get("progress_status", "") - status_msg = self.wgp.merge_status_context(high_level_status, phase_name) + status_msg = wgp.merge_status_context(high_level_status, phase_name) progress_args = [(step, total_steps), status_msg] self.progress.emit(progress_args) preview_img = gen.get('preview') @@ -260,7 +260,6 @@ class WgpDesktopPluginWidget(QWidget): def __init__(self, plugin): super().__init__() self.plugin = plugin - self.wgp = plugin.wgp self.widgets = {} self.state = {} self.worker = None @@ -739,7 +738,7 @@ def _setup_adv_tab_misc(self, tabs): layout.addRow("Force FPS:", fps_combo) profile_combo = self.create_widget(QComboBox, 'override_profile') profile_combo.addItem("Default Profile", -1) - for text, val in self.wgp.memory_profile_choices: profile_combo.addItem(text.split(':')[0], val) + for text, val in wgp.memory_profile_choices: profile_combo.addItem(text.split(':')[0], val) layout.addRow("Override Memory Profile:", profile_combo) combo = self.create_widget(QComboBox, 'multi_prompts_gen_type') combo.addItem("Generate new Video per line", 0) @@ -777,7 +776,7 @@ def _create_scrollable_form_tab(self): def _create_config_combo(self, form_layout, label, key, choices, default_value): combo = QComboBox() for text, data in choices: combo.addItem(text, data) - index = combo.findData(self.wgp.server_config.get(key, default_value)) + index = combo.findData(wgp.server_config.get(key, default_value)) if index != -1: combo.setCurrentIndex(index) self.widgets[f'config_{key}'] = combo form_layout.addRow(label, combo) @@ -789,7 +788,7 @@ def _create_config_slider(self, form_layout, label, key, min_val, max_val, defau slider = QSlider(Qt.Orientation.Horizontal) slider.setRange(min_val, max_val) slider.setSingleStep(step) - slider.setValue(self.wgp.server_config.get(key, default_value)) + slider.setValue(wgp.server_config.get(key, default_value)) value_label = QLabel(str(slider.value())) value_label.setMinimumWidth(40) slider.valueChanged.connect(lambda v, lbl=value_label: lbl.setText(str(v))) @@ -801,7 +800,7 @@ def _create_config_slider(self, form_layout, label, key, min_val, max_val, defau def _create_config_checklist(self, form_layout, label, key, choices, default_value): list_widget = QListWidget() list_widget.setMinimumHeight(100) - current_values = self.wgp.server_config.get(key, default_value) + current_values = wgp.server_config.get(key, default_value) for text, data in choices: item = QListWidgetItem(text) item.setData(Qt.ItemDataRole.UserRole, data) @@ -822,8 +821,8 @@ def _create_config_textbox(self, form_layout, label, key, default_value, multi_l def _create_general_config_tab(self): tab, form = self._create_scrollable_form_tab() - _, _, dropdown_choices = self.wgp.get_sorted_dropdown(self.wgp.displayed_model_types, None, None, False) - self._create_config_checklist(form, "Selectable Models:", "transformer_types", dropdown_choices, self.wgp.transformer_types) + _, _, dropdown_choices = wgp.get_sorted_dropdown(wgp.displayed_model_types, None, None, False) + self._create_config_checklist(form, "Selectable Models:", "transformer_types", dropdown_choices, wgp.transformer_types) self._create_config_combo(form, "Model Hierarchy:", "model_hierarchy_type", [("Two Levels (Family > Model)", 0), ("Three Levels (Family > Base > Finetune)", 1)], 1) self._create_config_combo(form, "Video Dimensions:", "fit_canvas", [("Dimensions are Pixels Budget", 0), ("Dimensions are Max Width/Height", 1), ("Dimensions are Output Width/Height (Cropped)", 2)], 0) self._create_config_combo(form, "Attention Type:", "attention_mode", [("Auto (Recommended)", "auto"), ("SDPA", "sdpa"), ("Flash", "flash"), ("Xformers", "xformers"), ("Sage", "sage"), ("Sage2/2++", "sage2")], "auto") @@ -832,7 +831,7 @@ def _create_general_config_tab(self): self._create_config_combo(form, "Keep Previous Videos:", "clear_file_list", [("None", 0), ("Keep last video", 1), ("Keep last 5", 5), ("Keep last 10", 10), ("Keep last 20", 20), ("Keep last 30", 30)], 5) self._create_config_combo(form, "Display RAM/VRAM Stats:", "display_stats", [("Disabled", 0), ("Enabled", 1)], 0) self._create_config_combo(form, "Max Frames Multiplier:", "max_frames_multiplier", [(f"x{i}", i) for i in range(1, 8)], 1) - checkpoints_paths_text = "\n".join(self.wgp.server_config.get("checkpoints_paths", self.wgp.fl.default_checkpoints_paths)) + checkpoints_paths_text = "\n".join(wgp.server_config.get("checkpoints_paths", wgp.fl.default_checkpoints_paths)) checkpoints_textbox = QTextEdit() checkpoints_textbox.setPlainText(checkpoints_paths_text) checkpoints_textbox.setAcceptRichText(False) @@ -853,7 +852,7 @@ def _create_performance_config_tab(self): self._create_config_combo(form, "DepthAnything v2 Variant:", "depth_anything_v2_variant", [("Large (more precise)", "vitl"), ("Big (faster)", "vitb")], "vitl") self._create_config_combo(form, "VAE Tiling:", "vae_config", [("Auto", 0), ("Disabled", 1), ("256x256 (~8GB VRAM)", 2), ("128x128 (~6GB VRAM)", 3)], 0) self._create_config_combo(form, "Boost:", "boost", [("On", 1), ("Off", 2)], 1) - self._create_config_combo(form, "Memory Profile:", "profile", self.wgp.memory_profile_choices, self.wgp.profile_type.LowRAM_LowVRAM) + self._create_config_combo(form, "Memory Profile:", "profile", wgp.memory_profile_choices, wgp.profile_type.LowRAM_LowVRAM) self._create_config_slider(form, "Preload in VRAM (MB):", "preload_in_VRAM", 0, 40000, 0, 100) release_ram_btn = QPushButton("Force Release Models from RAM") release_ram_btn.clicked.connect(self._on_release_ram) @@ -882,7 +881,6 @@ def _create_notifications_config_tab(self): return tab def init_wgp_state(self): - wgp = self.wgp initial_model = wgp.server_config.get("last_model_type", wgp.transformer_type) dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) @@ -903,14 +901,16 @@ def init_wgp_state(self): self._update_input_visibility() def update_model_dropdowns(self, current_model_type): - wgp = self.wgp family_mock, base_type_mock, choice_mock = wgp.generate_dropdown_model_list(current_model_type) for combo_name, mock in [('model_family', family_mock), ('model_base_type_choice', base_type_mock), ('model_choice', choice_mock)]: combo = self.widgets[combo_name] combo.blockSignals(True) combo.clear() if mock.choices: - for display_name, internal_key in mock.choices: combo.addItem(display_name, internal_key) + for item in mock.choices: + if isinstance(item, (list, tuple)) and len(item) >= 2: + display_name, internal_key = item[0], item[1] + combo.addItem(display_name, internal_key) index = combo.findData(mock.value) if index != -1: combo.setCurrentIndex(index) @@ -925,7 +925,6 @@ def update_model_dropdowns(self, current_model_type): def refresh_ui_from_model_change(self, model_type): """Update UI controls with default settings when the model is changed.""" - wgp = self.wgp self.header_info.setText(wgp.generate_header(model_type, wgp.compile, wgp.attention_mode)) ui_defaults = wgp.get_default_settings(model_type) wgp.set_model_settings(self.state, model_type, ui_defaults) @@ -1286,7 +1285,7 @@ def _on_preview_toggled(self, checked): def _on_family_changed(self): family = self.widgets['model_family'].currentData() if not family or not self.state: return - base_type_mock, choice_mock = self.wgp.change_model_family(self.state, family) + base_type_mock, choice_mock = wgp.change_model_family(self.state, family) if hasattr(base_type_mock, 'kwargs') and isinstance(base_type_mock.kwargs, dict): is_visible_base = base_type_mock.kwargs.get('visible', True) @@ -1324,7 +1323,7 @@ def _on_base_type_changed(self): family = self.widgets['model_family'].currentData() base_type = self.widgets['model_base_type_choice'].currentData() if not family or not base_type or not self.state: return - base_type_mock, choice_mock = self.wgp.change_model_base_types(self.state, family, base_type) + base_type_mock, choice_mock = wgp.change_model_base_types(self.state, family, base_type) if hasattr(choice_mock, 'kwargs') and isinstance(choice_mock.kwargs, dict): is_visible_choice = choice_mock.kwargs.get('visible', True) @@ -1345,18 +1344,18 @@ def _on_base_type_changed(self): def _on_model_changed(self): model_type = self.widgets['model_choice'].currentData() if not model_type or model_type == self.state.get('model_type'): return - self.wgp.change_model(self.state, model_type) + wgp.change_model(self.state, model_type) self.refresh_ui_from_model_change(model_type) def _on_resolution_group_changed(self): selected_group = self.widgets['resolution_group'].currentText() if not selected_group or not hasattr(self, 'full_resolution_choices'): return model_type = self.state['model_type'] - model_def = self.wgp.get_model_def(model_type) + model_def = wgp.get_model_def(model_type) model_resolutions = model_def.get("resolutions", None) group_resolution_choices = [] if model_resolutions is None: - group_resolution_choices = [res for res in self.full_resolution_choices if self.wgp.categorize_resolution(res[1]) == selected_group] + group_resolution_choices = [res for res in self.full_resolution_choices if wgp.categorize_resolution(res[1]) == selected_group] else: return last_resolution = self.state.get("last_resolution_per_group", {}).get(selected_group, "") if not any(last_resolution == res[1] for res in group_resolution_choices) and group_resolution_choices: @@ -1398,7 +1397,7 @@ def set_resolution_from_target(self, target_w, target_h): best_res_value = res_value if best_res_value: - best_group = self.wgp.categorize_resolution(best_res_value) + best_group = wgp.categorize_resolution(best_res_value) group_combo = self.widgets['resolution_group'] group_combo.blockSignals(True) @@ -1417,7 +1416,7 @@ def set_resolution_from_target(self, target_w, target_h): print(f"Warning: Could not find resolution '{best_res_value}' in dropdown after group change.") def collect_inputs(self): - full_inputs = self.wgp.get_current_model_settings(self.state).copy() + full_inputs = wgp.get_current_model_settings(self.state).copy() full_inputs['lset_name'] = "" full_inputs['image_mode'] = 0 expected_keys = { "audio_guide": None, "audio_guide2": None, "image_guide": None, "image_mask": None, "speakers_locations": "", "frames_positions": "", "keep_frames_video_guide": "", "keep_frames_video_source": "", "video_guide_outpainting": "", "switch_threshold2": 0, "model_switch_phase": 1, "batch_size": 1, "control_net_weight_alt": 1.0, "image_refs_relative_size": 50, } @@ -1512,9 +1511,9 @@ def _add_task_to_queue(self): all_inputs = self.collect_inputs() for key in ['type', 'settings_version', 'is_image', 'video_quality', 'image_quality', 'base_model_type']: all_inputs.pop(key, None) all_inputs['state'] = self.state - self.wgp.set_model_settings(self.state, self.state['model_type'], all_inputs) + wgp.set_model_settings(self.state, self.state['model_type'], all_inputs) self.state["validate_success"] = 1 - self.wgp.process_prompt_and_add_tasks(self.state, self.state['model_type']) + wgp.process_prompt_and_add_tasks(self.state, self.state['model_type']) self.update_queue_table() def start_generation(self): @@ -1582,11 +1581,11 @@ def add_result_item(self, video_path): self.results_list.setItemWidget(list_item, item_widget) def update_queue_table(self): - with self.wgp.lock: + with wgp.lock: queue = self.state.get('gen', {}).get('queue', []) is_running = self.thread and self.thread.isRunning() queue_to_display = queue if is_running else [None] + queue - table_data = self.wgp.get_queue_table(queue_to_display) + table_data = wgp.get_queue_table(queue_to_display) self.queue_table.setRowCount(0) self.queue_table.setRowCount(len(table_data)) self.queue_table.setColumnCount(4) @@ -1601,7 +1600,7 @@ def update_queue_table(self): def _on_remove_selected_from_queue(self): selected_row = self.queue_table.currentRow() if selected_row < 0: return - with self.wgp.lock: + with wgp.lock: is_running = self.thread and self.thread.isRunning() offset = 1 if is_running else 0 queue = self.state.get('gen', {}).get('queue', []) @@ -1609,7 +1608,7 @@ def _on_remove_selected_from_queue(self): self.update_queue_table() def _on_queue_rows_moved(self, source_row, dest_row): - with self.wgp.lock: + with wgp.lock: queue = self.state.get('gen', {}).get('queue', []) is_running = self.thread and self.thread.isRunning() offset = 1 if is_running else 0 @@ -1620,17 +1619,17 @@ def _on_queue_rows_moved(self, source_row, dest_row): self.update_queue_table() def _on_clear_queue(self): - self.wgp.clear_queue_action(self.state) + wgp.clear_queue_action(self.state) self.update_queue_table() def _on_abort(self): if self.worker: - self.wgp.abort_generation(self.state) + wgp.abort_generation(self.state) self.status_label.setText("Aborting...") self.worker._is_running = False def _on_release_ram(self): - self.wgp.release_RAM() + wgp.release_RAM() QMessageBox.information(self, "RAM Released", "Models stored in RAM have been released.") def _on_apply_config_changes(self): @@ -1655,12 +1654,12 @@ def _on_apply_config_changes(self): changes['notification_sound_volume_choice'] = self.widgets['config_notification_sound_volume'].value() changes['last_resolution_choice'] = self.widgets['resolution'].currentData() try: - msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = self.wgp.apply_changes(self.state, **changes) + msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = wgp.apply_changes(self.state, **changes) self.config_status_label.setText("Changes applied successfully. Some settings may require a restart.") - self.header_info.setText(self.wgp.generate_header(self.state['model_type'], self.wgp.compile, self.wgp.attention_mode)) + self.header_info.setText(wgp.generate_header(self.state['model_type'], wgp.compile, wgp.attention_mode)) if family_mock.choices is not None or choice_mock.choices is not None: - self.update_model_dropdowns(self.wgp.transformer_type) - self.refresh_ui_from_model_change(self.wgp.transformer_type) + self.update_model_dropdowns(wgp.transformer_type) + self.refresh_ui_from_model_change(wgp.transformer_type) except Exception as e: self.config_status_label.setText(f"Error applying changes: {e}") import traceback; traceback.print_exc() @@ -1669,23 +1668,82 @@ class Plugin(VideoEditorPlugin): def initialize(self): self.name = "AI Generator" self.description = "Uses the integrated Wan2GP library to generate video clips." - self.client_widget = WgpDesktopPluginWidget(self) + self.client_widget = None self.dock_widget = None - self.active_region = None; self.temp_dir = None - self.insert_on_new_track = False; self.start_frame_path = None; self.end_frame_path = None + self._heavy_content_loaded = False + + self.active_region = None + self.temp_dir = None + self.insert_on_new_track = False + self.start_frame_path = None + self.end_frame_path = None def enable(self): - if not self.dock_widget: self.dock_widget = self.app.add_dock_widget(self, self.client_widget, self.name) + if not self.dock_widget: + placeholder = QLabel("Loading AI Generator...") + placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.dock_widget = self.app.add_dock_widget(self, placeholder, self.name) + self.dock_widget.visibilityChanged.connect(self._on_visibility_changed) + self.app.timeline_widget.context_menu_requested.connect(self.on_timeline_context_menu) self.app.status_label.setText(f"{self.name}: Enabled.") + def _on_visibility_changed(self, visible): + if visible and not self._heavy_content_loaded: + self._load_heavy_ui() + + def _load_heavy_ui(self): + if self._heavy_content_loaded: + return True + + self.app.status_label.setText("Loading AI Generator...") + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + QApplication.processEvents() + try: + global wgp + if wgp is None: + import wgp as wgp_module + wgp = wgp_module + + self.client_widget = WgpDesktopPluginWidget(self) + self.dock_widget.setWidget(self.client_widget) + self._heavy_content_loaded = True + self.app.status_label.setText("AI Generator loaded.") + return True + except Exception as e: + print(f"Failed to load AI Generator plugin backend: {e}") + import traceback + traceback.print_exc() + error_label = QLabel(f"Failed to load AI Generator:\n\n{e}\n\nPlease see console for details.") + error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + error_label.setWordWrap(True) + if self.dock_widget: + self.dock_widget.setWidget(error_label) + QMessageBox.critical(self.app, "Plugin Load Error", f"Failed to load the AI Generator backend.\n\n{e}") + return False + finally: + QApplication.restoreOverrideCursor() + def disable(self): try: self.app.timeline_widget.context_menu_requested.disconnect(self.on_timeline_context_menu) except TypeError: pass + + if self.dock_widget: + try: self.dock_widget.visibilityChanged.disconnect(self._on_visibility_changed) + except TypeError: pass + self._cleanup_temp_dir() - if self.client_widget.worker: self.client_widget._on_abort() + if self.client_widget and self.client_widget.worker: + self.client_widget._on_abort() + self.app.status_label.setText(f"{self.name}: Disabled.") + def _ensure_ui_loaded(self): + if not self._heavy_content_loaded: + if not self._load_heavy_ui(): + return False + return True + def _cleanup_temp_dir(self): if self.temp_dir and os.path.exists(self.temp_dir): shutil.rmtree(self.temp_dir) @@ -1694,11 +1752,12 @@ def _cleanup_temp_dir(self): def _reset_state(self): self.active_region = None; self.insert_on_new_track = False self.start_frame_path = None; self.end_frame_path = None - self.client_widget.processed_files.clear() - self.client_widget.results_list.clear() - self.client_widget.widgets['image_start'].clear() - self.client_widget.widgets['image_end'].clear() - self.client_widget.widgets['video_source'].clear() + if self._heavy_content_loaded: + self.client_widget.processed_files.clear() + self.client_widget.results_list.clear() + self.client_widget.widgets['image_start'].clear() + self.client_widget.widgets['image_end'].clear() + self.client_widget.widgets['video_source'].clear() self._cleanup_temp_dir() self.app.status_label.setText(f"{self.name}: Ready.") @@ -1732,13 +1791,14 @@ def on_timeline_context_menu(self, menu, event): create_action_new_track.triggered.connect(lambda: self.setup_creator_for_region(region, on_new_track=True)) def setup_generator_for_region(self, region, on_new_track=False): + if not self._ensure_ui_loaded(): return self._reset_state() self.active_region = region self.insert_on_new_track = on_new_track model_to_set = 'i2v_2_2' - dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types - _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types + _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) if any(model_to_set == m[1] for m in all_models): if self.client_widget.state.get('model_type') != model_to_set: self.client_widget.update_model_dropdowns(model_to_set) @@ -1763,7 +1823,6 @@ def setup_generator_for_region(self, region, on_new_track=False): QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) duration_ms = end_ms - start_ms - wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) @@ -1792,13 +1851,14 @@ def setup_generator_for_region(self, region, on_new_track=False): self.dock_widget.raise_() def setup_generator_from_start(self, region, on_new_track=False): + if not self._ensure_ui_loaded(): return self._reset_state() self.active_region = region self.insert_on_new_track = on_new_track model_to_set = 'i2v_2_2' - dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types - _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types + _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) if any(model_to_set == m[1] for m in all_models): if self.client_widget.state.get('model_type') != model_to_set: self.client_widget.update_model_dropdowns(model_to_set) @@ -1820,7 +1880,6 @@ def setup_generator_from_start(self, region, on_new_track=False): QImage(start_data, w, h, QImage.Format.Format_RGB888).save(self.start_frame_path) duration_ms = end_ms - start_ms - wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) @@ -1851,13 +1910,14 @@ def setup_generator_from_start(self, region, on_new_track=False): self.dock_widget.raise_() def setup_generator_to_end(self, region, on_new_track=False): + if not self._ensure_ui_loaded(): return self._reset_state() self.active_region = region self.insert_on_new_track = on_new_track model_to_set = 'i2v_2_2' - dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types - _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types + _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) if any(model_to_set == m[1] for m in all_models): if self.client_widget.state.get('model_type') != model_to_set: self.client_widget.update_model_dropdowns(model_to_set) @@ -1879,7 +1939,6 @@ def setup_generator_to_end(self, region, on_new_track=False): QImage(end_data, w, h, QImage.Format.Format_RGB888).save(self.end_frame_path) duration_ms = end_ms - start_ms - wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) @@ -1887,7 +1946,7 @@ def setup_generator_to_end(self, region, on_new_track=False): widgets['video_length'].setValue(video_length_frames) - model_def = self.client_widget.wgp.get_model_def(self.client_widget.state['model_type']) + model_def = wgp.get_model_def(self.client_widget.state['model_type']) allowed_modes = model_def.get("image_prompt_types_allowed", "") if "E" not in allowed_modes: @@ -1921,13 +1980,14 @@ def setup_generator_to_end(self, region, on_new_track=False): self.dock_widget.raise_() def setup_creator_for_region(self, region, on_new_track=False): + if not self._ensure_ui_loaded(): return self._reset_state() self.active_region = region self.insert_on_new_track = on_new_track model_to_set = 't2v_2_2' - dropdown_types = self.wgp.transformer_types if len(self.wgp.transformer_types) > 0 else self.wgp.displayed_model_types - _, _, all_models = self.wgp.get_sorted_dropdown(dropdown_types, None, None, False) + dropdown_types = wgp.transformer_types if len(wgp.transformer_types) > 0 else wgp.displayed_model_types + _, _, all_models = wgp.get_sorted_dropdown(dropdown_types, None, None, False) if any(model_to_set == m[1] for m in all_models): if self.client_widget.state.get('model_type') != model_to_set: self.client_widget.update_model_dropdowns(model_to_set) @@ -1941,7 +2001,6 @@ def setup_creator_for_region(self, region, on_new_track=False): start_ms, end_ms = region duration_ms = end_ms - start_ms - wgp = self.client_widget.wgp model_type = self.client_widget.state['model_type'] fps = wgp.get_model_fps(model_type) video_length_frames = int((duration_ms / 1000.0) * fps) if fps > 0 else int((duration_ms / 1000.0) * 16) diff --git a/plugins/wan2gp/plugin.json b/plugins/wan2gp/plugin.json new file mode 100644 index 000000000..47e592c50 --- /dev/null +++ b/plugins/wan2gp/plugin.json @@ -0,0 +1,5 @@ +{ + "preload_modules": [ + "onnxruntime" + ] +} \ No newline at end of file diff --git a/videoeditor.py b/videoeditor.py index d6e744ebe..21df9e63d 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -1,4 +1,4 @@ -import wgp +import onnxruntime import sys import os import uuid @@ -7,19 +7,21 @@ import json import ffmpeg import copy +from plugins import PluginManager, ManagePluginsDialog from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QFileDialog, QLabel, QScrollArea, QFrame, QProgressBar, QDialog, QCheckBox, QDialogButtonBox, QMenu, QSplitter, QDockWidget, QListWidget, QListWidgetItem, QMessageBox, QComboBox, - QFormLayout, QGroupBox, QLineEdit) + QFormLayout, QGroupBox, QLineEdit, QSlider) from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, QPixmap, QImage, QDrag, QCursor, QKeyEvent) from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, pyqtSignal, QTimer, QByteArray, QMimeData) -from plugins import PluginManager, ManagePluginsDialog from undo import UndoStack, TimelineStateChangeCommand, MoveClipsCommand +from playback import PlaybackManager +from encoding import Encoder CONTAINER_PRESETS = { 'mp4': { @@ -210,36 +212,6 @@ def get_total_duration(self): if not self.clips: return 0 return max(c.timeline_end_ms for c in self.clips) -class ExportWorker(QObject): - progress = pyqtSignal(int) - finished = pyqtSignal(str) - def __init__(self, ffmpeg_cmd, total_duration_ms): - super().__init__() - self.ffmpeg_cmd = ffmpeg_cmd - self.total_duration_ms = total_duration_ms - - def run_export(self): - try: - process = subprocess.Popen(self.ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, encoding="utf-8") - time_pattern = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})") - for line in iter(process.stdout.readline, ""): - match = time_pattern.search(line) - if match: - h, m, s, cs = [int(g) for g in match.groups()] - processed_ms = (h * 3600 + m * 60 + s) * 1000 + cs * 10 - if self.total_duration_ms > 0: - percentage = int((processed_ms / self.total_duration_ms) * 100) - self.progress.emit(percentage) - process.stdout.close() - return_code = process.wait() - if return_code == 0: - self.progress.emit(100) - self.finished.emit("Export completed successfully!") - else: - self.finished.emit(f"Export failed! Check console for FFmpeg errors.") - except Exception as e: - self.finished.emit(f"An exception occurred during export: {e}") - class TimelineWidget(QWidget): TIMESCALE_HEIGHT = 30 HEADER_WIDTH = 120 @@ -333,6 +305,10 @@ def set_project_fps(self, fps): def ms_to_x(self, ms): return self.HEADER_WIDTH + int(max(-500_000_000, min((ms - self.view_start_ms) * self.pixels_per_ms, 500_000_000))) def x_to_ms(self, x): return self.view_start_ms + int(float(x - self.HEADER_WIDTH) / self.pixels_per_ms) if x > self.HEADER_WIDTH and self.pixels_per_ms > 0 else self.view_start_ms + def set_playhead_pos(self, time_ms): + self.playhead_pos_ms = time_ms + self.update() + def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -1848,8 +1824,6 @@ def __init__(self, project_to_load=None): self.undo_stack = UndoStack() self.media_pool = [] self.media_properties = {} - self.export_thread = None - self.export_worker = None self.current_project_path = None self.last_export_path = None self.settings = {} @@ -1857,7 +1831,10 @@ def __init__(self, project_to_load=None): self.is_shutting_down = False self._load_settings() - self.plugin_manager = PluginManager(self, wgp) + self.playback_manager = PlaybackManager(self._get_playback_data) + self.encoder = Encoder() + + self.plugin_manager = PluginManager(self) self.plugin_manager.discover_and_load_plugins() # Project settings with defaults @@ -1869,16 +1846,12 @@ def __init__(self, project_to_load=None): self.scale_to_fit = True self.current_preview_pixmap = None - self.playback_timer = QTimer(self) - self.playback_process = None - self.playback_clip = None - self._setup_ui() self._connect_signals() self.plugin_manager.load_enabled_plugins_from_settings(self.settings.get("enabled_plugins", [])) self._apply_loaded_settings() - self.seek_preview(0) + self.playback_manager.seek_to_frame(0) if not self.settings_file_was_loaded: self._save_settings() if project_to_load: QTimer.singleShot(100, lambda: self._load_project_from_path(project_to_load)) @@ -1893,6 +1866,17 @@ def _get_current_timeline_state(self): self.timeline.num_video_tracks, self.timeline.num_audio_tracks ) + + def _get_playback_data(self): + return ( + self.timeline, + self.timeline.clips, + { + 'width': self.project_width, + 'height': self.project_height, + 'fps': self.project_fps + } + ) def _setup_ui(self): self.media_dock = QDockWidget("Project Media", self) @@ -1946,15 +1930,33 @@ def _setup_ui(self): controls_layout.addStretch() main_layout.addWidget(controls_widget) - status_layout = QHBoxLayout() + status_bar_widget = QWidget() + status_layout = QHBoxLayout(status_bar_widget) + status_layout.setContentsMargins(5,2,5,2) + self.status_label = QLabel("Ready. Create or open a project from the File menu.") + self.stats_label = QLabel("AQ: 0/0 | VQ: 0/0") + self.stats_label.setMinimumWidth(120) self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) self.progress_bar.setTextVisible(True) self.progress_bar.setRange(0, 100) + + self.mute_button = QPushButton("Mute") + self.mute_button.setCheckable(True) + self.volume_slider = QSlider(Qt.Orientation.Horizontal) + self.volume_slider.setRange(0, 100) + self.volume_slider.setValue(100) + self.volume_slider.setMaximumWidth(100) + status_layout.addWidget(self.status_label, 1) + status_layout.addWidget(self.stats_label) status_layout.addWidget(self.progress_bar, 1) - main_layout.addLayout(status_layout) + status_layout.addStretch() + status_layout.addWidget(self.mute_button) + status_layout.addWidget(self.volume_slider) + + main_layout.addWidget(status_bar_widget) self.setCentralWidget(container_widget) @@ -1978,7 +1980,7 @@ def _connect_signals(self): self.timeline_widget.split_requested.connect(self.split_clip_at_playhead) self.timeline_widget.delete_clip_requested.connect(self.delete_clip) self.timeline_widget.delete_clips_requested.connect(self.delete_clips) - self.timeline_widget.playhead_moved.connect(self.seek_preview) + self.timeline_widget.playhead_moved.connect(self.playback_manager.seek_to_frame) self.timeline_widget.split_region_requested.connect(self.on_split_region) self.timeline_widget.split_all_regions_requested.connect(self.on_split_all_regions) self.timeline_widget.join_region_requested.connect(self.on_join_region) @@ -1995,7 +1997,6 @@ def _connect_signals(self): self.frame_forward_button.clicked.connect(lambda: self.step_frame(1)) self.snap_back_button.clicked.connect(lambda: self.snap_playhead(-1)) self.snap_forward_button.clicked.connect(lambda: self.snap_playhead(1)) - self.playback_timer.timeout.connect(self.advance_playback_frame) self.project_media_widget.add_media_requested.connect(self.add_media_files) self.project_media_widget.media_removed.connect(self.on_media_removed_from_pool) @@ -2004,6 +2005,19 @@ def _connect_signals(self): self.undo_stack.history_changed.connect(self.update_undo_redo_actions) self.undo_stack.timeline_changed.connect(self.on_timeline_changed_by_undo) + self.playback_manager.new_frame.connect(self._on_new_frame) + self.playback_manager.playback_pos_changed.connect(self._on_playback_pos_changed) + self.playback_manager.stopped.connect(self._on_playback_stopped) + self.playback_manager.started.connect(self._on_playback_started) + self.playback_manager.paused.connect(self._on_playback_paused) + self.playback_manager.stats_updated.connect(self.stats_label.setText) + + self.encoder.progress.connect(self.progress_bar.setValue) + self.encoder.finished.connect(self.on_export_finished) + + self.mute_button.toggled.connect(self._on_mute_toggled) + self.volume_slider.valueChanged.connect(self._on_volume_changed) + def _show_preview_context_menu(self, pos): menu = QMenu(self) scale_action = QAction("Scale to Fit", self, checkable=True) @@ -2019,7 +2033,7 @@ def _toggle_scale_to_fit(self, checked): def on_timeline_changed_by_undo(self): self.prune_empty_tracks() self.timeline_widget.update() - self.seek_preview(self.timeline_widget.playhead_pos_ms) + self.playback_manager.seek_to_frame(self.timeline_widget.playhead_pos_ms) self.status_label.setText("Operation undone/redone.") def update_undo_redo_actions(self): @@ -2191,41 +2205,6 @@ def _create_menu_bar(self): data['action'] = action self.windows_menu.addAction(action) - def _get_topmost_video_clip_at(self, time_ms): - """Finds the video clip on the highest track at a specific time.""" - top_clip = None - for c in self.timeline.clips: - if c.track_type == 'video' and c.timeline_start_ms <= time_ms < c.timeline_end_ms: - if top_clip is None or c.track_index > top_clip.track_index: - top_clip = c - return top_clip - - def _start_playback_stream_at(self, time_ms): - self._stop_playback_stream() - clip = self._get_topmost_video_clip_at(time_ms) - if not clip: return - - self.playback_clip = clip - clip_time_sec = (time_ms - clip.timeline_start_ms + clip.clip_start_ms) / 1000.0 - w, h = self.project_width, self.project_height - try: - args = (ffmpeg.input(self.playback_clip.source_path, ss=f"{clip_time_sec:.6f}") - .filter('scale', w, h, force_original_aspect_ratio='decrease') - .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') - .output('pipe:', format='rawvideo', pix_fmt='rgb24', r=self.project_fps).compile()) - self.playback_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - except Exception as e: - print(f"Failed to start playback stream: {e}"); self._stop_playback_stream() - - def _stop_playback_stream(self): - if self.playback_process: - if self.playback_process.poll() is None: - self.playback_process.terminate() - try: self.playback_process.wait(timeout=0.5) - except subprocess.TimeoutExpired: self.playback_process.kill(); self.playback_process.wait() - self.playback_process = None - self.playback_clip = None - def _get_media_properties(self, file_path): """Probes a file to get its media properties. Returns a dict or None.""" if file_path in self.media_properties: @@ -2307,11 +2286,16 @@ def _probe_for_drag(self, file_path): return self._get_media_properties(file_path) def get_frame_data_at_time(self, time_ms): - clip_at_time = self._get_topmost_video_clip_at(time_ms) + """Blocking frame grab for plugin compatibility.""" + _, clips, proj_settings = self._get_playback_data() + w, h = proj_settings['width'], proj_settings['height'] + + clip_at_time = next((c for c in sorted(clips, key=lambda x: x.track_index, reverse=True) + if c.track_type == 'video' and c.timeline_start_ms <= time_ms < c.timeline_end_ms), None) + if not clip_at_time: return (None, 0, 0) try: - w, h = self.project_width, self.project_height if clip_at_time.media_type == 'image': out, _ = ( ffmpeg @@ -2333,41 +2317,16 @@ def get_frame_data_at_time(self, time_ms): ) return (out, self.project_width, self.project_height) except ffmpeg.Error as e: - print(f"Error extracting frame data: {e.stderr}") + print(f"Error extracting frame data for plugin: {e.stderr}") return (None, 0, 0) - def get_frame_at_time(self, time_ms): - clip_at_time = self._get_topmost_video_clip_at(time_ms) - if not clip_at_time: return None - try: - w, h = self.project_width, self.project_height - if clip_at_time.media_type == 'image': - out, _ = ( - ffmpeg.input(clip_at_time.source_path) - .filter('scale', w, h, force_original_aspect_ratio='decrease') - .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') - .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') - .run(capture_stdout=True, quiet=True) - ) - else: - clip_time_sec = (time_ms - clip_at_time.timeline_start_ms + clip_at_time.clip_start_ms) / 1000.0 - out, _ = (ffmpeg.input(clip_at_time.source_path, ss=f"{clip_time_sec:.6f}") - .filter('scale', w, h, force_original_aspect_ratio='decrease') - .filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black') - .output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24') - .run(capture_stdout=True, quiet=True)) - - image = QImage(out, self.project_width, self.project_height, QImage.Format.Format_RGB888) - return QPixmap.fromImage(image) - except ffmpeg.Error as e: print(f"Error extracting frame: {e.stderr}"); return None - - def seek_preview(self, time_ms): - self._stop_playback_stream() - self.timeline_widget.playhead_pos_ms = int(time_ms) - self.timeline_widget.update() - self.current_preview_pixmap = self.get_frame_at_time(time_ms) + def _on_new_frame(self, pixmap): + self.current_preview_pixmap = pixmap self._update_preview_display() + def _on_playback_pos_changed(self, time_ms): + self.timeline_widget.set_playhead_pos(time_ms) + def _update_preview_display(self): pixmap_to_show = self.current_preview_pixmap if not pixmap_to_show: @@ -2388,22 +2347,42 @@ def _update_preview_display(self): self.preview_scroll_area.setWidgetResizable(False) self.preview_widget.setPixmap(pixmap_to_show) self.preview_widget.adjustSize() - def toggle_playback(self): - if self.playback_timer.isActive(): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play") + if not self.timeline.clips: + return + + if self.playback_manager.is_playing: + if self.playback_manager.is_paused: + self.playback_manager.resume() + else: + self.playback_manager.pause() else: - if not self.timeline.clips: return - if self.timeline_widget.playhead_pos_ms >= self.timeline.get_total_duration(): self.timeline_widget.playhead_pos_ms = 0 - self.playback_timer.start(int(1000 / self.project_fps)); self.play_pause_button.setText("Pause") + current_pos = self.timeline_widget.playhead_pos_ms + if current_pos >= self.timeline.get_total_duration(): + current_pos = 0 + self.playback_manager.play(current_pos) + + def _on_playback_started(self): + self.play_pause_button.setText("Pause") + + def _on_playback_paused(self): + self.play_pause_button.setText("Play") + + def _on_playback_stopped(self): + self.play_pause_button.setText("Play") + + def stop_playback(self): + self.playback_manager.stop() + self.playback_manager.seek_to_frame(0) - def stop_playback(self): self.playback_timer.stop(); self._stop_playback_stream(); self.play_pause_button.setText("Play"); self.seek_preview(0) def step_frame(self, direction): if not self.timeline.clips: return - self.playback_timer.stop(); self.play_pause_button.setText("Play"); self._stop_playback_stream() + self.playback_manager.pause() frame_duration_ms = 1000.0 / self.project_fps new_time = self.timeline_widget.playhead_pos_ms + (direction * frame_duration_ms) - self.seek_preview(int(max(0, min(new_time, self.timeline.get_total_duration())))) + final_time = int(max(0, min(new_time, self.timeline.get_total_duration()))) + self.playback_manager.seek_to_frame(final_time) def snap_playhead(self, direction): if not self.timeline.clips: @@ -2423,50 +2402,20 @@ def snap_playhead(self, direction): if direction == 1: # Forward next_points = [p for p in sorted_points if p > current_time_ms + TOLERANCE_MS] if next_points: - self.seek_preview(next_points[0]) + self.playback_manager.seek_to_frame(next_points[0]) elif direction == -1: # Backward prev_points = [p for p in sorted_points if p < current_time_ms - TOLERANCE_MS] if prev_points: - self.seek_preview(prev_points[-1]) + self.playback_manager.seek_to_frame(prev_points[-1]) elif current_time_ms > 0: - self.seek_preview(0) - - def advance_playback_frame(self): - frame_duration_ms = 1000.0 / self.project_fps - new_time_ms = self.timeline_widget.playhead_pos_ms + frame_duration_ms - if new_time_ms > self.timeline.get_total_duration(): self.stop_playback(); return - - self.timeline_widget.playhead_pos_ms = round(new_time_ms) - self.timeline_widget.update() - - clip_at_new_time = self._get_topmost_video_clip_at(new_time_ms) - - if not clip_at_new_time: - self._stop_playback_stream() - self.current_preview_pixmap = None - self._update_preview_display() - return + self.playback_manager.seek_to_frame(0) - if clip_at_new_time.media_type == 'image': - if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: - self._stop_playback_stream() - self.playback_clip = clip_at_new_time - self.current_preview_pixmap = self.get_frame_at_time(new_time_ms) - self._update_preview_display() - return + def _on_volume_changed(self, value): + self.playback_manager.set_volume(value / 100.0) - if self.playback_clip is None or self.playback_clip.id != clip_at_new_time.id: - self._start_playback_stream_at(new_time_ms) - - if self.playback_process: - frame_size = self.project_width * self.project_height * 3 - frame_bytes = self.playback_process.stdout.read(frame_size) - if len(frame_bytes) == frame_size: - image = QImage(frame_bytes, self.project_width, self.project_height, QImage.Format.Format_RGB888) - self.current_preview_pixmap = QPixmap.fromImage(image) - self._update_preview_display() - else: - self._stop_playback_stream() + def _on_mute_toggled(self, checked): + self.playback_manager.set_muted(checked) + self.mute_button.setText("Unmute" if checked else "Mute") def _load_settings(self): self.settings_file_was_loaded = False @@ -2518,9 +2467,10 @@ def open_settings_dialog(self): self.settings.update(dialog.get_settings()); self._save_settings(); self.status_label.setText("Settings updated.") def new_project(self): + self.playback_manager.stop() self.timeline.clips.clear(); self.timeline.num_video_tracks = 1; self.timeline.num_audio_tracks = 1 self.media_pool.clear(); self.media_properties.clear(); self.project_media_widget.clear_list() - self.current_project_path = None; self.stop_playback() + self.current_project_path = None self.last_export_path = None self.project_fps = 25.0 self.project_width = 1280 @@ -2532,6 +2482,7 @@ def new_project(self): self.undo_stack.history_changed.connect(self.update_undo_redo_actions) self.update_undo_redo_actions() self.status_label.setText("New project created. Add media to begin.") + self.playback_manager.seek_to_frame(0) def save_project_as(self): path, _ = QFileDialog.getSaveFileName(self, "Save Project", "", "JSON Project Files (*.json)") @@ -2592,7 +2543,8 @@ def _load_project_from_path(self, path): self.current_project_path = path self.prune_empty_tracks() - self.timeline_widget.update(); self.stop_playback() + self.timeline_widget.update() + self.playback_manager.seek_to_frame(0) self.status_label.setText(f"Project '{os.path.basename(path)}' loaded.") self._add_to_recent_files(path) except Exception as e: self.status_label.setText(f"Error opening project: {e}") @@ -3045,103 +2997,29 @@ def export_video(self): self.status_label.setText("Export canceled.") return - settings = dialog.get_export_settings() - output_path = settings["output_path"] + export_settings = dialog.get_export_settings() + output_path = export_settings["output_path"] if not output_path: self.status_label.setText("Export failed: No output path specified.") return self.last_export_path = output_path - total_dur_ms = self.timeline.get_total_duration() - total_dur_sec = total_dur_ms / 1000.0 - w, h, fr_str = self.project_width, self.project_height, str(self.project_fps) - sample_rate, channel_layout = '44100', 'stereo' - - video_stream = ffmpeg.input(f'color=c=black:s={w}x{h}:r={fr_str}:d={total_dur_sec}', f='lavfi') - - all_video_clips = sorted( - [c for c in self.timeline.clips if c.track_type == 'video'], - key=lambda c: c.track_index - ) - input_nodes = {} - for clip in all_video_clips: - if clip.source_path not in input_nodes: - if clip.media_type == 'image': - input_nodes[clip.source_path] = ffmpeg.input(clip.source_path, loop=1, framerate=self.project_fps) - else: - input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) - - clip_source_node = input_nodes[clip.source_path] - clip_duration_sec = clip.duration_ms / 1000.0 - clip_start_sec = clip.clip_start_ms / 1000.0 - timeline_start_sec = clip.timeline_start_ms / 1000.0 - timeline_end_sec = clip.timeline_end_ms / 1000.0 - - if clip.media_type == 'image': - segment_stream = (clip_source_node.video.trim(duration=clip_duration_sec).setpts('PTS-STARTPTS')) - else: - segment_stream = (clip_source_node.video.trim(start=clip_start_sec, duration=clip_duration_sec).setpts('PTS-STARTPTS')) - - processed_segment = (segment_stream.filter('scale', w, h, force_original_aspect_ratio='decrease').filter('pad', w, h, '(ow-iw)/2', '(oh-ih)/2', 'black')) - video_stream = ffmpeg.overlay(video_stream, processed_segment, enable=f'between(t,{timeline_start_sec},{timeline_end_sec})') - - final_video = video_stream.filter('format', pix_fmts='yuv420p').filter('fps', fps=self.project_fps) + project_settings = { + 'width': self.project_width, + 'height': self.project_height, + 'fps': self.project_fps + } - track_audio_streams = [] - for i in range(self.timeline.num_audio_tracks): - track_clips = sorted([c for c in self.timeline.clips if c.track_type == 'audio' and c.track_index == i + 1], key=lambda c: c.timeline_start_ms) - if not track_clips: continue - track_segments = [] - last_end_ms = track_clips[0].timeline_start_ms - for clip in track_clips: - if clip.source_path not in input_nodes: - input_nodes[clip.source_path] = ffmpeg.input(clip.source_path) - gap_ms = clip.timeline_start_ms - last_end_ms - if gap_ms > 10: - track_segments.append(ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={gap_ms/1000.0}', f='lavfi')) - - clip_start_sec = clip.clip_start_ms / 1000.0 - clip_duration_sec = clip.duration_ms / 1000.0 - a_seg = input_nodes[clip.source_path].audio.filter('atrim', start=clip_start_sec, duration=clip_duration_sec).filter('asetpts', 'PTS-STARTPTS') - track_segments.append(a_seg) - last_end_ms = clip.timeline_end_ms - track_audio_streams.append(ffmpeg.concat(*track_segments, v=0, a=1).filter('adelay', f'{int(track_clips[0].timeline_start_ms)}ms', all=True)) - - if track_audio_streams: - final_audio = ffmpeg.filter(track_audio_streams, 'amix', inputs=len(track_audio_streams), duration='longest') - else: - final_audio = ffmpeg.input(f'anullsrc=r={sample_rate}:cl={channel_layout}:d={total_dur_sec}', f='lavfi') + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + self.status_label.setText("Exporting...") - output_args = {'vcodec': settings['vcodec'], 'acodec': settings['acodec'], 'pix_fmt': 'yuv420p'} - if settings['v_bitrate']: output_args['b:v'] = settings['v_bitrate'] - if settings['a_bitrate']: output_args['b:a'] = settings['a_bitrate'] - - try: - ffmpeg_cmd = ffmpeg.output(final_video, final_audio, output_path, **output_args).overwrite_output().compile() - self.progress_bar.setVisible(True); self.progress_bar.setValue(0); self.status_label.setText("Exporting...") - self.export_thread = QThread() - self.export_worker = ExportWorker(ffmpeg_cmd, total_dur_ms) - self.export_worker.moveToThread(self.export_thread) - self.export_thread.started.connect(self.export_worker.run_export) - self.export_worker.finished.connect(self.on_export_finished) - self.export_worker.progress.connect(self.progress_bar.setValue) - self.export_worker.finished.connect(self.export_thread.quit) - self.export_worker.finished.connect(self.export_worker.deleteLater) - self.export_thread.finished.connect(self.export_thread.deleteLater) - self.export_thread.finished.connect(self.on_thread_finished_cleanup) - self.export_thread.start() - except ffmpeg.Error as e: - self.status_label.setText(f"FFmpeg error: {e.stderr}") - print(e.stderr) + self.encoder.start_export(self.timeline, project_settings, export_settings) - def on_export_finished(self, message): + def on_export_finished(self, success, message): self.status_label.setText(message) self.progress_bar.setVisible(False) - - def on_thread_finished_cleanup(self): - self.export_thread = None - self.export_worker = None def add_dock_widget(self, plugin_instance, widget, title, area=Qt.DockWidgetArea.RightDockWidgetArea, show_on_creation=True): widget_key = f"plugin_{plugin_instance.name}_{title}".replace(' ', '_').lower() @@ -3205,14 +3083,12 @@ def closeEvent(self, event): return self.is_shutting_down = True + self.playback_manager.stop() self._save_settings() - self._stop_playback_stream() - if self.export_thread and self.export_thread.isRunning(): - self.export_thread.quit() - self.export_thread.wait() event.accept() if __name__ == '__main__': + PluginManager.run_preloads() app = QApplication(sys.argv) project_to_load_on_startup = None if len(sys.argv) > 1: From 47a64b5bd11f73ca3fdb51eb8d7256ffbb929e25 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Tue, 14 Oct 2025 19:18:06 +1100 Subject: [PATCH 60/67] remove failed attempt to make onnxruntime import plugin-based --- plugins.py | 24 ------------------------ plugins/wan2gp/plugin.json | 5 ----- videoeditor.py | 1 - 3 files changed, 30 deletions(-) delete mode 100644 plugins/wan2gp/plugin.json diff --git a/plugins.py b/plugins.py index a4e0699dd..cde026750 100644 --- a/plugins.py +++ b/plugins.py @@ -5,7 +5,6 @@ import shutil import git import json -import importlib from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QLabel, QLineEdit, QMessageBox, QProgressBar, QDialogButtonBox, QWidget, QCheckBox) @@ -34,29 +33,6 @@ def __init__(self, main_app): if not os.path.exists(self.plugins_dir): os.makedirs(self.plugins_dir) - def run_preloads(plugins_dir="plugins"): - print("Running plugin pre-load scan...") - if not os.path.isdir(plugins_dir): - return - - for plugin_name in os.listdir(plugins_dir): - plugin_path = os.path.join(plugins_dir, plugin_name) - manifest_path = os.path.join(plugin_path, 'plugin.json') - if os.path.isfile(manifest_path): - try: - with open(manifest_path, 'r') as f: - manifest = json.load(f) - - preload_modules = manifest.get("preload_modules", []) - if preload_modules: - for module_name in preload_modules: - try: - importlib.import_module(module_name) - except ImportError as e: - print(f" - WARNING: Could not pre-load '{module_name}'. Error: {e}") - except Exception as e: - print(f" - WARNING: Could not read or parse manifest for plugin '{plugin_name}': {e}") - def discover_and_load_plugins(self): for plugin_name in os.listdir(self.plugins_dir): plugin_path = os.path.join(self.plugins_dir, plugin_name) diff --git a/plugins/wan2gp/plugin.json b/plugins/wan2gp/plugin.json deleted file mode 100644 index 47e592c50..000000000 --- a/plugins/wan2gp/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "preload_modules": [ - "onnxruntime" - ] -} \ No newline at end of file diff --git a/videoeditor.py b/videoeditor.py index 21df9e63d..101b2059a 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -3088,7 +3088,6 @@ def closeEvent(self, event): event.accept() if __name__ == '__main__': - PluginManager.run_preloads() app = QApplication(sys.argv) project_to_load_on_startup = None if len(sys.argv) > 1: From f151887f7f7ebe46fe4b1ad1e0a98bb4525a37e5 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Thu, 16 Oct 2025 07:19:30 +1100 Subject: [PATCH 61/67] Update README.md --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 81c93be78..ca7c0161e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Inline AI Video Editor +image# Inline AI Video Editor A simple, non-linear video editor built with Python, PyQt6, and FFmpeg. It provides a multi-track timeline, a media preview window, and basic clip manipulation capabilities, all wrapped in a dockable user interface. The editor is designed to be extensible through a plugin system. @@ -58,9 +58,11 @@ python videoeditor.py ``` ## Screenshots -image -image -image +![sc1](https://github.com/user-attachments/assets/d624100d-5b4d-48f0-85e2-706482d4311a) +![sc2](https://github.com/user-attachments/assets/a9509acd-b423-4313-a0fc-e51479373089) +![sc3](https://github.com/user-attachments/assets/e639d3b4-c2cd-445f-9f00-72597ff70248) + + From 7029b660f743765effd8ced523219f80f636fd3e Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Thu, 16 Oct 2025 07:25:05 +1100 Subject: [PATCH 62/67] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca7c0161e..a37096feb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -image# Inline AI Video Editor +# Inline AI Video Editor A simple, non-linear video editor built with Python, PyQt6, and FFmpeg. It provides a multi-track timeline, a media preview window, and basic clip manipulation capabilities, all wrapped in a dockable user interface. The editor is designed to be extensible through a plugin system. From ad19ab47f5ed3ac15f076435c6592119a13e01e8 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 17 Oct 2025 12:58:57 +1100 Subject: [PATCH 63/67] use icons for multimedia controls --- icons/pause.svg | 8 +++++ icons/play.svg | 7 +++++ icons/previous_frame.svg | 7 +++++ icons/snap_to_start.svg | 8 +++++ icons/stop.svg | 5 +++ videoeditor.py | 66 ++++++++++++++++++++++++++++++++-------- 6 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 icons/pause.svg create mode 100644 icons/play.svg create mode 100644 icons/previous_frame.svg create mode 100644 icons/snap_to_start.svg create mode 100644 icons/stop.svg diff --git a/icons/pause.svg b/icons/pause.svg new file mode 100644 index 000000000..df6b56823 --- /dev/null +++ b/icons/pause.svg @@ -0,0 +1,8 @@ + + Pause + + + + + + \ No newline at end of file diff --git a/icons/play.svg b/icons/play.svg new file mode 100644 index 000000000..bb3a02a8f --- /dev/null +++ b/icons/play.svg @@ -0,0 +1,7 @@ + + Play + + + + + \ No newline at end of file diff --git a/icons/previous_frame.svg b/icons/previous_frame.svg new file mode 100644 index 000000000..63bae024e --- /dev/null +++ b/icons/previous_frame.svg @@ -0,0 +1,7 @@ + + Previous frame + + + + + \ No newline at end of file diff --git a/icons/snap_to_start.svg b/icons/snap_to_start.svg new file mode 100644 index 000000000..3624c85cc --- /dev/null +++ b/icons/snap_to_start.svg @@ -0,0 +1,8 @@ + + Snap to first frame + + + + + + \ No newline at end of file diff --git a/icons/stop.svg b/icons/stop.svg new file mode 100644 index 000000000..7f51b9433 --- /dev/null +++ b/icons/stop.svg @@ -0,0 +1,5 @@ + + Stop + + + \ No newline at end of file diff --git a/videoeditor.py b/videoeditor.py index 101b2059a..4473d9cc0 100644 --- a/videoeditor.py +++ b/videoeditor.py @@ -15,7 +15,7 @@ QListWidget, QListWidgetItem, QMessageBox, QComboBox, QFormLayout, QGroupBox, QLineEdit, QSlider) from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, - QPixmap, QImage, QDrag, QCursor, QKeyEvent) + QPixmap, QImage, QDrag, QCursor, QKeyEvent, QIcon, QTransform) from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, pyqtSignal, QTimer, QByteArray, QMimeData) @@ -1886,9 +1886,8 @@ def _setup_ui(self): self.splitter = QSplitter(Qt.Orientation.Vertical) - # --- PREVIEW WIDGET SETUP (CHANGED) --- self.preview_scroll_area = QScrollArea() - self.preview_scroll_area.setWidgetResizable(False) # Important for 1:1 scaling + self.preview_scroll_area.setWidgetResizable(False) self.preview_scroll_area.setStyleSheet("background-color: black; border: 0px;") self.preview_widget = QLabel() @@ -1914,12 +1913,55 @@ def _setup_ui(self): controls_widget = QWidget() controls_layout = QHBoxLayout(controls_widget) controls_layout.setContentsMargins(0, 5, 0, 5) - self.play_pause_button = QPushButton("Play") - self.stop_button = QPushButton("Stop") - self.frame_back_button = QPushButton("<") - self.frame_forward_button = QPushButton(">") - self.snap_back_button = QPushButton("|<") - self.snap_forward_button = QPushButton(">|") + + icon_dir = "icons" + icon_size = QSize(32, 32) + button_size = QSize(40, 40) + + self.play_icon = QIcon(os.path.join(icon_dir, "play.svg")) + self.pause_icon = QIcon(os.path.join(icon_dir, "pause.svg")) + stop_icon = QIcon(os.path.join(icon_dir, "stop.svg")) + back_pixmap = QPixmap(os.path.join(icon_dir, "previous_frame.svg")) + snap_back_pixmap = QPixmap(os.path.join(icon_dir, "snap_to_start.svg")) + + transform = QTransform().rotate(180) + + frame_forward_icon = QIcon(back_pixmap.transformed(transform)) + snap_forward_icon = QIcon(snap_back_pixmap.transformed(transform)) + frame_back_icon = QIcon(back_pixmap) + snap_back_icon = QIcon(snap_back_pixmap) + + self.play_pause_button = QPushButton() + self.play_pause_button.setIcon(self.play_icon) + self.play_pause_button.setToolTip("Play/Pause") + + self.stop_button = QPushButton() + self.stop_button.setIcon(stop_icon) + self.stop_button.setToolTip("Stop") + + self.frame_back_button = QPushButton() + self.frame_back_button.setIcon(frame_back_icon) + self.frame_back_button.setToolTip("Previous Frame (Left Arrow)") + + self.frame_forward_button = QPushButton() + self.frame_forward_button.setIcon(frame_forward_icon) + self.frame_forward_button.setToolTip("Next Frame (Right Arrow)") + + self.snap_back_button = QPushButton() + self.snap_back_button.setIcon(snap_back_icon) + self.snap_back_button.setToolTip("Snap to Previous Clip Edge") + + self.snap_forward_button = QPushButton() + self.snap_forward_button.setIcon(snap_forward_icon) + self.snap_forward_button.setToolTip("Snap to Next Clip Edge") + + button_list = [self.snap_back_button, self.frame_back_button, self.play_pause_button, + self.stop_button, self.frame_forward_button, self.snap_forward_button] + for btn in button_list: + btn.setIconSize(icon_size) + btn.setFixedSize(button_size) + btn.setStyleSheet("QPushButton { border: none; background-color: transparent; }") + controls_layout.addStretch() controls_layout.addWidget(self.snap_back_button) controls_layout.addWidget(self.frame_back_button) @@ -2364,13 +2406,13 @@ def toggle_playback(self): self.playback_manager.play(current_pos) def _on_playback_started(self): - self.play_pause_button.setText("Pause") + self.play_pause_button.setIcon(self.pause_icon) def _on_playback_paused(self): - self.play_pause_button.setText("Play") + self.play_pause_button.setIcon(self.play_icon) def _on_playback_stopped(self): - self.play_pause_button.setText("Play") + self.play_pause_button.setIcon(self.play_icon) def stop_playback(self): self.playback_manager.stop() From 08a7e00ce3a2336205ec03f835e25f594f09564d Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 17 Oct 2025 13:00:12 +1100 Subject: [PATCH 64/67] Update README.md --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a37096feb..a97c6b402 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,9 @@ python videoeditor.py ``` ## Screenshots -![sc1](https://github.com/user-attachments/assets/d624100d-5b4d-48f0-85e2-706482d4311a) -![sc2](https://github.com/user-attachments/assets/a9509acd-b423-4313-a0fc-e51479373089) -![sc3](https://github.com/user-attachments/assets/e639d3b4-c2cd-445f-9f00-72597ff70248) - - +![sc1_](https://github.com/user-attachments/assets/98247de8-613d-418a-b71e-fdf2d6b547f4) +![sc2_](https://github.com/user-attachments/assets/41c3f885-2fa9-4a81-911c-68c13da1e97b) +![sc3_](https://github.com/user-attachments/assets/ec129087-03f5-43e5-a102-934efc62b001) From b5c7a740b71c344383e1003b0446150aa86671ed Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 17 Oct 2025 20:01:23 +1100 Subject: [PATCH 65/67] fix configuration --- plugins/wan2gp/main.py | 120 +++++++++++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 28 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 2a38230dd..95d0beeb3 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -827,6 +827,10 @@ def _create_general_config_tab(self): self._create_config_combo(form, "Video Dimensions:", "fit_canvas", [("Dimensions are Pixels Budget", 0), ("Dimensions are Max Width/Height", 1), ("Dimensions are Output Width/Height (Cropped)", 2)], 0) self._create_config_combo(form, "Attention Type:", "attention_mode", [("Auto (Recommended)", "auto"), ("SDPA", "sdpa"), ("Flash", "flash"), ("Xformers", "xformers"), ("Sage", "sage"), ("Sage2/2++", "sage2")], "auto") self._create_config_combo(form, "Metadata Handling:", "metadata_type", [("Embed in file (Exif/Comment)", "metadata"), ("Export separate JSON", "json"), ("None", "none")], "metadata") + checkbox = QCheckBox() + checkbox.setChecked(wgp.server_config.get("embed_source_images", False)) + self.widgets['config_embed_source_images'] = checkbox + form.addRow("Embed Source Images in MP4:", checkbox) self._create_config_checklist(form, "RAM Loading Policy:", "preload_model_policy", [("Preload on App Launch", "P"), ("Preload on Model Switch", "S"), ("Unload when Queue is Done", "U")], []) self._create_config_combo(form, "Keep Previous Videos:", "clear_file_list", [("None", 0), ("Keep last video", 1), ("Keep last 5", 5), ("Keep last 10", 10), ("Keep last 20", 20), ("Keep last 30", 30)], 5) self._create_config_combo(form, "Display RAM/VRAM Stats:", "display_stats", [("Disabled", 0), ("Enabled", 1)], 0) @@ -839,6 +843,7 @@ def _create_general_config_tab(self): self.widgets['config_checkpoints_paths'] = checkpoints_textbox form.addRow("Checkpoints Paths:", checkpoints_textbox) self._create_config_combo(form, "UI Theme (requires restart):", "UI_theme", [("Blue Sky", "default"), ("Classic Gradio", "gradio")], "default") + self._create_config_combo(form, "Queue Color Scheme:", "queue_color_scheme", [("Pastel (Unique color per item)", "pastel"), ("Alternating Grey Shades", "alternating_grey")], "pastel") return tab def _create_performance_config_tab(self): @@ -870,8 +875,9 @@ def _create_outputs_config_tab(self): tab, form = self._create_scrollable_form_tab() self._create_config_combo(form, "Video Codec:", "video_output_codec", [("x265 Balanced", 'libx265_28'), ("x264 Balanced", 'libx264_8'), ("x265 High Quality", 'libx265_8'), ("x264 High Quality", 'libx264_10'), ("x264 Lossless", 'libx264_lossless')], 'libx264_8') self._create_config_combo(form, "Image Codec:", "image_output_codec", [("JPEG Q85", 'jpeg_85'), ("WEBP Q85", 'webp_85'), ("JPEG Q95", 'jpeg_95'), ("WEBP Q95", 'webp_95'), ("WEBP Lossless", 'webp_lossless'), ("PNG Lossless", 'png')], 'jpeg_95') - self._create_config_textbox(form, "Video Output Folder:", "save_path", "outputs") - self._create_config_textbox(form, "Image Output Folder:", "image_save_path", "outputs") + self._create_config_combo(form, "Audio Codec:", "audio_output_codec", [("AAC 128 kbit", 'aac_128')], 'aac_128') + self._create_config_textbox(form, "Video Output Folder:", "save_path", wgp.server_config.get("save_path", "outputs")) + self._create_config_textbox(form, "Image Output Folder:", "image_save_path", wgp.server_config.get("image_save_path", "outputs")) return tab def _create_notifications_config_tab(self): @@ -1419,7 +1425,8 @@ def collect_inputs(self): full_inputs = wgp.get_current_model_settings(self.state).copy() full_inputs['lset_name'] = "" full_inputs['image_mode'] = 0 - expected_keys = { "audio_guide": None, "audio_guide2": None, "image_guide": None, "image_mask": None, "speakers_locations": "", "frames_positions": "", "keep_frames_video_guide": "", "keep_frames_video_source": "", "video_guide_outpainting": "", "switch_threshold2": 0, "model_switch_phase": 1, "batch_size": 1, "control_net_weight_alt": 1.0, "image_refs_relative_size": 50, } + full_inputs['mode'] = "" + expected_keys = { "audio_guide": None, "audio_guide2": None, "image_guide": None, "image_mask": None, "speakers_locations": "", "frames_positions": "", "keep_frames_video_guide": "", "keep_frames_video_source": "", "video_guide_outpainting": "", "switch_threshold2": 0, "model_switch_phase": 1, "batch_size": 1, "control_net_weight_alt": 1.0, "image_refs_relative_size": 50, "embedded_guidance_scale": None, "model_mode": None, "control_net_weight": 1.0, "control_net_weight2": 1.0, "mask_expand": 0, "remove_background_images_ref": 0, "prompt_enhancer": ""} for key, default_value in expected_keys.items(): if key not in full_inputs: full_inputs[key] = default_value full_inputs['prompt'] = self.widgets['prompt'].toPlainText() @@ -1633,33 +1640,90 @@ def _on_release_ram(self): QMessageBox.information(self, "RAM Released", "Models stored in RAM have been released.") def _on_apply_config_changes(self): - changes = {} - list_widget = self.widgets['config_transformer_types'] - changes['transformer_types_choices'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] - list_widget = self.widgets['config_preload_model_policy'] - changes['preload_model_policy_choice'] = [item.data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] - changes['model_hierarchy_type_choice'] = self.widgets['config_model_hierarchy_type'].currentData() - changes['checkpoints_paths'] = self.widgets['config_checkpoints_paths'].toPlainText() - for key in ["fit_canvas", "attention_mode", "metadata_type", "clear_file_list", "display_stats", "max_frames_multiplier", "UI_theme"]: - changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - for key in ["transformer_quantization", "transformer_dtype_policy", "mixed_precision", "text_encoder_quantization", "vae_precision", "compile", "depth_anything_v2_variant", "vae_config", "boost", "profile"]: - changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - changes['preload_in_VRAM_choice'] = self.widgets['config_preload_in_VRAM'].value() - for key in ["enhancer_enabled", "enhancer_mode", "mmaudio_enabled"]: - changes[f'{key}_choice'] = self.widgets[f'config_{key}'].currentData() - for key in ["video_output_codec", "image_output_codec", "save_path", "image_save_path"]: - widget = self.widgets[f'config_{key}'] - changes[f'{key}_choice'] = widget.currentData() if isinstance(widget, QComboBox) else widget.text() - changes['notification_sound_enabled_choice'] = self.widgets['config_notification_sound_enabled'].currentData() - changes['notification_sound_volume_choice'] = self.widgets['config_notification_sound_volume'].value() - changes['last_resolution_choice'] = self.widgets['resolution'].currentData() + if wgp.args.lock_config: + self.config_status_label.setText("Configuration is locked by command-line arguments.") + return + if self.thread and self.thread.isRunning(): + self.config_status_label.setText("Cannot change config while a generation is in progress.") + return + try: - msg, header_mock, family_mock, base_type_mock, choice_mock, refresh_trigger = wgp.apply_changes(self.state, **changes) - self.config_status_label.setText("Changes applied successfully. Some settings may require a restart.") + ui_settings = {} + list_widget = self.widgets['config_transformer_types'] + ui_settings['transformer_types'] = [list_widget.item(i).data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] + list_widget = self.widgets['config_preload_model_policy'] + ui_settings['preload_model_policy'] = [list_widget.item(i).data(Qt.ItemDataRole.UserRole) for i in range(list_widget.count()) if list_widget.item(i).checkState() == Qt.CheckState.Checked] + + ui_settings['model_hierarchy_type'] = self.widgets['config_model_hierarchy_type'].currentData() + ui_settings['fit_canvas'] = self.widgets['config_fit_canvas'].currentData() + ui_settings['attention_mode'] = self.widgets['config_attention_mode'].currentData() + ui_settings['metadata_type'] = self.widgets['config_metadata_type'].currentData() + ui_settings['clear_file_list'] = self.widgets['config_clear_file_list'].currentData() + ui_settings['display_stats'] = self.widgets['config_display_stats'].currentData() + ui_settings['max_frames_multiplier'] = self.widgets['config_max_frames_multiplier'].currentData() + ui_settings['checkpoints_paths'] = [p.strip() for p in self.widgets['config_checkpoints_paths'].toPlainText().replace("\r", "").split("\n") if p.strip()] + ui_settings['UI_theme'] = self.widgets['config_UI_theme'].currentData() + ui_settings['queue_color_scheme'] = self.widgets['config_queue_color_scheme'].currentData() + + ui_settings['transformer_quantization'] = self.widgets['config_transformer_quantization'].currentData() + ui_settings['transformer_dtype_policy'] = self.widgets['config_transformer_dtype_policy'].currentData() + ui_settings['mixed_precision'] = self.widgets['config_mixed_precision'].currentData() + ui_settings['text_encoder_quantization'] = self.widgets['config_text_encoder_quantization'].currentData() + ui_settings['vae_precision'] = self.widgets['config_vae_precision'].currentData() + ui_settings['compile'] = self.widgets['config_compile'].currentData() + ui_settings['depth_anything_v2_variant'] = self.widgets['config_depth_anything_v2_variant'].currentData() + ui_settings['vae_config'] = self.widgets['config_vae_config'].currentData() + ui_settings['boost'] = self.widgets['config_boost'].currentData() + ui_settings['profile'] = self.widgets['config_profile'].currentData() + ui_settings['preload_in_VRAM'] = self.widgets['config_preload_in_VRAM'].value() + + ui_settings['enhancer_enabled'] = self.widgets['config_enhancer_enabled'].currentData() + ui_settings['enhancer_mode'] = self.widgets['config_enhancer_mode'].currentData() + ui_settings['mmaudio_enabled'] = self.widgets['config_mmaudio_enabled'].currentData() + + ui_settings['video_output_codec'] = self.widgets['config_video_output_codec'].currentData() + ui_settings['image_output_codec'] = self.widgets['config_image_output_codec'].currentData() + ui_settings['audio_output_codec'] = self.widgets['config_audio_output_codec'].currentData() + ui_settings['embed_source_images'] = self.widgets['config_embed_source_images'].isChecked() + ui_settings['save_path'] = self.widgets['config_save_path'].text() + ui_settings['image_save_path'] = self.widgets['config_image_save_path'].text() + + ui_settings['notification_sound_enabled'] = self.widgets['config_notification_sound_enabled'].currentData() + ui_settings['notification_sound_volume'] = self.widgets['config_notification_sound_volume'].value() + + ui_settings['last_model_type'] = self.state["model_type"] + ui_settings['last_model_per_family'] = self.state["last_model_per_family"] + ui_settings['last_model_per_type'] = self.state["last_model_per_type"] + ui_settings['last_advanced_choice'] = self.state["advanced"] + ui_settings['last_resolution_choice'] = self.widgets['resolution'].currentData() + ui_settings['last_resolution_per_group'] = self.state["last_resolution_per_group"] + + wgp.server_config.update(ui_settings) + + wgp.fl.set_checkpoints_paths(ui_settings['checkpoints_paths']) + wgp.three_levels_hierarchy = ui_settings["model_hierarchy_type"] == 1 + wgp.attention_mode = ui_settings["attention_mode"] + wgp.default_profile = ui_settings["profile"] + wgp.compile = ui_settings["compile"] + wgp.text_encoder_quantization = ui_settings["text_encoder_quantization"] + wgp.vae_config = ui_settings["vae_config"] + wgp.boost = ui_settings["boost"] + wgp.save_path = ui_settings["save_path"] + wgp.image_save_path = ui_settings["image_save_path"] + wgp.preload_model_policy = ui_settings["preload_model_policy"] + wgp.transformer_quantization = ui_settings["transformer_quantization"] + wgp.transformer_dtype_policy = ui_settings["transformer_dtype_policy"] + wgp.transformer_types = ui_settings["transformer_types"] + wgp.reload_needed = True + + with open(wgp.server_config_filename, "w", encoding="utf-8") as writer: + json.dump(wgp.server_config, writer, indent=4) + + self.config_status_label.setText("Settings saved successfully. Restart may be required for some changes.") self.header_info.setText(wgp.generate_header(self.state['model_type'], wgp.compile, wgp.attention_mode)) - if family_mock.choices is not None or choice_mock.choices is not None: - self.update_model_dropdowns(wgp.transformer_type) - self.refresh_ui_from_model_change(wgp.transformer_type) + self.update_model_dropdowns(wgp.transformer_type) + self.refresh_ui_from_model_change(wgp.transformer_type) + except Exception as e: self.config_status_label.setText(f"Error applying changes: {e}") import traceback; traceback.print_exc() From 73eac71c0e3963b4151dba1018ac2778bbcc6e43 Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 17 Oct 2025 23:28:50 +1100 Subject: [PATCH 66/67] fix queue index problems in plugin --- plugins/wan2gp/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 95d0beeb3..0fe492479 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -179,7 +179,6 @@ def dropEvent(self, event: QDropEvent): source_row = self.currentRow() target_item = self.itemAt(event.position().toPoint()) dest_row = target_item.row() if target_item else self.rowCount() - if source_row < dest_row: dest_row -=1 if source_row != dest_row: self.rowsMoved.emit(source_row, dest_row) event.acceptProposedAction() else: From f9f4bd1342fe9bd3a7a902a0b0dac1d63ee2492e Mon Sep 17 00:00:00 2001 From: Chris Malone Date: Fri, 17 Oct 2025 23:50:04 +1100 Subject: [PATCH 67/67] fix queue width --- plugins/wan2gp/main.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py index 0fe492479..f91236d0c 100644 --- a/plugins/wan2gp/main.py +++ b/plugins/wan2gp/main.py @@ -563,9 +563,18 @@ def setup_generator_tab(self): results_layout.addWidget(self.results_list) right_layout.addWidget(results_group) - right_layout.addWidget(QLabel("Queue:")) + right_layout.addWidget(QLabel("Queue")) self.queue_table = self.create_widget(QueueTableWidget, 'queue_table') + self.queue_table.verticalHeader().setVisible(False) + self.queue_table.setColumnCount(4) + self.queue_table.setHorizontalHeaderLabels(["Qty", "Prompt", "Length", "Steps"]) + header = self.queue_table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) right_layout.addWidget(self.queue_table) + queue_btn_layout = QHBoxLayout() self.remove_queue_btn = self.create_widget(QPushButton, 'remove_queue_btn', "Remove Selected") self.clear_queue_btn = self.create_widget(QPushButton, 'clear_queue_btn', "Clear Queue") @@ -1594,14 +1603,10 @@ def update_queue_table(self): table_data = wgp.get_queue_table(queue_to_display) self.queue_table.setRowCount(0) self.queue_table.setRowCount(len(table_data)) - self.queue_table.setColumnCount(4) - self.queue_table.setHorizontalHeaderLabels(["Qty", "Prompt", "Length", "Steps"]) for row_idx, row_data in enumerate(table_data): prompt_text = str(row_data[1]).split('>')[1].split('<')[0] if '>' in str(row_data[1]) else str(row_data[1]) for col_idx, cell_data in enumerate([row_data[0], prompt_text, row_data[2], row_data[3]]): self.queue_table.setItem(row_idx, col_idx, QTableWidgetItem(str(cell_data))) - self.queue_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - self.queue_table.resizeColumnsToContents() def _on_remove_selected_from_queue(self): selected_row = self.queue_table.currentRow()