Skip to content

Commit 014d993

Browse files
committed
feat: update version to 4.0.0 and enhance image processing features with new conversion, resizing, rotation, and cropping functionalities
1 parent 662c1ae commit 014d993

File tree

6 files changed

+266
-19
lines changed

6 files changed

+266
-19
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "peg_this"
7-
version = "3.0.6"
7+
version = "4.0.0"
88
authors = [
99
{ name="Hariharen S S", email="thisishariharen@gmail.com" },
1010
]

src/peg_this/features/convert.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,135 @@ def convert_file(file_path):
9191
os.remove(f"palette_{Path(file_path).stem}.png")
9292

9393
questionary.press_any_key_to_continue().ask()
94+
95+
96+
def convert_image(file_path):
97+
"""Convert an image to a different format."""
98+
output_format = questionary.select(
99+
"Select the output format:",
100+
choices=["jpg", "png", "webp", "bmp", "tiff"],
101+
use_indicator=True
102+
).ask()
103+
if not output_format: return
104+
105+
output_file = f"{Path(file_path).stem}_converted.{output_format}"
106+
kwargs = {'y': None}
107+
108+
# For JPG and WEBP, allow quality selection
109+
if output_format in ['jpg', 'webp']:
110+
quality_preset = questionary.select(
111+
"Select quality preset:",
112+
choices=["High (95%)", "Medium (80%)", "Low (60%)"],
113+
use_indicator=True
114+
).ask()
115+
if not quality_preset: return
116+
117+
quality_map = {"High (95%)": "95", "Medium (80%)": "80", "Low (60%)": "60"}
118+
quality = quality_map[quality_preset]
119+
120+
if output_format == 'jpg':
121+
q_scale = int(31 - (int(quality) / 100.0) * 30)
122+
kwargs['q:v'] = q_scale
123+
elif output_format == 'webp':
124+
kwargs['quality'] = quality
125+
126+
stream = ffmpeg.input(file_path).output(output_file, **kwargs)
127+
128+
if run_command(stream, f"Converting to {output_format.upper()}..."):
129+
console.print(f"[bold green]Successfully converted image to {output_file}[/bold green]")
130+
else:
131+
console.print("[bold red]Image conversion failed.[/bold red]")
132+
133+
questionary.press_any_key_to_continue().ask()
134+
135+
136+
def resize_image(file_path):
137+
"""Resize an image to new dimensions."""
138+
console.print("Enter new dimensions. Use [bold]-1[/bold] for one dimension to preserve aspect ratio.")
139+
width = questionary.text("Enter new width (e.g., 1280 or -1):").ask()
140+
if not width: return
141+
height = questionary.text("Enter new height (e.g., 720 or -1):").ask()
142+
if not height: return
143+
144+
try:
145+
if int(width) == -1 and int(height) == -1:
146+
console.print("[bold red]Error: Width and Height cannot both be -1.[/bold red]")
147+
questionary.press_any_key_to_continue().ask()
148+
return
149+
except ValueError:
150+
console.print("[bold red]Error: Invalid dimensions. Please enter numbers.[/bold red]")
151+
questionary.press_any_key_to_continue().ask()
152+
return
153+
154+
output_file = f"{Path(file_path).stem}_resized{Path(file_path).suffix}"
155+
156+
stream = ffmpeg.input(file_path).filter('scale', w=width, h=height).output(output_file, y=None)
157+
158+
if run_command(stream, "Resizing image..."):
159+
console.print(f"[bold green]Successfully resized image to {output_file}[/bold green]")
160+
else:
161+
console.print("[bold red]Image resizing failed.[/bold red]")
162+
163+
questionary.press_any_key_to_continue().ask()
164+
165+
166+
def rotate_image(file_path):
167+
"""Rotate an image."""
168+
rotation = questionary.select(
169+
"Select rotation:",
170+
choices=[
171+
"90 degrees clockwise",
172+
"90 degrees counter-clockwise",
173+
"180 degrees"
174+
],
175+
use_indicator=True
176+
).ask()
177+
if not rotation: return
178+
179+
output_file = f"{Path(file_path).stem}_rotated{Path(file_path).suffix}"
180+
181+
stream = ffmpeg.input(file_path)
182+
if rotation == "90 degrees clockwise":
183+
stream = stream.filter('transpose', 1)
184+
elif rotation == "90 degrees counter-clockwise":
185+
stream = stream.filter('transpose', 2)
186+
elif rotation == "180 degrees":
187+
# Apply 90-degree rotation twice for 180 degrees
188+
stream = stream.filter('transpose', 2).filter('transpose', 2)
189+
190+
output_stream = stream.output(output_file, y=None)
191+
192+
if run_command(output_stream, "Rotating image..."):
193+
console.print(f"[bold green]Successfully rotated image and saved to {output_file}[/bold green]")
194+
else:
195+
console.print("[bold red]Image rotation failed.[/bold red]")
196+
197+
questionary.press_any_key_to_continue().ask()
198+
199+
200+
def flip_image(file_path):
201+
"""Flip an image horizontally or vertically."""
202+
flip_direction = questionary.select(
203+
"Select flip direction:",
204+
choices=["Horizontal", "Vertical"],
205+
use_indicator=True
206+
).ask()
207+
if not flip_direction: return
208+
209+
output_file = f"{Path(file_path).stem}_flipped{Path(file_path).suffix}"
210+
211+
stream = ffmpeg.input(file_path)
212+
if flip_direction == "Horizontal":
213+
stream = stream.filter('hflip')
214+
else:
215+
stream = stream.filter('vflip')
216+
217+
output_stream = stream.output(output_file, y=None)
218+
219+
if run_command(output_stream, "Flipping image..."):
220+
console.print(f"[bold green]Successfully flipped image and saved to {output_file}[/bold green]")
221+
else:
222+
console.print("[bold red]Image flipping failed.[/bold red]")
223+
224+
questionary.press_any_key_to_continue().ask()
225+

