diff --git a/README.md b/README.md index 365b31183..a97c6b402 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,70 @@ -# WanGP +# Inline AI Video Editor ------ -
-WanGP by DeepBeepMeep : The best Open Source Video Generative Models Accessible to the GPU Poor -
+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. -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 Nvidia GPUs (RTX 10XX, 20xx, ...) -- Support for AMD GPUs Radeon RX 76XX, 77XX, 78XX & 79XX, instructions in the Installation Section Below. -- 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 +## Features -**Discord Server to get Help from Other Users and show your Best Videos:** https://discord.gg/g7efUW9jGV +- **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**: + - 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. + - 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**: + - 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.. -**Follow DeepBeepMeep on Twitter/X to get the Latest News**: https://x.com/deepbeepmeep +## Installation -## 🔥 Latest Updates : -### October 6 2025: WanGP v8.994 - A few last things before the Big Unknown ... -This new version hasn't any new model... +#### **Windows** -...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 -conda create -n wan2gp python=3.10.9 -conda activate wan2gp +git checkout video_editor +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 ``` -**Run the application:** -```bash -python wgp.py -``` +#### **Linux / macOS** -**Update the application:** -If using Pinokio use Pinokio to update otherwise: -Get in the directory where WanGP is installed and: -```bash -git pull -conda activate wan2gp +``` +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 ``` -## 🐳 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 - -### Nvidia -For detailed installation instructions for different GPU generations: -- **[Installation Guide](docs/INSTALLATION.md)** - Complete setup instructions for RTX 10XX to RTX 50XX - -### AMD -For detailed installation instructions for different GPU generations: -- **[Installation Guide](docs/AMD-INSTALLATION.md)** - Complete setup instructions for Radeon RX 76XX, 77XX, 78XX & 79XX - -## 🎯 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 +## Screenshots + + + -### 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 ---- --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 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/icons/pause.svg b/icons/pause.svg new file mode 100644 index 000000000..df6b56823 --- /dev/null +++ b/icons/pause.svg @@ -0,0 +1,8 @@ + \ 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 @@ + \ 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 @@ + \ 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 @@ + \ 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 @@ + \ 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 new file mode 100644 index 000000000..cde026750 --- /dev/null +++ b/plugins.py @@ -0,0 +1,252 @@ +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: + def __init__(self, app_instance): + self.app = app_instance + self.name = "Unnamed Plugin" + self.description = "No description provided." + + def initialize(self): + pass + + def enable(self): + pass + + def disable(self): + pass + +class PluginManager: + def __init__(self, main_app): + self.app = main_app + self.plugins_dir = "plugins" + self.plugins = {} + if not os.path.exists(self.plugins_dir): + os.makedirs(self.plugins_dir) + + 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) + 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}") + import traceback + traceback.print_exc() + + def load_enabled_plugins_from_settings(self, enabled_plugins_list): + for name in enabled_plugins_list: + if name in self.plugins: + self.enable_plugin(name) + + def get_enabled_plugin_names(self): + return [name for name, data in self.plugins.items() if data['enabled']] + + def enable_plugin(self, name): + 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): + 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): + 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) + + self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) + layout.addWidget(self.button_box) + + 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() + self.populate_list() \ No newline at end of file diff --git a/plugins/wan2gp/main.py b/plugins/wan2gp/main.py new file mode 100644 index 000000000..f91236d0c --- /dev/null +++ b/plugins/wan2gp/main.py @@ -0,0 +1,2123 @@ +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 --- + +wgp = None +import ffmpeg + +from PyQt6.QtWidgets import ( + QApplication, 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_ms, _ = self.plugin.active_region + timeline = self.app.timeline_widget + video_rect, audio_rect = None, None + 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) + 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: 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.state = state + self._is_running = True + self._last_progress_phase = None + self._last_preview = None + + 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 WgpDesktopPluginWidget(QWidget): + def __init__(self, plugin): + super().__init__() + self.plugin = plugin + 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_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_edit) + 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_panel.setMinimumWidth(628) + 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:")) + 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:")) + 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() + 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) + 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") + 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') + 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") + 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) + 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 = 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") + 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) + 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") + 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): + 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_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): + 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) + 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): + 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 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) + + 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.""" + 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 = 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 = 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 + 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 = 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 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 = 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 = wgp.get_current_model_settings(self.state).copy() + full_inputs['lset_name'] = "" + full_inputs['image_mode'] = 0 + 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() + 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 + 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.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 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)) + 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))) + + 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): + 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: + 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)) + 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() + +class Plugin(VideoEditorPlugin): + def initialize(self): + self.name = "AI Generator" + self.description = "Uses the integrated Wan2GP library to generate video clips." + self.client_widget = None + self.dock_widget = 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: + 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 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) + 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 + 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.") + + def on_timeline_context_menu(self, menu, event): + region = self.app.timeline_widget.get_region_at_pos(event.pos()) + if region: + menu.addSeparator() + 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") + 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)") + 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 = 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) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") + + 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 + + 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") + 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_ms = end_ms - start_ms + 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) + widgets = self.client_widget.widgets + + 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) + widgets['image_end_checkbox'].setChecked(True) + widgets['image_start'].setText(self.start_frame_path) + 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 images: {e}") + self._cleanup_temp_dir() + return + 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_() + + 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 = 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) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") + + 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 + + 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_ms = end_ms - start_ms + 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) + 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_ms / 1000.0:.2f}s.") + self.dock_widget.show() + 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 = 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) + self.client_widget._on_model_changed() + else: + print(f"Warning: Default model '{model_to_set}' not found for AI Joiner. Using current model.") + + 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 + + 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_ms = end_ms - start_ms + 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) + widgets = self.client_widget.widgets + + widgets['video_length'].setValue(video_length_frames) + + 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: + 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_ms / 1000.0:.2f}s.") + self.dock_widget.show() + 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 = 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) + self.client_widget._on_model_changed() + 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_ms, end_ms = region + duration_ms = end_ms - start_ms + 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) + + 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_ms / 1000.0:.2f}s to {end_ms / 1000.0:.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_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_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_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_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_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) + 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 diff --git a/requirements.txt b/requirements.txt index 4fa1e10f0..fa8a71cd2 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/undo.py b/undo.py new file mode 100644 index 000000000..b43f4fb46 --- /dev/null +++ b/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 diff --git a/videoeditor.py b/videoeditor.py new file mode 100644 index 000000000..4473d9cc0 --- /dev/null +++ b/videoeditor.py @@ -0,0 +1,3142 @@ +import onnxruntime +import sys +import os +import uuid +import subprocess +import re +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, QSlider) +from PyQt6.QtGui import (QPainter, QColor, QPen, QFont, QFontMetrics, QMouseEvent, QAction, + QPixmap, QImage, QDrag, QCursor, QKeyEvent, QIcon, QTransform) +from PyQt6.QtCore import (Qt, QPoint, QRect, QRectF, QSize, QPointF, QObject, QThread, + pyqtSignal, QTimer, QByteArray, QMimeData) + +from undo import UndoStack, TimelineStateChangeCommand, MoveClipsCommand +from playback import PlaybackManager +from encoding import Encoder + +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_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_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_ms(self): + return self.timeline_start_ms + self.duration_ms + +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_ms) + + def get_total_duration(self): + if not self.clips: return 0 + return max(c.timeline_end_ms for c in self.clips) + +class TimelineWidget(QWidget): + TIMESCALE_HEIGHT = 30 + HEADER_WIDTH = 120 + 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) + delete_clips_requested = pyqtSignal(list) + playhead_moved = pyqtSignal(int) + 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') + + def __init__(self, timeline_model, settings, project_fps, parent=None): + super().__init__(parent) + self.timeline = timeline_model + self.settings = settings + self.playhead_pos_ms = 0 + self.view_start_ms = 0 + self.panning = False + self.pan_start_pos = QPoint() + self.pan_start_view_ms = 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) + + 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 + self.creating_selection_region = False + self.dragging_selection_region = None + self.drag_start_pos = QPoint() + self.drag_original_clip_states = {} + self.selection_drag_start_ms = 0 + self.drag_selection_start_values = None + self.drag_start_state = None + + self.resizing_clip = 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() + 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 + 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_ms = (self.project_fps * 20) / 1000.0 + 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(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) + painter.fillRect(self.rect(), QColor("#333")) + + self.draw_headers(painter) + + 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) + + if self.drag_over_active: + painter.setPen(QColor(0, 255, 0, 150)) + 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) + + 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) + + painter.restore() + + total_height = self.calculate_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): + 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 _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) + + if h > 0: return f"{sign}{h}h:{m:02d}m" + + total_minutes = int(seconds / 60) + return f"{sign}{total_minutes}m" + + 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) + + frame_dur_ms = 1000.0 / self.project_fps + intervals_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 + ] + + min_pixel_dist = 70 + 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_ms > 10: + minor_interval = major_interval / divisor + break + + start_ms = self.x_to_ms(self.HEADER_WIDTH) + end_ms = self.x_to_ms(self.width()) + + 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_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_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_ms / major_interval) + end_major_tick = int(end_ms / major_interval) + 1 + for i in range(start_major_tick, end_major_tick + 1): + 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_ms, major_interval) + label_width = font_metrics.horizontalAdvance(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() + + 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.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) + + 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, 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: + 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, int(y), self.width() - self.HEADER_WIDTH, self.TRACK_HEIGHT) + painter.fillRect(highlight_rect, QColor(255, 255, 0, 40)) + + for clip in self.timeline.clips: + clip_rect = self.get_clip_rect(clip) + base_color = QColor("#46A") + if clip.media_type == 'image': + base_color = QColor("#4A6") + elif clip.track_type == '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) + + 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) + 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_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.ms_to_x(self.playhead_pos_ms) + painter.setPen(QPen(QColor("red"), 2)) + painter.drawLine(playhead_x, 0, playhead_x, self.height()) + + def y_to_track_info(self, y): + if self.TIMESCALE_HEIGHT <= y < self.video_tracks_y_start: + return ('video', self.timeline.num_video_tracks + 1) + + 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) + + 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 _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: + return self._snap_to_frame(time_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_ms = self.x_to_ms(pos.x()) + for region in reversed(self.selection_regions): + 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_ms + + if delta > 0: + new_pps = old_pps * zoom_factor + else: + new_pps = old_pps / zoom_factor + + 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_ms = self.view_start_ms * (old_pps / new_pps) + else: + mouse_x = event.position().x() + 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_ms = new_pps + self.view_start_ms = int(max(0, new_view_start_ms)) + + self.update() + event.accept() + + def mousePressEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.MiddleButton: + self.panning = True + self.pan_start_pos = event.pos() + self.pan_start_view_ms = self.view_start_ms + 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') + elif self.remove_audio_track_btn_rect.contains(event.pos()): self.remove_track.emit('audio') + return + + if event.button() == Qt.MouseButton.LeftButton: + self.setFocus() + + self.dragging_clip = None + self.dragging_linked_clip = None + self.dragging_playhead = False + self.creating_selection_region = False + self.dragging_selection_region = None + 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.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 + 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) + 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 + + clicked_clip = None + for clip in reversed(self.timeline.clips): + 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[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_ms, self.dragging_linked_clip.track_index) + self.drag_start_pos = event.pos() + + 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 + 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 + 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: + 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())) + 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 + + 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_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_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': + new_start = current_ms + new_end = original_end + else: # right + new_start = original_start + new_end = current_ms + + 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: + 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 + min_duration_ms = int(1000 / self.project_fps) + snap_time_delta = self.SNAP_THRESHOLD_PIXELS / self.pixels_per_ms + + 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_ms) + snap_points.append(clip.timeline_end_ms) + + media_props = self.window().media_properties.get(self.resizing_clip.source_path) + 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_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 + + 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 + + new_start_ms = max(0, new_start_ms) + + if self.resizing_clip.media_type != 'image': + 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_ms + new_clip_start = original_clip_start + (new_start_ms - original_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_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_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 + + 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 + + if new_duration < min_duration_ms: + new_duration = min_duration_ms + + if self.resizing_clip.media_type != 'image': + 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_ms = int(new_duration) + if linked_clip: + 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.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) + cursor_set = True + + if not cursor_set and is_in_track_area: + for region in self.selection_regions: + 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) + cursor_set = True + break + if not cursor_set: + 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_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] + self.update() + return + + if self.dragging_selection_region: + delta_x = event.pos().x() - self.drag_start_pos.x() + time_delta = int(delta_x / self.pixels_per_ms) + + original_start, original_end = self.drag_selection_start_values + 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 + + self.update() + return + + if self.dragging_playhead: + time_ms = max(0, self.x_to_ms(event.pos().x())) + 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: + self.highlighted_track_info = None + self.highlighted_ghost_track_info = None + new_track_info = self.y_to_track_info(event.pos().y()) + + 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 + + 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 + + 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 + + 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 + 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_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_ms + if movement_direction > 0: + new_start_time = other_clip.timeline_start_ms - self.dragging_clip.duration_ms + else: + new_start_time = other_clip.timeline_end_ms + break + + final_start_time = max(0, new_start_time) + self.dragging_clip.timeline_start_ms = int(final_start_time) + if self.dragging_linked_clip: + self.dragging_linked_clip.timeline_start_ms = int(final_start_time) + + 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 + 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) + 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: + start, end = self.selection_regions[-1] + if (end - start) * self.pixels_per_ms < 2: + self.clear_all_regions() + + if self.dragging_selection_region: + self.dragging_selection_region = None + self.drag_selection_start_values = None + + 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_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_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_ms) + 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() + + def dragEnterEvent(self, event): + 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() + + 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() + 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() + + duration_ms = media_props['duration_ms'] + media_type = media_props['media_type'] + has_audio = media_props['has_audio'] + + pos = event.position() + start_ms = self.x_to_ms(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 + else: + self.highlighted_track_info = track_info + + width = int(duration_ms * self.pixels_per_ms) + x = self.ms_to_x(start_ms) + + 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 + + if video_y != -1: + self.drag_over_rect = QRectF(x, video_y, width, self.TRACK_HEIGHT) + if audio_y != -1: + self.drag_over_audio_rect = QRectF(x, audio_y, width, self.TRACK_HEIGHT) + + self.update() + + 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_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_ms + + for file_path in added_files: + media_info = main_window.media_properties.get(file_path) + if not media_info: continue + + duration_ms = media_info['duration_ms'] + 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_ms=current_timeline_pos, + duration_ms=duration_ms, + media_type=media_type, + clip_start_ms=0, + video_track_index=video_track_idx, + audio_track_index=audio_track_idx + ) + current_timeline_pos += duration_ms + + event.acceptProposedAction() + return + + 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_ms = json_data['duration_ms'] + has_audio = json_data['has_audio'] + media_type = json_data['media_type'] + + pos = event.position() + start_ms = self.x_to_ms(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 = 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_ms=start_ms, + duration_ms=duration_ms, + media_type=media_type, + clip_start_ms=0, + video_track_index=video_track_idx, + audio_track_index=audio_track_idx + ) + + def contextMenuEvent(self, event: 'QContextMenuEvent'): + menu = QMenu(self) + + region_at_pos = self.get_region_at_pos(event.pos()) + if region_at_pos: + 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: + 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() + + 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_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)) + + 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() + + 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): + super().__init__(parent) + self.setWindowTitle("Settings") + 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(), + "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): + 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_ms": media_info['duration_ms'], + "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'))) + 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 + self.setAcceptDrops(True) + 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 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: + 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): + 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.undo_stack = UndoStack() + self.media_pool = [] + self.media_properties = {} + self.current_project_path = None + self.last_export_path = None + self.settings = {} + self.settings_file = "settings.json" + self.is_shutting_down = False + self._load_settings() + + 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 + self.project_fps = 50.0 + self.project_width = 1280 + self.project_height = 720 + + # Preview settings + self.scale_to_fit = True + self.current_preview_pixmap = 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.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)) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_preview_display() + + def _get_current_timeline_state(self): + return ( + copy.deepcopy(self.timeline.clips), + 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) + 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_scroll_area = QScrollArea() + self.preview_scroll_area.setWidgetResizable(False) + 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.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_widget.setMinimumHeight(250) + self.splitter.addWidget(self.timeline_widget) + + 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) + main_layout.addWidget(self.splitter, 1) + + controls_widget = QWidget() + controls_layout = QHBoxLayout(controls_widget) + controls_layout.setContentsMargins(0, 5, 0, 5) + + 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) + 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) + + 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) + 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) + + self.managed_widgets = { + 'preview': {'widget': self.preview_scroll_area, 'name': 'Video Preview', '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 = {} + 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) + + 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) + self.timeline_widget.delete_clips_requested.connect(self.delete_clips) + 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) + 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) + + 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.snap_back_button.clicked.connect(lambda: self.snap_playhead(-1)) + self.snap_forward_button.clicked.connect(lambda: self.snap_playhead(1)) + + 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) + + 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) + 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() + 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): + 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 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) + 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_ms + duration_ms = media_info['duration_ms'] + 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_ms=playhead_pos, + duration_ms=duration_ms, + media_type=media_type, + clip_start_ms=0, + video_track_index=video_track, + audio_track_index=audio_track + ) + + 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): + 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 + 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) + 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() + 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 + else: + return + new_state = self._get_current_timeline_state() + + command = TimelineStateChangeCommand(f"Remove {track_type.capitalize()} Track", self.timeline, *old_state, *new_state) + 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() + 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_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_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() + + 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() + + 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(): + 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) + 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)) + + data['action'] = action + self.windows_menu.addAction(action) + + 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 = {} + + if file_ext in ['.png', '.jpg', '.jpeg']: + img = QImage(file_path) + media_info['media_type'] = 'image' + media_info['duration_ms'] = 5000 + 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) + audio_stream = next((s for s in probe['streams'] if s['codec_type'] == 'audio'), None) + + if video_stream: + media_info['media_type'] = 'video' + 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']) + 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' + 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 + + return media_info + except Exception as e: + print(f"Failed to probe file {os.path.basename(file_path)}: {e}") + return None + + def _update_project_properties_from_clip(self, source_path): + try: + 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): + return self._get_media_properties(file_path) + + def get_frame_data_at_time(self, 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: + 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) + ) + return (out, self.project_width, self.project_height) + except ffmpeg.Error as e: + print(f"Error extracting frame data for plugin: {e.stderr}") + return (None, 0, 0) + + 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: + 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 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: + 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.setIcon(self.pause_icon) + + def _on_playback_paused(self): + self.play_pause_button.setIcon(self.play_icon) + + def _on_playback_stopped(self): + self.play_pause_button.setIcon(self.play_icon) + + def stop_playback(self): + self.playback_manager.stop() + self.playback_manager.seek_to_frame(0) + + def step_frame(self, direction): + if not self.timeline.clips: return + self.playback_manager.pause() + frame_duration_ms = 1000.0 / self.project_fps + new_time = self.timeline_widget.playhead_pos_ms + (direction * frame_duration_ms) + 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: + 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.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.playback_manager.seek_to_frame(prev_points[-1]) + elif current_time_ms > 0: + self.playback_manager.seek_to_frame(0) + + def _on_volume_changed(self, value): + self.playback_manager.set_volume(value / 100.0) + + 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 + 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) + 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(): + is_visible = visibility_settings.get(key, False if key == 'project_media' else True) + 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) + self._update_preview_display() + + 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.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.last_export_path = None + 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.clear_all_regions() + self.timeline_widget.update() + 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.") + self.playback_manager.seek_to_frame(0) + + 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_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": { + "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) + 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): + 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.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.last_export_path = project_data.get("last_export_path") + self.timeline_widget.selection_regions = project_data.get("selection_regions", []) + + 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"]): + 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)) + + self.current_project_path = path + self.prune_empty_tracks() + 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}") + + 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 + + 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 + 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): + 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) + + def _add_media_files_to_project(self, file_paths): + if not file_paths: + return [] + + self.media_dock.show() + added_files = [] + + for file_path in file_paths: + self._update_project_properties_from_clip(file_path) + if self._add_media_to_pool(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_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_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_ms + + 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_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: + 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_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: + 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, 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, 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.") + + 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_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) + + 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_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_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() + command = TimelineStateChangeCommand("Add Clip", self.timeline, *old_state, *new_state) + command.undo() + self.undo_stack.push(command) + + 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_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_ms + if not clip_to_split: + 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 + 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) + if linked_clip: + self._split_at_time(linked_clip, playhead_time, new_group_id=new_right_side_group_id) + + 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 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() + + 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() + + new_state = self._get_current_timeline_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() + + 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_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_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_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 + ) + + 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_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, + 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() + + new_state = self._get_current_timeline_state() + 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) + command.undo() + self.undo_stack.push(command) + + def on_split_region(self, region): + def action(): + start_ms, end_ms = region + clips = list(self.timeline.clips) + 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) + + 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_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]) + self.timeline_widget.clear_all_regions() + self._perform_complex_timeline_change("Split All Regions", action) + + def on_join_region(self, region): + def action(): + start_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: return + + 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_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_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove + + 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_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: continue + + 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_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_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove + + 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_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: return + + 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_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_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove + + 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_ms, end_ms = region + duration_to_remove = end_ms - start_ms + if duration_to_remove <= 10: continue + + 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_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_ms >= end_ms: + clip.timeline_start_ms -= duration_to_remove + + 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) + + + def export_video(self): + 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 + + 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 + + project_settings = { + 'width': self.project_width, + 'height': self.project_height, + 'fps': self.project_fps + } + + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + self.status_label.setText("Exporting...") + + self.encoder.start_export(self.timeline, project_settings, export_settings) + + def on_export_finished(self, success, message): + self.status_label.setText(message) + self.progress_bar.setVisible(False) + + 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(dock.setVisible) + 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} + 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): + 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.playback_manager.stop() + self._save_settings() + 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