devbits 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -12,6 +12,7 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: opencv-python>=4.8.0
14
14
  Requires-Dist: pillow>=10.0.0
15
+ Requires-Dist: pytest>=8.0.0
15
16
  Dynamic: license-file
16
17
 
17
18
  # devbits
@@ -1,3 +1,3 @@
1
1
  """devbits: A lightweight CLI toolkit for daily development utilities."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -157,7 +157,9 @@ def cmd_video2gif(args: argparse.Namespace) -> None:
157
157
 
158
158
  def cmd_clipvideo(args: argparse.Namespace) -> None:
159
159
  if args.gui:
160
- print("Warning: --gui is reserved for a future interactive clip selector; using CLI options now.")
160
+ from .gui import launch_gui
161
+ launch_gui(args.video)
162
+ return
161
163
  print(clip_video(ensure_exists(args.video), args.output, args.start, args.end, args.start_frame, args.end_frame))
162
164
 
163
165
 
@@ -0,0 +1,299 @@
1
+ import tkinter as tk
2
+ from tkinter import ttk, filedialog, messagebox
3
+ import cv2
4
+ from PIL import Image, ImageTk
5
+ from pathlib import Path
6
+ import threading
7
+
8
+ class VideoEditorGUI:
9
+ def __init__(self, root, video_path):
10
+ self.root = root
11
+ self.root.title("ClipVideo Editor")
12
+ self.root.geometry("1000x700")
13
+
14
+ self.video_path = Path(video_path) if video_path else None
15
+
16
+ # Clip data: list of dicts {'path': Path, 'start_f': int, 'end_f': int, 'speed': float}
17
+ self.clips = []
18
+ self.cap = None
19
+ self.fps = 30.0
20
+ self.total_frames = 0
21
+ self.width = 640
22
+ self.height = 480
23
+
24
+ if self.video_path:
25
+ self._load_video_info(self.video_path)
26
+
27
+ self.is_playing = False
28
+ self.current_frame = 0
29
+ self.selected_clip_index = -1
30
+
31
+ self._build_ui()
32
+ self._update_timeline()
33
+ self._show_frame()
34
+
35
+ def _load_video_info(self, path):
36
+ cap = cv2.VideoCapture(str(path))
37
+ if cap.isOpened():
38
+ self.fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
39
+ self.total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
40
+ self.width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
41
+ self.height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
42
+ self.clips = [{'path': path, 'start_f': 0, 'end_f': self.total_frames - 1, 'speed': 1.0}]
43
+ cap.release()
44
+
45
+ def _build_ui(self):
46
+ # Top: Video display
47
+ self.video_frame = ttk.Frame(self.root)
48
+ self.video_frame.pack(fill=tk.BOTH, expand=True, pady=10)
49
+
50
+ self.lbl_video = tk.Label(self.video_frame, bg="black")
51
+ self.lbl_video.pack(fill=tk.BOTH, expand=True)
52
+
53
+ # Middle: Controls
54
+ control_frame = ttk.Frame(self.root)
55
+ control_frame.pack(fill=tk.X, padx=10, pady=5)
56
+
57
+ ttk.Button(control_frame, text="Play/Pause", command=self.toggle_play).pack(side=tk.LEFT, padx=5)
58
+ ttk.Button(control_frame, text="Split", command=self.split_clip).pack(side=tk.LEFT, padx=5)
59
+ ttk.Button(control_frame, text="Delete Clip", command=self.delete_clip).pack(side=tk.LEFT, padx=5)
60
+ ttk.Button(control_frame, text="Move Left", command=lambda: self.move_clip(-1)).pack(side=tk.LEFT, padx=5)
61
+ ttk.Button(control_frame, text="Move Right", command=lambda: self.move_clip(1)).pack(side=tk.LEFT, padx=5)
62
+
63
+ ttk.Label(control_frame, text="Speed:").pack(side=tk.LEFT, padx=5)
64
+ self.speed_var = tk.DoubleVar(value=1.0)
65
+ self.speed_scale = ttk.Scale(control_frame, from_=0.25, to=4.0, variable=self.speed_var, command=self.change_speed)
66
+ self.speed_scale.pack(side=tk.LEFT, padx=5)
67
+
68
+ # Export controls
69
+ export_frame = ttk.Frame(control_frame)
70
+ export_frame.pack(side=tk.RIGHT, padx=5)
71
+ self.format_var = tk.StringVar(value="mp4")
72
+ ttk.Combobox(export_frame, textvariable=self.format_var, values=["mp4", "avi", "gif"], width=5).pack(side=tk.LEFT, padx=5)
73
+ ttk.Button(export_frame, text="Export", command=self.export_video).pack(side=tk.LEFT, padx=5)
74
+
75
+ # Bottom: Timeline
76
+ self.timeline_canvas = tk.Canvas(self.root, height=100, bg="gray20")
77
+ self.timeline_canvas.pack(fill=tk.X, padx=10, pady=10)
78
+ self.timeline_canvas.bind("<Button-1>", self.on_timeline_click)
79
+ self.timeline_canvas.bind("<B1-Motion>", self.on_timeline_drag)
80
+
81
+ def toggle_play(self):
82
+ self.is_playing = not self.is_playing
83
+ if self.is_playing:
84
+ self._play_loop()
85
+
86
+ def _play_loop(self):
87
+ if not self.is_playing:
88
+ return
89
+
90
+ self.current_frame += 1
91
+ total_len = sum((c['end_f'] - c['start_f']) / c['speed'] for c in self.clips)
92
+ if self.current_frame >= total_len:
93
+ self.current_frame = 0
94
+ self.is_playing = False
95
+ self._update_timeline()
96
+ self._show_frame()
97
+ return
98
+
99
+ self._show_frame()
100
+ self._update_timeline()
101
+
102
+ # Calculate delay based on current clip speed
103
+ delay = int(1000 / self.fps)
104
+ clip, _ = self._get_clip_at_frame(self.current_frame)
105
+ if clip:
106
+ delay = int(delay / clip['speed'])
107
+
108
+ self.root.after(delay, self._play_loop)
109
+
110
+ def _get_clip_at_frame(self, global_f):
111
+ acc = 0
112
+ for i, c in enumerate(self.clips):
113
+ c_len = (c['end_f'] - c['start_f']) / c['speed']
114
+ if acc <= global_f < acc + c_len:
115
+ local_f = c['start_f'] + (global_f - acc) * c['speed']
116
+ return c, int(local_f)
117
+ acc += c_len
118
+ return None, 0
119
+
120
+ def _show_frame(self):
121
+ if not self.clips:
122
+ return
123
+
124
+ clip, local_f = self._get_clip_at_frame(self.current_frame)
125
+ if not clip:
126
+ return
127
+
128
+ if self.cap is None or self.cap_path != clip['path']:
129
+ if self.cap:
130
+ self.cap.release()
131
+ self.cap = cv2.VideoCapture(str(clip['path']))
132
+ self.cap_path = clip['path']
133
+
134
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, local_f)
135
+ ok, frame = self.cap.read()
136
+ if ok:
137
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
138
+ # Resize for display
139
+ h, w = frame.shape[:2]
140
+ display_h = 400
141
+ display_w = int(w * (display_h / h))
142
+ frame = cv2.resize(frame, (display_w, display_h))
143
+
144
+ img = ImageTk.PhotoImage(image=Image.fromarray(frame))
145
+ self.lbl_video.config(image=img)
146
+ self.lbl_video.image = img
147
+
148
+ def _update_timeline(self):
149
+ self.timeline_canvas.delete("all")
150
+ if not self.clips:
151
+ return
152
+
153
+ w = self.timeline_canvas.winfo_width()
154
+ if w == 1: # Window not initialized fully
155
+ w = 1000
156
+
157
+ total_len = sum((c['end_f'] - c['start_f']) / c['speed'] for c in self.clips)
158
+ if total_len == 0:
159
+ return
160
+
161
+ x = 0
162
+ for i, c in enumerate(self.clips):
163
+ c_len = (c['end_f'] - c['start_f']) / c['speed']
164
+ c_w = (c_len / total_len) * w
165
+ color = "royalblue" if i == self.selected_clip_index else "steelblue"
166
+ self.timeline_canvas.create_rectangle(x, 10, x + c_w, 90, fill=color, outline="white", tags=f"clip_{i}")
167
+ self.timeline_canvas.create_text(x + c_w/2, 50, text=f"Clip {i+1}\n{c['speed']}x", fill="white")
168
+ x += c_w
169
+
170
+ # Draw playhead
171
+ playhead_x = (self.current_frame / total_len) * w
172
+ self.timeline_canvas.create_line(playhead_x, 0, playhead_x, 100, fill="red", width=2, tags="playhead")
173
+
174
+ def on_timeline_click(self, event):
175
+ w = self.timeline_canvas.winfo_width()
176
+ total_len = sum((c['end_f'] - c['start_f']) / c['speed'] for c in self.clips)
177
+
178
+ click_f = (event.x / w) * total_len
179
+ self.current_frame = max(0, min(click_f, total_len - 1))
180
+
181
+ # Find selected clip
182
+ acc = 0
183
+ self.selected_clip_index = -1
184
+ for i, c in enumerate(self.clips):
185
+ c_len = (c['end_f'] - c['start_f']) / c['speed']
186
+ if acc <= self.current_frame <= acc + c_len:
187
+ self.selected_clip_index = i
188
+ self.speed_var.set(c['speed'])
189
+ break
190
+ acc += c_len
191
+
192
+ self._update_timeline()
193
+ self._show_frame()
194
+
195
+ def on_timeline_drag(self, event):
196
+ self.on_timeline_click(event)
197
+
198
+ def change_speed(self, val):
199
+ if self.selected_clip_index != -1:
200
+ self.clips[self.selected_clip_index]['speed'] = round(float(val), 2)
201
+ self._update_timeline()
202
+
203
+ def split_clip(self):
204
+ if self.selected_clip_index == -1:
205
+ return
206
+
207
+ acc = 0
208
+ for i, c in enumerate(self.clips):
209
+ c_len = (c['end_f'] - c['start_f']) / c['speed']
210
+ if i == self.selected_clip_index:
211
+ global_offset = self.current_frame - acc
212
+ local_f = c['start_f'] + global_offset * c['speed']
213
+
214
+ # Create two clips
215
+ c1 = c.copy()
216
+ c1['end_f'] = int(local_f)
217
+
218
+ c2 = c.copy()
219
+ c2['start_f'] = int(local_f) + 1
220
+
221
+ if c1['end_f'] > c1['start_f'] and c2['end_f'] > c2['start_f']:
222
+ self.clips[i] = c1
223
+ self.clips.insert(i + 1, c2)
224
+ break
225
+ acc += c_len
226
+
227
+ self._update_timeline()
228
+
229
+ def delete_clip(self):
230
+ if self.selected_clip_index != -1 and len(self.clips) > 1:
231
+ del self.clips[self.selected_clip_index]
232
+ self.selected_clip_index = -1
233
+ self.current_frame = 0
234
+ self._update_timeline()
235
+ self._show_frame()
236
+
237
+ def move_clip(self, direction):
238
+ i = self.selected_clip_index
239
+ if i == -1: return
240
+ new_i = i + direction
241
+ if 0 <= new_i < len(self.clips):
242
+ self.clips[i], self.clips[new_i] = self.clips[new_i], self.clips[i]
243
+ self.selected_clip_index = new_i
244
+ self._update_timeline()
245
+
246
+ def export_video(self):
247
+ if not self.clips:
248
+ return
249
+
250
+ fmt = self.format_var.get()
251
+ out_path = filedialog.asksaveasfilename(defaultextension=f".{fmt}")
252
+ if not out_path:
253
+ return
254
+
255
+ def process():
256
+ from .media import images_to_gif
257
+ import tempfile
258
+
259
+ if fmt == "gif":
260
+ temp_dir = tempfile.mkdtemp()
261
+ temp_path = Path(temp_dir)
262
+ else:
263
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v") if fmt == "mp4" else cv2.VideoWriter_fourcc(*"XVID")
264
+ writer = cv2.VideoWriter(out_path, fourcc, self.fps, (self.width, self.height))
265
+
266
+ frame_idx = 0
267
+ for c in self.clips:
268
+ cap = cv2.VideoCapture(str(c['path']))
269
+ cap.set(cv2.CAP_PROP_POS_FRAMES, c['start_f'])
270
+ f = c['start_f']
271
+ while f <= c['end_f']:
272
+ ok, frame = cap.read()
273
+ if not ok: break
274
+
275
+ if fmt == "gif":
276
+ cv2.imwrite(str(temp_path / f"{frame_idx:06d}.jpg"), frame)
277
+ else:
278
+ writer.write(frame)
279
+
280
+ # Skip frames based on speed
281
+ f += c['speed']
282
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(f))
283
+ frame_idx += 1
284
+ cap.release()
285
+
286
+ if fmt == "gif":
287
+ images_to_gif(temp_path, Path(out_path), fps=self.fps)
288
+
289
+ elif fmt in ["mp4", "avi"]:
290
+ writer.release()
291
+
292
+ messagebox.showinfo("Export", "Export complete!")
293
+
294
+ threading.Thread(target=process, daemon=True).start()
295
+
296
+ def launch_gui(video_path: Path | None = None):
297
+ root = tk.Tk()
298
+ app = VideoEditorGUI(root, video_path)
299
+ root.mainloop()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -12,6 +12,7 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: opencv-python>=4.8.0
14
14
  Requires-Dist: pillow>=10.0.0
15
+ Requires-Dist: pytest>=8.0.0
15
16
  Dynamic: license-file
16
17
 
17
18
  # devbits
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  devbits/__init__.py
5
5
  devbits/cache.py
6
6
  devbits/cli.py
7
+ devbits/gui.py
7
8
  devbits/image.py
8
9
  devbits/media.py
9
10
  devbits/project.py
@@ -1,2 +1,3 @@
1
1
  opencv-python>=4.8.0
2
2
  pillow>=10.0.0
3
+ pytest>=8.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devbits"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "A lightweight CLI toolkit for daily development utilities."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -11,6 +11,7 @@ authors = [
11
11
  dependencies = [
12
12
  "opencv-python>=4.8.0",
13
13
  "pillow>=10.0.0",
14
+ "pytest>=8.0.0",
14
15
  ]
15
16
 
16
17
  [project.urls]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes