Compare commits

..

7 Commits

Author SHA1 Message Date
79aa51a21c Refactor display dimension calculations in VideoEditor
This commit simplifies the calculation of display dimensions by utilizing the transformed display frame directly, eliminating redundant width and height calculations based on rotation and crop. It enhances the overall efficiency of the display handling process, ensuring accurate scaling and improved performance during video editing.
2025-09-16 15:07:00 +02:00
5637a9a3e0 Refactor motion tracking and display transformation in VideoEditor
This commit enhances the motion tracking logic by refining how the crop center is adjusted based on tracked points. It introduces a new method, transform_point_for_display, which applies cropping, rotation, and zoom transformations to video coordinates for accurate screen positioning. Additionally, it removes redundant motion tracking offset calculations, streamlining the overall crop and display handling process, thereby improving the user experience during video editing.
2025-09-16 14:47:49 +02:00
81f17953f7 Enhance crop application with motion tracking in VideoEditor
This commit updates the crop functionality to incorporate motion tracking offsets, ensuring that the crop center aligns with tracked points when motion tracking is enabled. It also refines the handling of crop adjustments and display offsets, improving the overall user experience during video editing. Additionally, the key mappings for expanding crop dimensions have been corrected for clarity in user interactions.
2025-09-16 14:43:46 +02:00
04d914834e Refactor crop and motion tracking logic in VideoEditor
This commit simplifies the crop application process by removing unnecessary motion tracking offset calculations in certain areas. It enhances the distance calculation for tracking point removal by switching from screen to video coordinates, improving reliability. Additionally, it updates the HJKL key mapping to ensure visual directions correspond correctly to the current video rotation, enhancing user interaction during editing.
2025-09-16 14:38:48 +02:00
4960812cba Enhance motion tracking functionality in VideoEditor
This commit updates the MotionTracker class to improve the offset calculation for centering crops on tracked points. It modifies the user interaction for adding and removing tracking points, allowing for nearby points to be removed with a right-click. Additionally, it introduces a method to map HJKL keys to directions based on the current rotation, enhancing the crop adjustment experience. The VideoEditor class has been updated to apply these changes, ensuring a more intuitive and responsive editing workflow.
2025-09-16 14:35:42 +02:00
0b007b572e Fix deserialization of tracking points in MotionTracker: convert string keys to integers for proper handling of frame numbers. This ensures accurate data loading from dictionaries during the deserialization process. 2025-09-16 14:30:48 +02:00
f111571601 Add MotionTracker class for enhanced motion tracking in VideoEditor
This commit introduces the MotionTracker class, which manages motion tracking points for crop and pan operations. It allows users to add, remove, and clear tracking points, as well as interpolate positions based on these points. The VideoEditor class has been updated to integrate motion tracking functionality, including user interactions for adding tracking points and toggling tracking on and off. Additionally, the display now reflects motion tracking status and visualizes tracking points on the canvas, improving the editing experience.
2025-09-16 14:26:44 +02:00
10 changed files with 951 additions and 2608 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
__pycache__
croppa/build/lib
croppa/croppa.egg-info
*.log

View File

@@ -1,14 +1,3 @@
module tcleaner
go 1.23.6
require git.site.quack-lab.dev/dave/cylogger v1.4.0
require (
github.com/google/go-cmp v0.5.9 // indirect
github.com/hexops/valast v1.5.0 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/tools v0.4.0 // indirect
mvdan.cc/gofumpt v0.4.0 // indirect
)

View File

@@ -1,28 +0,0 @@
git.site.quack-lab.dev/dave/cylogger v1.4.0 h1:3Ca7V5JWvruARJd5S8xDFwW9LnZ9QInqkYLRdrEFvuY=
git.site.quack-lab.dev/dave/cylogger v1.4.0/go.mod h1:wctgZplMvroA4X6p8f4B/LaCKtiBcT1Pp+L14kcS8jk=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hexops/autogold v0.8.1 h1:wvyd/bAJ+Dy+DcE09BoLk6r4Fa5R5W+O+GUzmR985WM=
github.com/hexops/autogold v0.8.1/go.mod h1:97HLDXyG23akzAoRYJh/2OBs3kd80eHyKPvZw0S5ZBY=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hexops/valast v1.5.0 h1:FBTuvVi0wjTngtXJRZXMbkN/Dn6DgsUsBwch2DUJU8Y=
github.com/hexops/valast v1.5.0/go.mod h1:Jcy1pNH7LNraVaAZDLyv21hHg2WBv9Nf9FL6fGxU7o4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM=
mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ=

