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.
- {devbits-0.1.0 → devbits-0.1.1}/PKG-INFO +2 -1
- {devbits-0.1.0 → devbits-0.1.1}/devbits/__init__.py +1 -1
- {devbits-0.1.0 → devbits-0.1.1}/devbits/cli.py +3 -1
- devbits-0.1.1/devbits/gui.py +299 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits.egg-info/PKG-INFO +2 -1
- {devbits-0.1.0 → devbits-0.1.1}/devbits.egg-info/SOURCES.txt +1 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits.egg-info/requires.txt +1 -0
- {devbits-0.1.0 → devbits-0.1.1}/pyproject.toml +2 -1
- {devbits-0.1.0 → devbits-0.1.1}/LICENSE +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/README.md +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits/cache.py +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits/image.py +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits/media.py +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits/project.py +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits/scripts.py +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits/utils.py +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits.egg-info/dependency_links.txt +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits.egg-info/entry_points.txt +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/devbits.egg-info/top_level.txt +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/setup.cfg +0 -0
- {devbits-0.1.0 → devbits-0.1.1}/tests/test_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
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,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devbits"
|
|
3
|
-
version = "0.1.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|