src/peg_this/features/crop.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,75 @@ def on_drag(event):
104104
if os.path.exists(preview_frame):
105105
os.remove(preview_frame)
106106
questionary.press_any_key_to_continue().ask()
107+
108+
109+
def crop_image(file_path):
110+
"""Visually crop an image by selecting an area."""
111+
if not tk:
112+
console.print("[bold red]Cannot perform visual cropping: tkinter & Pillow are not installed.[/bold red]")
113+
console.print("Please install them with: [bold]pip install tk Pillow[/bold]")
114+
questionary.press_any_key_to_continue().ask()
115+
return
116+
117+
try:
118+
# --- Tkinter GUI for Cropping ---
119+
root = tk.Tk()
120+
root.title("Crop Image - Drag to select area, close window to confirm")
121+
root.attributes("-topmost", True)
122+
123+
img = Image.open(file_path)
124+
125+
max_width = root.winfo_screenwidth() - 100
126+
max_height = root.winfo_screenheight() - 100
127+
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
128+
129+
img_tk = ImageTk.PhotoImage(img)
130+
131+
canvas = tk.Canvas(root, width=img.width, height=img.height, cursor="cross")
132+
canvas.pack()
133+
canvas.create_image(0, 0, anchor=tk.NW, image=img_tk)
134+
135+
rect_coords = {"x1": 0, "y1": 0, "x2": 0, "y2": 0}
136+
rect_id = None
137+
138+
def on_press(event):
139+
nonlocal rect_id
140+
rect_coords['x1'], rect_coords['y1'] = event.x, event.y
141+
rect_id = canvas.create_rectangle(0, 0, 1, 1, outline='red', width=2)
142+
143+
def on_drag(event):
144+
rect_coords['x2'], rect_coords['y2'] = event.x, event.y
145+
canvas.coords(rect_id, rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2'])
146+
147+
canvas.bind("<ButtonPress-1>", on_press)
148+
canvas.bind("<B1-Motion>", on_drag)
149+
150+
messagebox.showinfo("Instructions", "Click and drag to draw a cropping rectangle.\nClose this window when you are done.", parent=root)
151+
root.mainloop()
152+
153+
# --- Cropping Logic ---
154+
crop_w = abs(rect_coords['x2'] - rect_coords['x1'])
155+
crop_h = abs(rect_coords['y2'] - rect_coords['y1'])
156+
crop_x = min(rect_coords['x1'], rect_coords['x2'])
157+
crop_y = min(rect_coords['y1'], rect_coords['y2'])
158+
159+
if crop_w < 2 or crop_h < 2:
160+
console.print("[bold yellow]Cropping cancelled as no valid area was selected.[/bold yellow]")
161+
questionary.press_any_key_to_continue().ask()
162+
return
163+
164+
console.print(f"Selected crop area: [bold]width={crop_w} height={crop_h} at (x={crop_x}, y={crop_y})[/bold]")
165+
166+
output_file = f"{Path(file_path).stem}_cropped{Path(file_path).suffix}"
167+
168+
stream = ffmpeg.input(file_path).filter('crop', w=crop_w, h=crop_h, x=crop_x, y=crop_y).output(output_file, y=None)
169+
170+
if run_command(stream, "Applying crop to image..."):
171+
console.print(f"[bold green]Successfully cropped image and saved to {output_file}[/bold green]")
172+
else:
173+
console.print("[bold red]Image cropping failed.[/bold red]")
174+
175+
except Exception as e:
176+
console.print(f"[bold red]An error occurred during cropping: {e}[/bold red]")
177+
finally:
178+
questionary.press_any_key_to_continue().ask()

src/peg_this/peg_this.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import os
22
import sys
33
import logging
4+
from pathlib import Path
45
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
56

67
import questionary
78
from rich.console import Console
89

910
from peg_this.features.audio import extract_audio, remove_audio
1011
from peg_this.features.batch import batch_convert
11-
from peg_this.features.convert import convert_file
12-
from peg_this.features.crop import crop_video
12+
from peg_this.features.convert import convert_file, convert_image, resize_image, rotate_image, flip_image
13+
from peg_this.features.crop import crop_video, crop_image
1314
from peg_this.features.inspect import inspect_file
1415
from peg_this.features.join import join_videos
1516
from peg_this.features.trim import trim_video
@@ -29,9 +30,44 @@
2930

3031
# Initialize Rich Console
3132
console = Console()
33+
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"]
3234
# --- End Global Configuration ---
3335

3436

37+
def image_action_menu(file_path):
38+
"""Display the menu of actions for a selected image file."""
39+
while True:
40+
console.rule(f"[bold]Actions for Image: {os.path.basename(file_path)}[/bold]")
41+
action = questionary.select(
42+
"Choose an action:",
43+
choices=[
44+
"Inspect File Details",
45+
"Convert Format",
46+
"Resize",
47+
"Rotate",
48+
"Flip",
49+
"Crop (Visual)",
50+
questionary.Separator(),
51+
"Back to File List"
52+
],
53+
use_indicator=True
54+
).ask()
55+
56+
if action is None or action == "Back to File List":
57+
break
58+
59+
actions = {
60+
"Inspect File Details": inspect_file,
61+
"Convert Format": convert_image,
62+
"Resize": resize_image,
63+
"Rotate": rotate_image,
64+
"Flip": flip_image,
65+
"Crop (Visual)": crop_image,
66+
}
67+
if action in actions:
68+
actions[action](file_path)
69+
70+
3571
def action_menu(file_path):
3672
"""Display the menu of actions for a selected file."""
3773
while True:
@@ -90,7 +126,10 @@ def main_menu():
90126
elif choice == "Process a Single Media File":
91127
selected_file = select_media_file()
92128
if selected_file:
93-
action_menu(selected_file)
129+
if Path(selected_file).suffix.lower() in IMAGE_EXTENSIONS:
130+
image_action_menu(selected_file)
131+
else:
132+
action_menu(selected_file)
94133
elif choice == "Join Multiple Videos":
95134
join_videos()
96135
elif choice == "Batch Convert All Media in Directory":

src/peg_this/utils/ffmpeg_utils.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,37 +37,34 @@ def run_command(stream_spec, description="Processing...", show_progress=False):
3737
Runs an ffmpeg command using ffmpeg-python.
3838
- For simple commands, it runs directly.
3939
- For commands with a progress bar, it generates the ffmpeg arguments,
40-
runs them as a subprocess, and parses stderr to show progress,
41-
mimicking the logic from the original script for accuracy.
40+
runs them as a subprocess, and parses stderr to show progress.
41+
Returns True on success, False on failure.
4242
"""
4343
console.print(f"[bold cyan]{description}[/bold cyan]")
4444

45-
# Get the full command arguments from the ffmpeg-python stream object
4645
args = stream_spec.get_args()
4746
full_command = ['ffmpeg'] + args
4847
logging.info(f"Executing command: {' '.join(full_command)}")
4948

5049
if not show_progress:
5150
try:
5251
# Use ffmpeg.run() for simple, non-progress tasks. It's cleaner.
53-
out, err = ffmpeg.run(stream_spec, capture_stdout=True, capture_stderr=True, quiet=True)
52+
ffmpeg.run(stream_spec, capture_stdout=True, capture_stderr=True, quiet=True)
5453
logging.info("Command successful (no progress bar).")
55-
return out.decode('utf-8')
54+
return True
5655
except ffmpeg.Error as e:
5756
error_message = e.stderr.decode('utf-8')
5857
console.print("[bold red]An error occurred:[/bold red]")
5958
console.print(error_message)
6059
logging.error(f"ffmpeg error:{error_message}")
61-
return None
60+
return False
6261
else:
6362
# For the progress bar, we must run ffmpeg as a subprocess and parse stderr.
6463
duration = 0
6564
try:
66-
# Find the primary input file from the command arguments to probe it.
6765
input_file_path = None
6866
for i, arg in enumerate(full_command):
6967
if arg == '-i' and i + 1 < len(full_command):
70-
# This is a robust way to find the first input file.
7168
input_file_path = full_command[i+1]
7269
break
7370

@@ -90,7 +87,6 @@ def run_command(stream_spec, description="Processing...", show_progress=False):
9087
) as progress:
9188
task = progress.add_task(description, total=100)
9289

93-
# Run the command as a subprocess to capture stderr in real-time
9490
process = subprocess.Popen(
9591
full_command,
9692
stdout=subprocess.PIPE,
@@ -110,19 +106,18 @@ def run_command(stream_spec, description="Processing...", show_progress=False):
110106
percent_complete = (elapsed_time / duration) * 100
111107
progress.update(task, completed=min(percent_complete, 100))
112108
except Exception:
113-
pass # Ignore any parsing errors
109+
pass
114110

115111
process.wait()
116112
progress.update(task, completed=100)
117113

118114
if process.returncode != 0:
119-
# The error was already logged line-by-line, but we can add a final message.
120115
log_file = logging.getLogger().handlers[0].baseFilename
121116
console.print(f"[bold red]An error occurred during processing. Check {log_file} for details.[/bold red]")
122-
return None
117+
return False
123118

124119
logging.info("Command successful (with progress bar).")
125-
return "Success"
120+
return True
126121

127122

128123
def has_audio_stream(file_path):

src/peg_this/utils/ui_utils.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@
1616

1717
def get_media_files():
1818
"""Scan the current directory for media files."""
19-
media_extensions = [".mkv", ".mp4", ".avi", ".mov", ".webm", ".flv", ".wmv", ".mp3", ".flac", ".wav", ".ogg", ".gif"]
19+
media_extensions = [
20+
# Video
21+
".mkv", ".mp4", ".avi", ".mov", ".webm", ".flv", ".wmv",
22+
# Audio
23+
".mp3", ".flac", ".wav", ".ogg",
24+
# GIF
25+
".gif",
26+
# Image
27+
".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"
28+
]
2029
files = [f for f in os.listdir('.') if os.path.isfile(f) and Path(f).suffix.lower() in media_extensions]
2130
return files
2231

@@ -31,7 +40,7 @@ def select_media_file():
3140
root.withdraw()
3241
file_path = filedialog.askopenfilename(
3342
title="Select a media file",
34-
filetypes=[("Media Files", "*.mkv *.mp4 *.avi *.mov *.webm *.flv *.wmv *.mp3 *.flac *.wav *.ogg *.gif"), ("All Files", "*.*")]
43+
filetypes=[("Media Files", "*.mkv *.mp4 *.avi *.mov *.webm *.flv *.wmv *.mp3 *.flac *.wav *.ogg *.gif *.jpg *.jpeg *.png *.webp *.bmp *.tiff"), ("All Files", "*.*")]
3544
)
3645
return file_path if file_path else None
3746
return None

0 commit comments

Comments
 (0)