View File

@@ -1,7 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Classes\*\shell\Clean name]
@="Clean name"
[HKEY_CURRENT_USER\Software\Classes\*\shell\Clean video name]
@="Clean video name"
[HKEY_CURRENT_USER\Software\Classes\*\shell\Clean name\command]
[HKEY_CURRENT_USER\Software\Classes\*\shell\Clean video name\command]
@="C:\\Users\\administrator\\go\\bin\\tcleaner.exe \"%1\""

View File

@@ -6,71 +6,51 @@ import (
"os"
"path/filepath"
"regexp"
logger "git.site.quack-lab.dev/dave/cylogger"
)
func main() {
flag.Parse()
logger.InitFlag()
if flag.NArg() == 0 {
fmt.Println("Usage: cleaner <files>")
os.Exit(1)
}
// regex to match "2025-07-08"
re := regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
editedRe := regexp.MustCompile(`_edited_\d{5}`)
// regex to match " - 2025-07-08 01h31m45s - "
re := regexp.MustCompile(` - (\d{4}-\d{2}-\d{2} \d{2}h\d{2}m\d{2}s) - `)
for _, file := range flag.Args() {
filelog := logger.Default.WithPrefix(file)
filelog.Info("Processing file")
info, err := os.Stat(file)
if err != nil {
filelog.Error("ERROR: %v\n", err)
fmt.Printf("ERROR: %v\n", err)
continue
}
if info.IsDir() {
filelog.Info("SKIP (directory): %s\n", file)
fmt.Printf("SKIP (directory): %s\n", file)
continue
}
name := filepath.Base(file)
match := re.FindStringSubmatch(name)
filelog.Debug("Match: %v", match)
if match == nil {
filelog.Info("SKIP (no date pattern): %s\n", name)
fmt.Printf("SKIP (no date pattern): %s\n", name)
continue
}
namePart := match[0]
editMatch := editedRe.FindStringSubmatch(name)
filelog.Debug("Edit match: %v", editMatch)
if editMatch != nil {
namePart = namePart + editMatch[0]
filelog.Info("Video has edited part, new name: %s", namePart)
}
newName := namePart + filepath.Ext(name)
filelog.Debug("New name: %s", newName)
newName := match[1] + filepath.Ext(name)
if name == newName {
filelog.Info("SKIP (already named): %s\n", name)
fmt.Printf("SKIP (already named): %s\n", name)
continue
}
filelog.Debug("Checking if target exists: %s", newName)
if _, err := os.Stat(newName); err == nil {
filelog.Info("SKIP (target exists): %s -> %s\n", name, newName)
fmt.Printf("SKIP (target exists): %s -> %s\n", name, newName)
continue
}
filelog.Info("Renaming to: %s", newName)
err = os.Rename(name, newName)
if err != nil {
filelog.Error("ERROR renaming %s: %v\n", name, err)
fmt.Printf("ERROR renaming %s: %v\n", name, err)
} else {
filelog.Info("RENAMED: %s -> %s\n", name, newName)
fmt.Printf("RENAMED: %s -> %s\n", name, newName)
}
filelog.Info("All done")
continue
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"opencv-python>=4.8.0",
"numpy>=1.24.0",
"Pillow>=10.0.0"
"numpy>=1.24.0"
]
[project.scripts]

View File

@@ -1,138 +0,0 @@
# Croppa - Feature Specification
## Overview
Croppa is a lightweight video and image editor that provides real-time editing capabilities with persistent state management.
## Notes:
Note the distinction between lowercase and uppercase keys
Uppercase keys imply shift+key
Note that every transformation (cropping, motion track points) are almost always applied to an already transformed frame
Be that by rotation, cropping, zooming or motion tracking itself
Which means that the user input must be "de-transformed" before being applied to the frame
In other words if we zoom into an area and right click to add a tracking point it must be added to that exact pixel ON THE ORIGINAL FRAME
And NOT the zoomed in / processed frame
Likwise with rotations
All coordinates (crop region, zoom center, motion tracking point) are to be in reference to the original raw unprocessed frame
To then display these points they must be transformed to the processed - display - frame
Likewise when accepting user input from the processed display frame the coordinates must be transformed back to the original raw unprocessed frame
A simple example if we are rotated by 90 degrees and click on the top left corner of the display frame
That coordinate is to be mapped to the bottom left corner of the original raw unprocessed frame
The input to the editor is either a list of video files
Or a directory
In the case a directory is provided the editor is to open "all" editable files in the given directory
In the case multiple files are open we are able to navigate between them using n and N keys for next and previous file
Be careful to save and load settings when navigating this way
## Core Features
### Video Playback
- **Space**: Play/pause video
- **a/d**: Seek backward/forward 1 frame
- **A/D**: Seek backward/forward 10 frames
- **Ctrl+a/d**: Seek backward/forward 60 frames
- **Mouse Wheel**: Seek backward/forward 1 frame (ignores seek multiplier)
- **W/S**: Increase/decrease playback speed (0.1x to 10.0x, increments of 0.2)
- **Q/Y**: Increase/decrease seek multiplier (multiplies the frame count for a/d/A/D/Ctrl+a/d keys by 1.0x to 100.0x, increments of 2.0)
- **q**: Quit the program
- **Timeline**: Click anywhere to jump to that position
- **Auto-repeat**: Hold seek keys for continuous seeking at 1 FPS rate
### Visual Transformations
- **-**: Rotate 90 degrees clockwise
- **e/E**: Increase/decrease brightness (-100 to +100, increments of 5)
- **r/R**: Increase/decrease contrast (0.1 to 3.0, increments of 0.1)
- **Ctrl+Scroll**: Zoom in/out (0.1x to 10.0x, increments of 0.1)
- **Ctrl+Click**: Set zoom center point
### Cropping
- **Shift+Click+Drag**: Select crop area with green rectangle preview
- **h/j/k/l**: Expand crop from right/down/up/left edges (15 pixels per keypress)
- **H/J/K/L**: Contract crop to left/down/up/right edges (15 pixels per keypress)
- **u**: Undo last crop
- **c**: Clear all cropping
- **C**: Complete reset (crop, zoom, rotation, brightness, contrast, tracking points, cut markers)
### Motion Tracking
- **Right-click**: Add tracking point (green circle with white border)
- **Right-click existing point**: Remove tracking point (within 10px)
- **Right-click near existing point**: Snap to existing point from any frame (within 10px radius)
- **Right-click near motion path**: Snap to closest point on yellow arrow line between tracking points (within 10px radius)
- **v**: Toggle motion tracking on/off
- **V**: Clear all tracking points
- **Blue cross**: Shows computed tracking position
- **Automatic interpolation**: Tracks between keyframes
- **Crop follows**: Crop area centers on tracked object
- **Display** Points are rendered as blue dots per frame, in addition the previous tracking point (red) and next tracking point (magenta) are shown with yellow arrows indicating motion direction
#### Motion Tracking Navigation
- **,**: Jump to previous tracking marker (previous frame that has one or more tracking points). Goes to first marker if at beginning.
- **.**: Jump to next tracking marker (next frame that has one or more tracking points). Goes to last marker if at end.
### Markers and Looping
- **1**: Set cut start marker at current frame
- **2**: Set cut end marker at current frame
- **t**: Toggle loop playback between markers
- **Red lines**: Markers shown on timeline with numbers
- **Continuous loop**: Playback loops between markers when enabled
### File Management
- **Enter**: Render video (overwrites if filename contains "_edited_")
- **b**: Render video with new "_edited_001" filename (does NOT overwrite!)
- **s**: Save screenshot with auto-incrementing filename (video_frame_00001.jpg, video_frame_00002.jpg, etc. - NEVER overwrite existing screenshots)
- **N/n**: Next/previous video in directory
- **p**: Toggle project view (directory browser)
### Project View
- **wasd**: Navigate through video thumbnails
- **e**: Open selected video
- **Q/Y**: Change thumbnail size (fewer/more per row, size automatically computed to fit row)
- **q**: Quit
- **Progress bars**: Show editing progress for each video (blue bar showing current_frame/total_frames)
- **ESC**: Return to editor
### Display and Interface
- **f**: Toggle fullscreen
- **Status overlay**: Shows "Frame: 1500/3000 | Speed: 1.5x | Zoom: 2.0x | Seek: 5.0x | Rotation: 90° | Brightness: 10 | Contrast: 1.2 | Motion: ON (3 pts) | Playing/Paused"
- **Timeline**: Visual progress bar with current position handle
- **Feedback messages**: Temporary on-screen notifications (e.g. "Screenshot saved: video_frame_00001.jpg")
- **Progress bar**: Shows rendering progress with FPS counter (e.g. "Processing 1500/3000 frames | 25.3 FPS")
### State Management
- **Auto-save**: Settings saved automatically on changes and on quit
- **Per-video state**: Each video remembers its own settings
- **Cross-session**: Settings persist between application restarts
- **JSON files**: State stored as .json files next to videos with the same name as the video
### Rendering
- **Background rendering**: Continue editing while rendering (rendering happens in separate thread, you can still seek/play/edit)
- **x**: Cancel active render
- **FFmpeg output**: Invoke FFmpeg process, pipe raw video frames via stdin, output MP4 with H.264 encoding (CRF 18, preset fast)
- **Progress tracking**: Real-time progress with FPS display
- **Overwrite protection**: Only overwrites files with "_edited_" in name
### Image Mode
- **Same controls**: All editing features work on static images
- **No playback**: Space key disabled, no timeline
- **Screenshot mode**: Treats single images like video frames
### Error Handling
- **Format support**: MP4, AVI, MOV, MKV, WMV, FLV, WebM, M4V, JPG, PNG, BMP, TIFF, WebP
- **Backend fallback**: Tries multiple video backends automatically
- **Error messages**: Clear feedback for common issues
- **Graceful degradation**: Continues working when possible
### Performance Features
- **Frame caching**: Smooth seeking with cached frames (cache the decoded frames, LRU eviction, max 3000 frames)
- **Transformation caching**: Fast repeated operations (cache transformed frames during auto-repeat seeking)
- **Memory management**: Automatic cache cleanup
### Window Management
- **Resizable**: Window can be resized dynamically
- **Multi-window**: Project view opens in separate window
- **Focus handling**: Keys only affect active window
- **Context menu**: Right-click integration on Windows
This specification describes what Croppa does from the user's perspective - the features, controls, and behaviors that make up the application.

295
main.py
View File

@@ -7,74 +7,23 @@ import argparse
import shutil
import time
import threading
import subprocess
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import List
class Cv2BufferedCap:
"""Buffered wrapper around cv2.VideoCapture that handles frame loading, seeking, and caching correctly"""
def __init__(self, video_path, backend=None):
self.video_path = video_path
self.cap = cv2.VideoCapture(str(video_path), backend)
if not self.cap.isOpened():
raise ValueError(f"Could not open video: {video_path}")
# Video properties
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
self.frame_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self.frame_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Current position tracking
self.current_frame = 0
def get_frame(self, frame_number):
"""Get frame at specific index - always accurate"""
# Clamp frame number to valid range
frame_number = max(0, min(frame_number, self.total_frames - 1))
# Optimize for sequential reading (next frame)
if frame_number == self.current_frame + 1:
ret, frame = self.cap.read()
else:
# Seek for non-sequential access
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
ret, frame = self.cap.read()
if ret:
self.current_frame = frame_number
return frame
else:
raise ValueError(f"Failed to read frame {frame_number}")
def advance_frame(self, frames=1):
"""Advance by specified number of frames"""
new_frame = self.current_frame + frames
return self.get_frame(new_frame)
def release(self):
"""Release the video capture"""
if self.cap:
self.cap.release()
def isOpened(self):
"""Check if capture is opened"""
return self.cap and self.cap.isOpened()
class MediaGrader:
# Configuration constants - matching croppa implementation
TARGET_FPS = 80 # Target FPS for speed calculations
SPEED_INCREMENT = 0.1
MIN_PLAYBACK_SPEED = 0.05
MAX_PLAYBACK_SPEED = 1.0
# Legacy constants for compatibility
BASE_FRAME_DELAY_MS = 16
KEY_REPEAT_RATE_SEC = 0.5
FAST_SEEK_ACTIVATION_TIME = 2.0
FRAME_RENDER_TIME_MS = 50
SPEED_INCREMENT = 0.2
MIN_PLAYBACK_SPEED = 0.1
MAX_PLAYBACK_SPEED = 100.0
FAST_SEEK_MULTIPLIER = 60
IMAGE_DISPLAY_DELAY_MS = 100
MONITOR_WIDTH = 2560
MONITOR_HEIGHT = 1440
@@ -159,53 +108,30 @@ class MediaGrader:
# Get frame dimensions
frame_height, frame_width = frame.shape[:2]
# Calculate available height (subtract timeline height for videos)
timeline_height = self.TIMELINE_HEIGHT if self.is_video(self.media_files[self.current_index]) else 0
available_height = self.MONITOR_HEIGHT - timeline_height
# Calculate aspect ratio
frame_aspect_ratio = frame_width / frame_height
monitor_aspect_ratio = self.MONITOR_WIDTH / self.MONITOR_HEIGHT
# Calculate scale to fit within monitor bounds while maintaining aspect ratio
scale_x = self.MONITOR_WIDTH / frame_width
scale_y = available_height / frame_height
scale = min(scale_x, scale_y)
# Calculate display dimensions
display_width = int(frame_width * scale)
display_height = int(frame_height * scale)
# Resize the frame to maintain aspect ratio
if scale != 1.0:
resized_frame = cv2.resize(frame, (display_width, display_height), interpolation=cv2.INTER_AREA)
# Determine if frame is vertical or horizontal relative to monitor
if frame_aspect_ratio < monitor_aspect_ratio:
# Frame is more vertical than monitor - maximize height
display_height = self.MONITOR_HEIGHT
display_width = int(display_height * frame_aspect_ratio)
else:
resized_frame = frame
# Frame is more horizontal than monitor - maximize width
display_width = self.MONITOR_WIDTH
display_height = int(display_width / frame_aspect_ratio)
# Create canvas with proper dimensions
canvas_height = self.MONITOR_HEIGHT
canvas_width = self.MONITOR_WIDTH
canvas = np.zeros((canvas_height, canvas_width, 3), dtype=np.uint8)
# Center the resized frame on canvas
start_y = (available_height - display_height) // 2
start_x = (self.MONITOR_WIDTH - display_width) // 2
# Ensure frame fits within canvas bounds
end_y = min(start_y + display_height, available_height)
end_x = min(start_x + display_width, self.MONITOR_WIDTH)
actual_height = end_y - start_y
actual_width = end_x - start_x
if actual_height > 0 and actual_width > 0:
canvas[start_y:end_y, start_x:end_x] = resized_frame[:actual_height, :actual_width]
# Resize window to full monitor size
cv2.resizeWindow("Media Grader", self.MONITOR_WIDTH, self.MONITOR_HEIGHT)
# Resize window to calculated dimensions
cv2.resizeWindow("Media Grader", display_width, display_height)
# Center the window on screen
x_position = 0
y_position = 0
x_position = (self.MONITOR_WIDTH - display_width) // 2
y_position = (self.MONITOR_HEIGHT - display_height) // 2
cv2.moveWindow("Media Grader", x_position, y_position)
# Display the canvas with properly aspect-ratioed frame
cv2.imshow("Media Grader", canvas)
# Display the frame
cv2.imshow("Media Grader", frame)
def find_media_files(self) -> List[Path]:
"""Find all media files recursively in the directory"""
@@ -232,18 +158,19 @@ class MediaGrader:
def calculate_frame_delay(self) -> int:
"""Calculate frame delay in milliseconds based on playback speed"""
# Round to 2 decimals to handle floating point precision issues
speed = round(self.playback_speed, 2)
if speed >= 1.0:
# Speed >= 1: maximum FPS (no delay)
return 1
else:
# Speed < 1: scale FPS based on speed
# Formula: fps = TARGET_FPS * speed, so delay = 1000 / fps
target_fps = self.TARGET_FPS * speed
delay_ms = int(1000 / target_fps)
return max(1, delay_ms)
delay_ms = int(self.BASE_FRAME_DELAY_MS / self.playback_speed)
return max(1, delay_ms)
def calculate_frames_to_skip(self) -> int:
"""Calculate how many frames to skip for high-speed playback"""
if self.playback_speed <= 1.0:
return 0
elif self.playback_speed <= 2.0:
return 0
elif self.playback_speed <= 5.0:
return int(self.playback_speed - 1)
else:
return int(self.playback_speed * 2)
def load_media(self, file_path: Path) -> bool:
"""Load media file for display"""
@@ -251,17 +178,43 @@ class MediaGrader:
self.current_cap.release()
if self.is_video(file_path):
try:
# Use Cv2BufferedCap for better frame handling
self.current_cap = Cv2BufferedCap(file_path)
self.total_frames = self.current_cap.total_frames
self.current_frame = 0
print(f"Loaded: {file_path.name} | Frames: {self.total_frames} | FPS: {self.current_cap.fps:.2f}")
except Exception as e:
print(f"Warning: Could not open video file {file_path.name}: {e}")
# Try different backends for better performance
# For video files: FFmpeg is usually best, DirectShow is for cameras
backends_to_try = []
if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files
backends_to_try.append(cv2.CAP_FFMPEG)
if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - usually for cameras, but try as fallback
backends_to_try.append(cv2.CAP_DSHOW)
backends_to_try.append(cv2.CAP_ANY) # Final fallback
self.current_cap = None
for backend in backends_to_try:
try:
self.current_cap = cv2.VideoCapture(str(file_path), backend)
if self.current_cap.isOpened():
# Optimize buffer settings for better performance
self.current_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Minimize buffer to reduce latency
# Try to set hardware acceleration if available
if hasattr(cv2, 'CAP_PROP_HW_ACCELERATION'):
self.current_cap.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY)
break
self.current_cap.release()
except:
continue
if not self.current_cap or not self.current_cap.isOpened():
print(f"Warning: Could not open video file {file_path.name} (unsupported codec)")
return False
self.total_frames = int(self.current_cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.current_frame = 0
# Get codec information for debugging
fourcc = int(self.current_cap.get(cv2.CAP_PROP_FOURCC))
codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)])
backend = self.current_cap.getBackendName()
print(f"Loaded: {file_path.name} | Codec: {codec} | Backend: {backend} | Frames: {self.total_frames}")
else:
self.current_cap = None
@@ -282,13 +235,12 @@ class MediaGrader:
if not self.current_cap:
return False
try:
# Use Cv2BufferedCap to get frame
self.current_display_frame = self.current_cap.get_frame(self.current_frame)
ret, frame = self.current_cap.read()
if ret:
self.current_display_frame = frame
self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
return True
except Exception as e:
print(f"Failed to load frame {self.current_frame}: {e}")
return False
return False
else:
frame = cv2.imread(str(self.media_files[self.current_index]))
if frame is not None:
@@ -720,7 +672,7 @@ class MediaGrader:
# Draw timeline
self.draw_timeline(frame)
# Display with proper aspect ratio
# Maintain aspect ratio when displaying
self.display_with_aspect_ratio(frame)
def display_multi_segment_frame(self):
@@ -837,7 +789,7 @@ class MediaGrader:
# Draw multi-segment timeline
self.draw_multi_segment_timeline(combined_frame)
# Display with proper aspect ratio
# Maintain aspect ratio when displaying
self.display_with_aspect_ratio(combined_frame)
def draw_multi_segment_timeline(self, frame):
@@ -952,30 +904,38 @@ class MediaGrader:
target_frame = max(0, min(target_frame, self.total_frames - 1))
# Seek to target frame
self.current_frame = target_frame
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
self.load_current_frame()
def advance_frame(self):
"""Advance to next frame - handles playback speed and marker looping"""
if not self.is_playing:
return True
"""Advance to next frame(s) based on playback speed"""
if (
not self.is_video(self.media_files[self.current_index])
or not self.is_playing
):
return
if self.multi_segment_mode:
self.update_segment_frames()
return True
else:
# Always advance by 1 frame - speed is controlled by delay timing
new_frame = self.current_frame + 1
frames_to_skip = self.calculate_frames_to_skip()
# Handle looping bounds
if new_frame >= self.total_frames:
# Loop to beginning
new_frame = 0
for _ in range(frames_to_skip + 1):
ret, frame = self.current_cap.read()
if not ret:
actual_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
if actual_frame < self.total_frames - 5:
print(f"Frame count mismatch! Reported: {self.total_frames}, Actual: {actual_frame}")
self.total_frames = actual_frame
return False
# Update current frame and load it
self.current_frame = new_frame
self.current_display_frame = frame
self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
self.update_watch_tracking()
return self.load_current_frame()
return True
def seek_video(self, frames_delta: int):
"""Seek video by specified number of frames"""
@@ -992,7 +952,7 @@ class MediaGrader:
0, min(self.current_frame + frames_delta, self.total_frames - 1)
)
self.current_frame = target_frame
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
self.load_current_frame()
def process_seek_key(self, key: int) -> bool:
@@ -1205,42 +1165,32 @@ class MediaGrader:
cv2.setWindowTitle("Media Grader", window_title)
while True:
# Update display
self.display_current_frame()
# Calculate appropriate delay based on playback state
if self.is_playing and self.is_video(current_file):
# Use calculated frame delay for proper playback speed
delay_ms = self.calculate_frame_delay()
if self.is_video(current_file):
if self.is_seeking:
delay = self.FRAME_RENDER_TIME_MS
else:
delay = self.calculate_frame_delay()
else:
# Use minimal delay for immediate responsiveness when not playing
delay_ms = 1
# Auto advance frame when playing (videos only)
if self.is_playing and self.is_video(current_file):
self.advance_frame()
delay = self.IMAGE_DISPLAY_DELAY_MS
# Key capture with appropriate delay
key = cv2.waitKey(delay_ms) & 0xFF
key = cv2.waitKey(delay) & 0xFF
if key == ord("q") or key == 27:
return
elif key == ord(" "):
self.is_playing = not self.is_playing
elif key == ord("s"):
# Speed control only for videos
if self.is_video(current_file):
self.playback_speed = max(
self.MIN_PLAYBACK_SPEED,
self.playback_speed - self.SPEED_INCREMENT,
)
self.playback_speed = max(
self.MIN_PLAYBACK_SPEED,
self.playback_speed - self.SPEED_INCREMENT,
)
elif key == ord("w"):
# Speed control only for videos
if self.is_video(current_file):
self.playback_speed = min(
self.MAX_PLAYBACK_SPEED,
self.playback_speed + self.SPEED_INCREMENT,
)
self.playback_speed = min(
self.MAX_PLAYBACK_SPEED,
self.playback_speed + self.SPEED_INCREMENT,
)
elif self.process_seek_key(key):
continue
elif key == ord("n"):
@@ -1279,6 +1229,17 @@ class MediaGrader:
if self.is_seeking and self.current_seek_key is not None:
self.process_seek_key(self.current_seek_key)
if (
self.is_playing
and self.is_video(current_file)
and not self.is_seeking
):
if not self.advance_frame():
# Video reached the end, restart it instead of navigating
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
self.current_frame = 0
self.load_current_frame()
if key not in [ord("p"), ord("u"), ord("1"), ord("2"), ord("3"), ord("4"), ord("5")]:
print("Navigating to (pu12345): ", self.current_index)
self.current_index += 1

57
uv.lock generated
View File

@@ -15,14 +15,12 @@ source = { virtual = "croppa" }
dependencies = [
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "opencv-python", specifier = ">=4.8.0" },
{ name = "pillow", specifier = ">=10.0.0" },
]
[[package]]
@@ -87,61 +85,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" },
]
[[package]]
name = "pillow"
version = "11.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
{ url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
{ url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
{ url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
{ url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
{ url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
{ url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
{ url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
{ url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
{ url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
{ url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
{ url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
{ url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
{ url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
{ url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
{ url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
{ url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
{ url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
{ url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
{ url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
{ url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
{ url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
{ url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
{ url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
{ url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
]
[[package]]
name = "ruff"
version = "0.12.12"