Compare commits
184 Commits
30cdd9d0e5
...
master
Author | SHA1 | Date | |
---|---|---|---|
bd1824a7ca | |||
4806c95095 | |||
16c841d14d | |||
bfb9ed54d9 | |||
3ac725c2aa | |||
b5a0811cbd | |||
1ac8cd04b3 | |||
203d036a92 | |||
fa2ac22f9f | |||
2013ccf627 | |||
e1d94f2b24 | |||
9df6d73db8 | |||
01340a0a81 | |||
44ed4220b9 | |||
151744d144 | |||
e823a11929 | |||
c1c01e86ca | |||
184aceeee3 | |||
db2aa57ce5 | |||
92c2e62166 | |||
86c31a49d9 | |||
f5b8656bc2 | |||
b9c60ffc25 | |||
b6c7863b77 | |||
612d024161 | |||
840440eb1a | |||
c3bf49f301 | |||
192a5c7124 | |||
2246ef9f45 | |||
c52d9b9399 | |||
10284dad81 | |||
a2dc4a2186 | |||
5d76681ded | |||
f8acef2da4 | |||
65b80034cb | |||
5400592afd | |||
e6616ed1b1 | |||
048e8ef033 | |||
c08d5c5999 | |||
8c1efb1b05 | |||
f942392fb3 | |||
c749d9af80 | |||
71e5870306 | |||
e813be2890 | |||
80fb35cced | |||
d8b4439382 | |||
463228baf5 | |||
e7571a78f4 | |||
ea008ba23c | |||
366c338c5d | |||
0d26ffaca4 | |||
aaf78bf0da | |||
43d350fff2 | |||
d1b9e7c470 | |||
c50234f5c1 | |||
171155e528 | |||
710a1f7de3 | |||
13fbc45b74 | |||
8b4f8026cc | |||
5c66935157 | |||
bae760837c | |||
4a1649a568 | |||
ea1a6e58f4 | |||
0c3e5e21bf | |||
472efbb9d9 | |||
dd2f40460b | |||
b2c7cf11e9 | |||
e0e5c8d933 | |||
04e391551e | |||
f9f442a2d0 | |||
0fd108bc9a | |||
83ef71934b | |||
b123b12d0d | |||
1bd935646e | |||
c3e0088a60 | |||
68a1cc3e7d | |||
498a1911b1 | |||
d068da20f4 | |||
84a0748f0b | |||
8c45b30bca | |||
615a3dce0d | |||
1ce05d33ba | |||
1aea3b8a6e | |||
fbac3b0dbb | |||
b90b5e5725 | |||
ed6f809029 | |||
8a7e2609c5 | |||
dd1bc12667 | |||
9c14249f88 | |||
47ec7fed04 | |||
e80278a2dd | |||
f1d4145e43 | |||
1d987a341a | |||
47ce52da37 | |||
6c86271428 | |||
d0d2f66b11 | |||
eeaeff6fe0 | |||
d478b28e0d | |||
b440da3094 | |||
fdf7d98850 | |||
3ee5c1bddc | |||
1d1d113a92 | |||
e162e4fe92 | |||
cd86cfc9f2 | |||
33a553c092 | |||
2979dca40a | |||
cb097c55f1 | |||
70364d0458 | |||
c88c2cc354 | |||
9085a82bdd | |||
85891a5f99 | |||
66b23834fd | |||
5baa2572ea | |||
c7c092d3f3 | |||
f0d540be27 | |||
c8dfcca954 | |||
b9cf9f0125 | |||
b8899004f3 | |||
8c4663c4ef | |||
9dd0c837b4 | |||
c56b012246 | |||
46f4441357 | |||
d60828d787 | |||
cfd919a377 | |||
d235fa693e | |||
97e4a140eb | |||
ae2b156b87 | |||
01aaa36eb0 | |||
83b40af001 | |||
1a086f9362 | |||
8f77960183 | |||
d6af05b6db | |||
76245b3adc | |||
6559310adc | |||
01ea25168a | |||
fc0aa1317b | |||
a6886a8ab8 | |||
4d4cba9876 | |||
0847ea1abd | |||
37e9e99f64 | |||
15382b8fe7 | |||
9763597af8 | |||
4d60526952 | |||
87ead9189f | |||
1e1a886766 | |||
34179e5922 | |||
f9272e76eb | |||
1a05963c31 | |||
3a8f8d26d3 | |||
56d6e04b48 | |||
6efbfa0c11 | |||
d1b26fe8b4 | |||
1da8efc528 | |||
0dbf82f76b | |||
4651ba51f1 | |||
2961fe088d | |||
0fb591d0b3 | |||
709e637e88 | |||
252cda9ad3 | |||
d29d45d4fd | |||
b7e4fac9e7 | |||
a815679a38 | |||
ce8560aafb | |||
0740af6024 | |||
b1ade237a7 | |||
eb9b4d9c8c | |||
84993c4fc8 | |||
ed0e8b3d6d | |||
e5e4dea2a3 | |||
8a266303a5 | |||
366de8e796 | |||
b85e757871 | |||
7f08f38457 | |||
161b221992 | |||
4a8492dcd2 | |||
a7c5398faf | |||
f919015e6b | |||
6f3f03d863 | |||
dd237d0723 | |||
b54131e4e7 | |||
204dcf491d | |||
0adcc8f32a | |||
4ffd4cd321 | |||
b510ec9637 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
__pycache__
|
||||
croppa/build/lib
|
||||
croppa/croppa.egg-info
|
||||
*.log
|
||||
|
14
cleaner/go.mod
Normal file
14
cleaner/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
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
|
||||
)
|
28
cleaner/go.sum
Normal file
28
cleaner/go.sum
Normal file
@@ -0,0 +1,28 @@
|
||||
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=
|
7
cleaner/install_grader_context_menu.reg
Normal file
7
cleaner/install_grader_context_menu.reg
Normal file
@@ -0,0 +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 name\command]
|
||||
@="C:\\Users\\administrator\\go\\bin\\tcleaner.exe \"%1\""
|
76
cleaner/main.go
Normal file
76
cleaner/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"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}`)
|
||||
|
||||
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)
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
filelog.Info("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)
|
||||
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)
|
||||
if name == newName {
|
||||
filelog.Info("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)
|
||||
continue
|
||||
}
|
||||
|
||||
filelog.Info("Renaming to: %s", newName)
|
||||
err = os.Rename(name, newName)
|
||||
if err != nil {
|
||||
filelog.Error("ERROR renaming %s: %v\n", name, err)
|
||||
} else {
|
||||
filelog.Info("RENAMED: %s -> %s\n", name, newName)
|
||||
}
|
||||
filelog.Info("All done")
|
||||
continue
|
||||
}
|
||||
}
|
4280
croppa/main.py
4280
croppa/main.py
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@ readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"opencv-python>=4.8.0",
|
||||
"numpy>=1.24.0"
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=10.0.0"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
138
croppa/spec.md
Normal file
138
croppa/spec.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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.
|
350
main.py
350
main.py
@@ -12,16 +12,69 @@ 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:
|
||||
BASE_FRAME_DELAY_MS = 16
|
||||
# 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
|
||||
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
|
||||
@@ -39,7 +92,6 @@ class MediaGrader:
|
||||
CTRL_SEEK_MULTIPLIER = 10
|
||||
|
||||
SEGMENT_COUNT = 16
|
||||
SEGMENT_OVERLAP_PERCENT = 10
|
||||
|
||||
def __init__(
|
||||
self, directory: str, seek_frames: int = 30, snap_to_iframe: bool = False
|
||||
@@ -56,10 +108,10 @@ class MediaGrader:
|
||||
|
||||
self.multi_segment_mode = False
|
||||
self.segment_count = self.SEGMENT_COUNT
|
||||
self.segment_overlap_percent = self.SEGMENT_OVERLAP_PERCENT
|
||||
self.segment_caps = []
|
||||
self.segment_frames = []
|
||||
self.segment_positions = []
|
||||
self.segment_end_positions = [] # Track where each segment should loop back to
|
||||
|
||||
self.timeline_visible = True
|
||||
|
||||
@@ -87,8 +139,6 @@ class MediaGrader:
|
||||
|
||||
self.mouse_dragging = False
|
||||
self.timeline_rect = None
|
||||
self.window_width = 800
|
||||
self.window_height = 600
|
||||
|
||||
self.undo_history = []
|
||||
|
||||
@@ -109,30 +159,53 @@ class MediaGrader:
|
||||
# Get frame dimensions
|
||||
frame_height, frame_width = frame.shape[:2]
|
||||
|
||||
# Calculate aspect ratio
|
||||
frame_aspect_ratio = frame_width / frame_height
|
||||
monitor_aspect_ratio = self.MONITOR_WIDTH / self.MONITOR_HEIGHT
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
else:
|
||||
# Frame is more horizontal than monitor - maximize width
|
||||
display_width = self.MONITOR_WIDTH
|
||||
display_height = int(display_width / frame_aspect_ratio)
|
||||
resized_frame = frame
|
||||
|
||||
# Resize window to calculated dimensions
|
||||
cv2.resizeWindow("Media Grader", display_width, display_height)
|
||||
# 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)
|
||||
|
||||
# Center the window on screen
|
||||
x_position = (self.MONITOR_WIDTH - display_width) // 2
|
||||
y_position = (self.MONITOR_HEIGHT - display_height) // 2
|
||||
x_position = 0
|
||||
y_position = 0
|
||||
cv2.moveWindow("Media Grader", x_position, y_position)
|
||||
|
||||
# Display the frame
|
||||
cv2.imshow("Media Grader", frame)
|
||||
# Display the canvas with properly aspect-ratioed frame
|
||||
cv2.imshow("Media Grader", canvas)
|
||||
|
||||
def find_media_files(self) -> List[Path]:
|
||||
"""Find all media files recursively in the directory"""
|
||||
@@ -159,19 +232,18 @@ class MediaGrader:
|
||||
|
||||
def calculate_frame_delay(self) -> int:
|
||||
"""Calculate frame delay in milliseconds based on playback speed"""
|
||||
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)
|
||||
# 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:
|
||||
return int(self.playback_speed * 2)
|
||||
# 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)
|
||||
|
||||
|
||||
def load_media(self, file_path: Path) -> bool:
|
||||
"""Load media file for display"""
|
||||
@@ -179,44 +251,18 @@ class MediaGrader:
|
||||
self.current_cap.release()
|
||||
|
||||
if self.is_video(file_path):
|
||||
# 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
|
||||
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
|
||||
|
||||
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
|
||||
print(f"Loaded: {file_path.name} | Frames: {self.total_frames} | FPS: {self.current_cap.fps:.2f}")
|
||||
|
||||
if not self.current_cap or not self.current_cap.isOpened():
|
||||
print(f"Warning: Could not open video file {file_path.name} (unsupported codec)")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not open video file {file_path.name}: {e}")
|
||||
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
|
||||
self.total_frames = 1
|
||||
@@ -236,12 +282,13 @@ class MediaGrader:
|
||||
if not self.current_cap:
|
||||
return False
|
||||
|
||||
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))
|
||||
try:
|
||||
# Use Cv2BufferedCap to get frame
|
||||
self.current_display_frame = self.current_cap.get_frame(self.current_frame)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Failed to load frame {self.current_frame}: {e}")
|
||||
return False
|
||||
else:
|
||||
frame = cv2.imread(str(self.media_files[self.current_index]))
|
||||
if frame is not None:
|
||||
@@ -490,34 +537,27 @@ class MediaGrader:
|
||||
if not self.is_video(self.media_files[self.current_index]):
|
||||
return
|
||||
|
||||
# Safety check for huge videos
|
||||
safe_frame_count = max(1, int(self.total_frames * 0.6))
|
||||
|
||||
# Calculate actual memory usage based on frame dimensions
|
||||
frame_width = int(self.current_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
frame_height = int(self.current_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
bytes_per_frame = frame_width * frame_height * 3 # RGB (3 bytes per pixel)
|
||||
total_bytes = safe_frame_count * bytes_per_frame
|
||||
total_mb = total_bytes / (1024 * 1024)
|
||||
total_gb = total_mb / 1024
|
||||
|
||||
total_mb = frame_width * frame_height * 3 / (1024 * 1024)
|
||||
# Memory-based limits (not frame count)
|
||||
if total_gb > 8: # 8GB limit
|
||||
if total_mb > 8000: # 8GB limit
|
||||
print(f"Video too large for preloading!")
|
||||
print(f" Resolution: {frame_width}x{frame_height}")
|
||||
print(f" Frames: {safe_frame_count} frames would use {total_gb:.1f}GB RAM")
|
||||
print(f" Frames: {self.total_frames} frames would use {total_mb:.1f}GB RAM")
|
||||
print(f"Multi-segment mode not available for videos requiring >8GB RAM")
|
||||
return
|
||||
elif total_mb > 500: # 500MB warning
|
||||
print(f"Large video detected:")
|
||||
print(f" Resolution: {frame_width}x{frame_height}")
|
||||
print(f" Memory: {safe_frame_count} frames will use {total_mb:.0f}MB RAM")
|
||||
print(f" Memory: {self.total_frames} frames will use {total_mb:.0f}GB RAM")
|
||||
print("Press any key to continue or 'q' to cancel...")
|
||||
# Note: In a real implementation, you'd want proper input handling here
|
||||
|
||||
start_time = time.time()
|
||||
print(f"Setting up {self.segment_count} segments with video preloading...")
|
||||
print(f"Will preload {safe_frame_count} frames ({frame_width}x{frame_height}) = {total_mb:.0f}MB RAM")
|
||||
|
||||
try:
|
||||
print("Cleaning up existing captures...")
|
||||
@@ -531,6 +571,7 @@ class MediaGrader:
|
||||
self.segment_caps = [None] * self.segment_count # Keep for compatibility
|
||||
self.segment_frames = [None] * self.segment_count
|
||||
self.segment_positions = []
|
||||
self.segment_end_positions = []
|
||||
self.segment_current_frames = [0] * self.segment_count # Track current frame for each segment
|
||||
|
||||
# Calculate target positions
|
||||
@@ -542,13 +583,23 @@ class MediaGrader:
|
||||
for i in range(self.segment_count):
|
||||
if self.segment_count <= 1:
|
||||
position_ratio = 0
|
||||
end_ratio = 1.0
|
||||
else:
|
||||
position_ratio = i / (self.segment_count - 1)
|
||||
start_frame = int(position_ratio * (safe_frame_count - 1))
|
||||
start_frame = max(0, min(start_frame, safe_frame_count - 1))
|
||||
end_ratio = (i + 1) / (self.segment_count - 1) if i < self.segment_count - 1 else 1.0
|
||||
|
||||
start_frame = int(position_ratio * (self.total_frames - 1))
|
||||
end_frame = int(end_ratio * (self.total_frames - 1))
|
||||
|
||||
start_frame = max(0, min(start_frame, self.total_frames - 1))
|
||||
end_frame = max(start_frame + 1, min(end_frame, self.total_frames - 1)) # Ensure at least 1 frame per segment
|
||||
|
||||
self.segment_positions.append(start_frame)
|
||||
self.segment_end_positions.append(end_frame)
|
||||
self.segment_current_frames[i] = start_frame # Start each segment at its position
|
||||
|
||||
print(f"Segment positions: {self.segment_positions}")
|
||||
print(f"Segment end positions: {self.segment_end_positions}")
|
||||
|
||||
# Preload the entire video into memory - simple and fast
|
||||
print("Preloading entire video into memory...")
|
||||
@@ -561,7 +612,7 @@ class MediaGrader:
|
||||
frames = []
|
||||
frame_count = 0
|
||||
|
||||
while frame_count < safe_frame_count:
|
||||
while frame_count < self.total_frames:
|
||||
ret, frame = self.current_cap.read()
|
||||
if ret and frame is not None:
|
||||
frames.append(frame)
|
||||
@@ -604,25 +655,35 @@ class MediaGrader:
|
||||
self.segment_caps = []
|
||||
self.segment_frames = []
|
||||
self.segment_positions = []
|
||||
self.segment_end_positions = []
|
||||
if hasattr(self, 'video_frame_cache'):
|
||||
self.video_frame_cache = []
|
||||
if hasattr(self, 'segment_current_frames'):
|
||||
self.segment_current_frames = []
|
||||
|
||||
def update_segment_frames(self):
|
||||
"""Update frames for segments using the preloaded video array - smooth playback!"""
|
||||
"""Update frames for segments - each segment loops within its own range"""
|
||||
if not self.multi_segment_mode or not self.segment_frames or not hasattr(self, 'video_frame_cache'):
|
||||
return
|
||||
|
||||
for i in range(len(self.segment_frames)):
|
||||
if self.segment_frames[i] is not None and self.video_frame_cache:
|
||||
# Advance to next frame in this segment
|
||||
self.segment_current_frames[i] += 1
|
||||
|
||||
if self.segment_current_frames[i] >= len(self.video_frame_cache):
|
||||
self.segment_current_frames[i] = 0
|
||||
# Get the segment boundaries
|
||||
start_frame = self.segment_positions[i]
|
||||
end_frame = self.segment_end_positions[i]
|
||||
|
||||
# Direct reference - no copy needed for display
|
||||
self.segment_frames[i] = self.video_frame_cache[self.segment_current_frames[i]]
|
||||
# Loop within the segment bounds
|
||||
if self.segment_current_frames[i] > end_frame:
|
||||
# Loop back to start of segment
|
||||
self.segment_current_frames[i] = start_frame
|
||||
|
||||
# Ensure we don't go beyond the video cache
|
||||
if self.segment_current_frames[i] < len(self.video_frame_cache):
|
||||
# Direct reference - no copy needed for display
|
||||
self.segment_frames[i] = self.video_frame_cache[self.segment_current_frames[i]]
|
||||
|
||||
def display_current_frame(self):
|
||||
"""Display the current cached frame with overlays"""
|
||||
@@ -659,7 +720,7 @@ class MediaGrader:
|
||||
# Draw timeline
|
||||
self.draw_timeline(frame)
|
||||
|
||||
# Maintain aspect ratio when displaying
|
||||
# Display with proper aspect ratio
|
||||
self.display_with_aspect_ratio(frame)
|
||||
|
||||
def display_multi_segment_frame(self):
|
||||
@@ -776,7 +837,7 @@ class MediaGrader:
|
||||
# Draw multi-segment timeline
|
||||
self.draw_multi_segment_timeline(combined_frame)
|
||||
|
||||
# Maintain aspect ratio when displaying
|
||||
# Display with proper aspect ratio
|
||||
self.display_with_aspect_ratio(combined_frame)
|
||||
|
||||
def draw_multi_segment_timeline(self, frame):
|
||||
@@ -827,8 +888,6 @@ class MediaGrader:
|
||||
return
|
||||
|
||||
height, width = frame.shape[:2]
|
||||
self.window_height = height
|
||||
self.window_width = width
|
||||
|
||||
# Timeline background area
|
||||
timeline_y = height - self.TIMELINE_HEIGHT
|
||||
@@ -859,7 +918,7 @@ class MediaGrader:
|
||||
cv2.circle(frame, (handle_x, handle_y), self.TIMELINE_HANDLE_SIZE // 2, self.TIMELINE_COLOR_HANDLE, -1)
|
||||
cv2.circle(frame, (handle_x, handle_y), self.TIMELINE_HANDLE_SIZE // 2, self.TIMELINE_COLOR_BORDER, 2)
|
||||
|
||||
def mouse_callback(self, event, x, y, flags, param):
|
||||
def mouse_callback(self, event, x, y, _, __):
|
||||
"""Handle mouse events for timeline interaction"""
|
||||
if not self.timeline_rect or not self.is_video(self.media_files[self.current_index]) or self.multi_segment_mode:
|
||||
return
|
||||
@@ -893,38 +952,30 @@ class MediaGrader:
|
||||
target_frame = max(0, min(target_frame, self.total_frames - 1))
|
||||
|
||||
# Seek to target frame
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
self.current_frame = target_frame
|
||||
self.load_current_frame()
|
||||
|
||||
def advance_frame(self):
|
||||
"""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
|
||||
"""Advance to next frame - handles playback speed and marker looping"""
|
||||
if not self.is_playing:
|
||||
return True
|
||||
|
||||
if self.multi_segment_mode:
|
||||
self.update_segment_frames()
|
||||
return True
|
||||
else:
|
||||
frames_to_skip = self.calculate_frames_to_skip()
|
||||
# Always advance by 1 frame - speed is controlled by delay timing
|
||||
new_frame = self.current_frame + 1
|
||||
|
||||
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
|
||||
|
||||
self.current_display_frame = frame
|
||||
self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
|
||||
# Handle looping bounds
|
||||
if new_frame >= self.total_frames:
|
||||
# Loop to beginning
|
||||
new_frame = 0
|
||||
|
||||
# Update current frame and load it
|
||||
self.current_frame = new_frame
|
||||
self.update_watch_tracking()
|
||||
|
||||
return True
|
||||
return self.load_current_frame()
|
||||
|
||||
def seek_video(self, frames_delta: int):
|
||||
"""Seek video by specified number of frames"""
|
||||
@@ -941,7 +992,7 @@ class MediaGrader:
|
||||
0, min(self.current_frame + frames_delta, self.total_frames - 1)
|
||||
)
|
||||
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
self.current_frame = target_frame
|
||||
self.load_current_frame()
|
||||
|
||||
def process_seek_key(self, key: int) -> bool:
|
||||
@@ -1154,32 +1205,42 @@ class MediaGrader:
|
||||
cv2.setWindowTitle("Media Grader", window_title)
|
||||
|
||||
while True:
|
||||
# Update display
|
||||
self.display_current_frame()
|
||||
|
||||
if self.is_video(current_file):
|
||||
if self.is_seeking:
|
||||
delay = self.FRAME_RENDER_TIME_MS
|
||||
else:
|
||||
delay = self.calculate_frame_delay()
|
||||
# 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()
|
||||
else:
|
||||
delay = self.IMAGE_DISPLAY_DELAY_MS
|
||||
# Use minimal delay for immediate responsiveness when not playing
|
||||
delay_ms = 1
|
||||
|
||||
key = cv2.waitKey(delay) & 0xFF
|
||||
# Auto advance frame when playing (videos only)
|
||||
if self.is_playing and self.is_video(current_file):
|
||||
self.advance_frame()
|
||||
|
||||
# Key capture with appropriate delay
|
||||
key = cv2.waitKey(delay_ms) & 0xFF
|
||||
|
||||
if key == ord("q") or key == 27:
|
||||
return
|
||||
elif key == ord(" "):
|
||||
self.is_playing = not self.is_playing
|
||||
elif key == ord("s"):
|
||||
self.playback_speed = max(
|
||||
self.MIN_PLAYBACK_SPEED,
|
||||
self.playback_speed - self.SPEED_INCREMENT,
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
elif key == ord("w"):
|
||||
self.playback_speed = min(
|
||||
self.MAX_PLAYBACK_SPEED,
|
||||
self.playback_speed + self.SPEED_INCREMENT,
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
elif self.process_seek_key(key):
|
||||
continue
|
||||
elif key == ord("n"):
|
||||
@@ -1218,17 +1279,6 @@ 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
|
||||
|
@@ -5,6 +5,8 @@ description = "Media Grader - Grade media files by moving them to numbered folde
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"opencv-python>=4.12.0.88",
|
||||
"ruff>=0.12.12",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -21,3 +23,7 @@ include = ["main.py"]
|
||||
members = [
|
||||
"croppa",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
# Ensure F841 is enabled (it's part of default linting)
|
||||
select = ["F841"]
|
||||
|
100
uv.lock
generated
100
uv.lock
generated
@@ -15,12 +15,14 @@ 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]]
|
||||
@@ -29,10 +31,16 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "opencv-python" },
|
||||
{ name = "ruff" },
|
||||
{ name = "vulture" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "opencv-python", specifier = ">=4.12.0.88" }]
|
||||
requires-dist = [
|
||||
{ name = "opencv-python", specifier = ">=4.12.0.88" },
|
||||
{ name = "ruff", specifier = ">=0.12.12" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
@@ -78,3 +86,93 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131, upload-time = "2025-07-07T09:14:08.819Z" },
|
||||
{ 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"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vulture"
|
||||
version = "2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload-time = "2024-12-08T17:39:43.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload-time = "2024-12-08T17:39:40.573Z" },
|
||||
]
|
||||
|
Reference in New Issue
Block a user