devbits 1.4.0__tar.gz → 1.4.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-1.4.0 → devbits-1.4.1}/PKG-INFO +7 -7
- {devbits-1.4.0 → devbits-1.4.1}/README.md +6 -6
- {devbits-1.4.0 → devbits-1.4.1}/devbits/__init__.py +1 -1
- {devbits-1.4.0 → devbits-1.4.1}/devbits/cli.py +18 -9
- {devbits-1.4.0 → devbits-1.4.1}/devbits/gui.py +313 -13
- {devbits-1.4.0 → devbits-1.4.1}/devbits/scripts.py +5 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/PKG-INFO +7 -7
- {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/entry_points.txt +1 -0
- {devbits-1.4.0 → devbits-1.4.1}/pyproject.toml +3 -2
- {devbits-1.4.0 → devbits-1.4.1}/tests/test_gui_cli.py +45 -9
- {devbits-1.4.0 → devbits-1.4.1}/LICENSE +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/cache.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/image.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/media.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/network.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/project.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/tui.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/utils.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits/wifi.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/SOURCES.txt +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/dependency_links.txt +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/requires.txt +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/top_level.txt +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/setup.cfg +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/tests/test_cli.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/tests/test_netsurvey.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/tests/test_tui.py +0 -0
- {devbits-1.4.0 → devbits-1.4.1}/tests/test_wifi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: A lightweight CLI toolkit for daily development utilities.
|
|
5
5
|
Author: Bruce Chuang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -42,8 +42,8 @@ devbits <command> [options]
|
|
|
42
42
|
Use `--help` on any command for detailed usage and parameter descriptions:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
devbits
|
|
46
|
-
|
|
45
|
+
devbits editvideo --help
|
|
46
|
+
editvideo --help
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
## Commands
|
|
@@ -52,7 +52,7 @@ clipvideo --help
|
|
|
52
52
|
|
|
53
53
|
| Command | Description |
|
|
54
54
|
|---------|-------------|
|
|
55
|
-
| `
|
|
55
|
+
| `editvideo` | Trim a video by time (seconds) or frame range. Includes `--gui` for browser-based editing. (Formerly `clipvideo`, which still works but prints a rename notice.) |
|
|
56
56
|
| `video2images` | Extract frames from a video. |
|
|
57
57
|
| `video2gif` | Convert a video (or a portion) to animated GIF. |
|
|
58
58
|
| `images2video` | Assemble an image sequence into an MP4 video. |
|
|
@@ -92,10 +92,10 @@ clipvideo --help
|
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
94
|
# Trim video from 5s to 20s
|
|
95
|
-
|
|
95
|
+
editvideo movie.mp4 --start 5.0 --end 20.0
|
|
96
96
|
|
|
97
97
|
# Open interactive clip editor in the browser
|
|
98
|
-
|
|
98
|
+
editvideo movie.mp4 --gui
|
|
99
99
|
|
|
100
100
|
# Convert video to GIF (3.5s–10s at 15 fps)
|
|
101
101
|
video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
|
|
@@ -290,7 +290,7 @@ dependency for one platform, devbits doesn't scan on macOS at all.
|
|
|
290
290
|
When `-o` / `--output` is omitted, the output filename is derived from the input:
|
|
291
291
|
|
|
292
292
|
```
|
|
293
|
-
|
|
293
|
+
editvideo movie.mp4 → movie_clip.mp4
|
|
294
294
|
video2gif movie.mp4 → movie.gif
|
|
295
295
|
resizeimage photo.jpg → photo_resized.jpg
|
|
296
296
|
recolor logo.png → logo_revised.png
|
|
@@ -25,8 +25,8 @@ devbits <command> [options]
|
|
|
25
25
|
Use `--help` on any command for detailed usage and parameter descriptions:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
devbits
|
|
29
|
-
|
|
28
|
+
devbits editvideo --help
|
|
29
|
+
editvideo --help
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Commands
|
|
@@ -35,7 +35,7 @@ clipvideo --help
|
|
|
35
35
|
|
|
36
36
|
| Command | Description |
|
|
37
37
|
|---------|-------------|
|
|
38
|
-
| `
|
|
38
|
+
| `editvideo` | Trim a video by time (seconds) or frame range. Includes `--gui` for browser-based editing. (Formerly `clipvideo`, which still works but prints a rename notice.) |
|
|
39
39
|
| `video2images` | Extract frames from a video. |
|
|
40
40
|
| `video2gif` | Convert a video (or a portion) to animated GIF. |
|
|
41
41
|
| `images2video` | Assemble an image sequence into an MP4 video. |
|
|
@@ -75,10 +75,10 @@ clipvideo --help
|
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
77
|
# Trim video from 5s to 20s
|
|
78
|
-
|
|
78
|
+
editvideo movie.mp4 --start 5.0 --end 20.0
|
|
79
79
|
|
|
80
80
|
# Open interactive clip editor in the browser
|
|
81
|
-
|
|
81
|
+
editvideo movie.mp4 --gui
|
|
82
82
|
|
|
83
83
|
# Convert video to GIF (3.5s–10s at 15 fps)
|
|
84
84
|
video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
|
|
@@ -273,7 +273,7 @@ dependency for one platform, devbits doesn't scan on macOS at all.
|
|
|
273
273
|
When `-o` / `--output` is omitted, the output filename is derived from the input:
|
|
274
274
|
|
|
275
275
|
```
|
|
276
|
-
|
|
276
|
+
editvideo movie.mp4 → movie_clip.mp4
|
|
277
277
|
video2gif movie.mp4 → movie.gif
|
|
278
278
|
resizeimage photo.jpg → photo_resized.jpg
|
|
279
279
|
recolor logo.png → logo_revised.png
|
|
@@ -195,20 +195,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
195
195
|
help="Output size as 'width,height' in pixels, e.g. 640,360.")
|
|
196
196
|
p.set_defaults(func=cmd_video2gif)
|
|
197
197
|
|
|
198
|
-
# ──
|
|
198
|
+
# ── editvideo ──────────────────────────────────────────────
|
|
199
199
|
p = sub.add_parser(
|
|
200
|
-
"
|
|
201
|
-
help="
|
|
200
|
+
"editvideo",
|
|
201
|
+
help="Edit (trim) a video by time range or frame range.",
|
|
202
202
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
203
203
|
description=(
|
|
204
204
|
"Trim a portion of a video. You can specify the range in seconds\n"
|
|
205
205
|
"(--start / --end) or in frame indices (--start-frame / --end-frame).\n"
|
|
206
206
|
"If no range is given, the interactive browser-based editor opens.\n\n"
|
|
207
207
|
"Examples:\n"
|
|
208
|
-
" devbits
|
|
209
|
-
" devbits
|
|
210
|
-
" devbits
|
|
211
|
-
" devbits
|
|
208
|
+
" devbits editvideo movie.mp4 # opens the GUI editor\n"
|
|
209
|
+
" devbits editvideo movie.mp4 --start 5.0 --end 20.0\n"
|
|
210
|
+
" devbits editvideo movie.mp4 --start-frame 150 --end-frame 600\n"
|
|
211
|
+
" devbits editvideo --gui # GUI with no initial video"
|
|
212
212
|
),
|
|
213
213
|
)
|
|
214
214
|
p.add_argument("video", type=Path, nargs="?", default=None,
|
|
@@ -225,7 +225,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
225
225
|
help="End frame index (0-based, inclusive).")
|
|
226
226
|
p.add_argument("--gui", action="store_true",
|
|
227
227
|
help="Open interactive browser-based clip editor.")
|
|
228
|
-
p.set_defaults(func=
|
|
228
|
+
p.set_defaults(func=cmd_editvideo)
|
|
229
229
|
|
|
230
230
|
# ── resizevideo ────────────────────────────────────────────
|
|
231
231
|
p = sub.add_parser(
|
|
@@ -700,7 +700,7 @@ def cmd_video2gif(args: argparse.Namespace) -> None:
|
|
|
700
700
|
print(video_to_gif(video, output, args.fps, args.start, args.end, args.size))
|
|
701
701
|
|
|
702
702
|
|
|
703
|
-
def
|
|
703
|
+
def cmd_editvideo(args: argparse.Namespace) -> None:
|
|
704
704
|
has_range = any(v is not None for v in (args.start, args.end, args.start_frame, args.end_frame))
|
|
705
705
|
# Open the interactive editor when --gui is set, or by default when a video
|
|
706
706
|
# is provided without an explicit trim range.
|
|
@@ -1141,6 +1141,15 @@ def cmd_wifi_forget(args: argparse.Namespace) -> None:
|
|
|
1141
1141
|
|
|
1142
1142
|
|
|
1143
1143
|
def main(argv: list[str] | None = None) -> int:
|
|
1144
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1145
|
+
if argv and argv[0] == "clipvideo":
|
|
1146
|
+
# Deprecated alias kept so existing invocations keep working.
|
|
1147
|
+
print(
|
|
1148
|
+
"Note: 'clipvideo' has been renamed to 'editvideo' and will be "
|
|
1149
|
+
"removed in a future release. Please use 'editvideo' instead.",
|
|
1150
|
+
file=sys.stderr,
|
|
1151
|
+
)
|
|
1152
|
+
argv[0] = "editvideo"
|
|
1144
1153
|
parser = build_parser()
|
|
1145
1154
|
args = parser.parse_args(argv)
|
|
1146
1155
|
try:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Web-based ClipChamp-style video editor GUI for devbits
|
|
1
|
+
"""Web-based ClipChamp-style video editor GUI for devbits editvideo.
|
|
2
2
|
|
|
3
3
|
Launches a local HTTP server and opens the browser. The frontend uses
|
|
4
4
|
native <video> playback for performance; the backend handles export via cv2.
|
|
@@ -32,7 +32,7 @@ _HTML = r"""<!DOCTYPE html>
|
|
|
32
32
|
<head>
|
|
33
33
|
<meta charset="utf-8">
|
|
34
34
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
35
|
-
<title>
|
|
35
|
+
<title>Devbits.EditVideo</title>
|
|
36
36
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA2NCA2NCc+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSdnJyB4MT0nMCcgeTE9JzAnIHgyPScxJyB5Mj0nMSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjN2M1Y2ZjJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjMDBkNGZmJy8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9JzY0JyBoZWlnaHQ9JzY0JyByeD0nMTUnIGZpbGw9J3VybCgjZyknLz48cGF0aCBkPSdNMjUgMTkgTDQ3IDMyIEwyNSA0NSBaJyBmaWxsPScjZmZmJy8+PC9zdmc+Cg==">
|
|
37
37
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
38
38
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
@@ -172,6 +172,7 @@ body{
|
|
|
172
172
|
.preview-area video{
|
|
173
173
|
max-width:100%;max-height:100%;object-fit:contain;
|
|
174
174
|
border-radius:4px;
|
|
175
|
+
transition:transform .2s ease;
|
|
175
176
|
}
|
|
176
177
|
.no-video{color:#555;font-size:1.1rem;text-align:center;line-height:2}
|
|
177
178
|
|
|
@@ -429,6 +430,38 @@ kbd{
|
|
|
429
430
|
.ctx-speed:hover{background:rgba(124,92,252,.35)}
|
|
430
431
|
.ctx-speed.active{background:#7c5cfc;color:#fff}
|
|
431
432
|
|
|
433
|
+
/* ── Crop Overlay ──────────────────────────────────────────── */
|
|
434
|
+
.crop-overlay{position:absolute;display:none;z-index:5;overflow:hidden}
|
|
435
|
+
.crop-overlay.show{display:block}
|
|
436
|
+
.crop-rect{
|
|
437
|
+
position:absolute;box-sizing:border-box;cursor:move;
|
|
438
|
+
border:1.5px solid #7c5cfc;
|
|
439
|
+
box-shadow:0 0 0 9999px rgba(0,0,0,.65);
|
|
440
|
+
background:
|
|
441
|
+
linear-gradient(rgba(255,255,255,.18),rgba(255,255,255,.18)) 33.3% 0/1px 100% no-repeat,
|
|
442
|
+
linear-gradient(rgba(255,255,255,.18),rgba(255,255,255,.18)) 66.6% 0/1px 100% no-repeat,
|
|
443
|
+
linear-gradient(rgba(255,255,255,.18),rgba(255,255,255,.18)) 0 33.3%/100% 1px no-repeat,
|
|
444
|
+
linear-gradient(rgba(255,255,255,.18),rgba(255,255,255,.18)) 0 66.6%/100% 1px no-repeat;
|
|
445
|
+
}
|
|
446
|
+
.crop-handle{
|
|
447
|
+
position:absolute;width:12px;height:12px;z-index:6;
|
|
448
|
+
background:#fff;border:2px solid #7c5cfc;border-radius:50%;box-sizing:border-box;
|
|
449
|
+
}
|
|
450
|
+
.crop-handle[data-dir="nw"]{left:-6px;top:-6px;cursor:nwse-resize}
|
|
451
|
+
.crop-handle[data-dir="n"]{left:50%;top:-6px;margin-left:-6px;cursor:ns-resize}
|
|
452
|
+
.crop-handle[data-dir="ne"]{right:-6px;top:-6px;cursor:nesw-resize}
|
|
453
|
+
.crop-handle[data-dir="e"]{right:-6px;top:50%;margin-top:-6px;cursor:ew-resize}
|
|
454
|
+
.crop-handle[data-dir="se"]{right:-6px;bottom:-6px;cursor:nwse-resize}
|
|
455
|
+
.crop-handle[data-dir="s"]{left:50%;bottom:-6px;margin-left:-6px;cursor:ns-resize}
|
|
456
|
+
.crop-handle[data-dir="sw"]{left:-6px;bottom:-6px;cursor:nesw-resize}
|
|
457
|
+
.crop-handle[data-dir="w"]{left:-6px;top:50%;margin-top:-6px;cursor:ew-resize}
|
|
458
|
+
.crop-size{
|
|
459
|
+
position:absolute;top:-26px;left:0;white-space:nowrap;
|
|
460
|
+
font-size:.72rem;color:#fff;background:rgba(0,0,0,.6);
|
|
461
|
+
padding:2px 7px;border-radius:4px;pointer-events:none;
|
|
462
|
+
}
|
|
463
|
+
.crop-actions{position:absolute;top:10px;right:10px;display:flex;gap:8px;z-index:7}
|
|
464
|
+
|
|
432
465
|
/* ── Responsive ────────────────────────────────────────────── */
|
|
433
466
|
@media(max-width:850px){
|
|
434
467
|
.main{flex-direction:column}
|
|
@@ -441,7 +474,7 @@ kbd{
|
|
|
441
474
|
<!-- Top Bar -->
|
|
442
475
|
<header class="topbar">
|
|
443
476
|
<div style="display:flex;align-items:center">
|
|
444
|
-
<span class="logo">✂
|
|
477
|
+
<span class="logo">✂ Devbits.EditVideo</span>
|
|
445
478
|
</div>
|
|
446
479
|
<div class="topbar-actions">
|
|
447
480
|
<button class="btn btn-primary" onclick="showExportModal()">⬇ Export</button>
|
|
@@ -471,6 +504,24 @@ kbd{
|
|
|
471
504
|
<small style="opacity:.5">Supports MP4, AVI, MOV, MKV, WebM</small>
|
|
472
505
|
</div>
|
|
473
506
|
<div class="drop-zone" id="dropZone"><span>Drop video file here</span></div>
|
|
507
|
+
<div class="crop-overlay" id="cropOverlay">
|
|
508
|
+
<div class="crop-rect" id="cropRect">
|
|
509
|
+
<span class="crop-size" id="cropSizeLabel"></span>
|
|
510
|
+
<div class="crop-handle" data-dir="nw"></div>
|
|
511
|
+
<div class="crop-handle" data-dir="n"></div>
|
|
512
|
+
<div class="crop-handle" data-dir="ne"></div>
|
|
513
|
+
<div class="crop-handle" data-dir="e"></div>
|
|
514
|
+
<div class="crop-handle" data-dir="se"></div>
|
|
515
|
+
<div class="crop-handle" data-dir="s"></div>
|
|
516
|
+
<div class="crop-handle" data-dir="sw"></div>
|
|
517
|
+
<div class="crop-handle" data-dir="w"></div>
|
|
518
|
+
</div>
|
|
519
|
+
<div class="crop-actions">
|
|
520
|
+
<button class="btn btn-ghost" onclick="resetCropDraft()" title="Select the full frame">Reset</button>
|
|
521
|
+
<button class="btn btn-ghost" onclick="cancelCrop()" title="Cancel (Esc)">Cancel</button>
|
|
522
|
+
<button class="btn btn-primary" onclick="applyCrop()" title="Apply (Enter)">✓ Apply</button>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
474
525
|
</div>
|
|
475
526
|
|
|
476
527
|
<!-- Playback Controls -->
|
|
@@ -505,6 +556,7 @@ kbd{
|
|
|
505
556
|
<div class="timeline-section">
|
|
506
557
|
<div class="timeline-toolbar">
|
|
507
558
|
<button class="btn btn-ghost" onclick="splitAtPlayhead()" title="Split (S)">✂ Split<kbd>S</kbd></button>
|
|
559
|
+
<button class="btn btn-ghost" onclick="toggleCropMode()" title="Crop (C)">⛶ Crop<kbd>C</kbd></button>
|
|
508
560
|
<button class="btn btn-danger" onclick="deleteSelected()" title="Delete (Del)">🗑 Delete<kbd>Del</kbd></button>
|
|
509
561
|
|
|
510
562
|
<div style="flex:1"></div>
|
|
@@ -575,7 +627,8 @@ kbd{
|
|
|
575
627
|
// ── State ──────────────────────────────────────────────────────
|
|
576
628
|
const video = document.getElementById('video');
|
|
577
629
|
let mediaLibrary = []; // {id, src, name, duration}
|
|
578
|
-
let clips = []; // {id, src, name, startTime, endTime, speed, duration, hue}
|
|
630
|
+
let clips = []; // {id, src, name, startTime, endTime, speed, duration, hue, crop}
|
|
631
|
+
// crop: {x, y, w, h} as fractions (0–1) of the source frame, or null
|
|
579
632
|
let selectedClipId = null;
|
|
580
633
|
let activeClipIndex = 0;
|
|
581
634
|
let timelineTime = 0;
|
|
@@ -996,6 +1049,7 @@ function loadVideoSource(src, seekTime, shouldPlay = false) {
|
|
|
996
1049
|
video.play().catch(e => console.log("Play interrupted:", e));
|
|
997
1050
|
}
|
|
998
1051
|
}
|
|
1052
|
+
applyPreviewCrop();
|
|
999
1053
|
}
|
|
1000
1054
|
|
|
1001
1055
|
function seekTimeline(t) {
|
|
@@ -1349,7 +1403,7 @@ function renderTimeline() {
|
|
|
1349
1403
|
|
|
1350
1404
|
const label = document.createElement('div');
|
|
1351
1405
|
label.className = 'clip-label';
|
|
1352
|
-
label.innerHTML = `${clip.name}<small>${clipDur.toFixed(1)}s · ${clip.speed}
|
|
1406
|
+
label.innerHTML = `${clip.name}<small>${clipDur.toFixed(1)}s · ${clip.speed}×${clip.crop ? ' · ⛶' : ''}</small>`;
|
|
1353
1407
|
el.appendChild(label);
|
|
1354
1408
|
|
|
1355
1409
|
const lh = document.createElement('div');
|
|
@@ -1388,7 +1442,7 @@ function layoutClips() {
|
|
|
1388
1442
|
el.style.left = (accTime * PX_PER_SEC + i * CLIP_GAP) + 'px';
|
|
1389
1443
|
el.style.width = (clipDur * PX_PER_SEC) + 'px';
|
|
1390
1444
|
const lbl = el.querySelector('.clip-label');
|
|
1391
|
-
if (lbl) lbl.innerHTML = `${clip.name}<small>${clipDur.toFixed(1)}s · ${clip.speed}
|
|
1445
|
+
if (lbl) lbl.innerHTML = `${clip.name}<small>${clipDur.toFixed(1)}s · ${clip.speed}×${clip.crop ? ' · ⛶' : ''}</small>`;
|
|
1392
1446
|
}
|
|
1393
1447
|
accTime += clipDur;
|
|
1394
1448
|
});
|
|
@@ -1539,6 +1593,172 @@ function deleteSelected() {
|
|
|
1539
1593
|
toast('Clip deleted');
|
|
1540
1594
|
}
|
|
1541
1595
|
|
|
1596
|
+
// ── Crop (spatial) ─────────────────────────────────────────────
|
|
1597
|
+
let cropMode = false;
|
|
1598
|
+
let cropDraft = null; // {x, y, w, h} fractions while editing
|
|
1599
|
+
let cropClipId = null;
|
|
1600
|
+
|
|
1601
|
+
// Displayed video content box inside #previewArea (object-fit: contain math).
|
|
1602
|
+
// Uses layout metrics (offset*), which ignore the preview-fit transform.
|
|
1603
|
+
function getVideoContentRect() {
|
|
1604
|
+
const vw = video.videoWidth, vh = video.videoHeight;
|
|
1605
|
+
if (!vw || !vh) return null;
|
|
1606
|
+
const ew = video.offsetWidth, eh = video.offsetHeight;
|
|
1607
|
+
const scale = Math.min(ew / vw, eh / vh);
|
|
1608
|
+
const cw = vw * scale, ch = vh * scale;
|
|
1609
|
+
return {
|
|
1610
|
+
left: video.offsetLeft + (ew - cw) / 2, // offsetParent is #previewArea (position:relative)
|
|
1611
|
+
top: video.offsetTop + (eh - ch) / 2,
|
|
1612
|
+
width: cw, height: ch,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function toggleCropMode() {
|
|
1617
|
+
if (cropMode) { cancelCrop(); return; }
|
|
1618
|
+
if (clips.length === 0 || !video.videoWidth) { toast('Load a clip first'); return; }
|
|
1619
|
+
const clip = clips.find(c => c.id === selectedClipId) || clips[activeClipIndex];
|
|
1620
|
+
if (!clip) return;
|
|
1621
|
+
selectClip(clip.id);
|
|
1622
|
+
video.pause();
|
|
1623
|
+
cropMode = true;
|
|
1624
|
+
cropClipId = clip.id;
|
|
1625
|
+
cropDraft = clip.crop ? { ...clip.crop } : { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
|
|
1626
|
+
// Show the full, untransformed frame while choosing the region.
|
|
1627
|
+
video.style.clipPath = '';
|
|
1628
|
+
video.style.transform = '';
|
|
1629
|
+
video.style.transformOrigin = '';
|
|
1630
|
+
document.getElementById('cropOverlay').classList.add('show');
|
|
1631
|
+
layoutCropOverlay();
|
|
1632
|
+
toast('Drag to choose the region — Enter to apply, Esc to cancel');
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function layoutCropOverlay() {
|
|
1636
|
+
const r = getVideoContentRect();
|
|
1637
|
+
if (!r || !cropDraft) return;
|
|
1638
|
+
const ov = document.getElementById('cropOverlay');
|
|
1639
|
+
ov.style.left = r.left + 'px';
|
|
1640
|
+
ov.style.top = r.top + 'px';
|
|
1641
|
+
ov.style.width = r.width + 'px';
|
|
1642
|
+
ov.style.height = r.height + 'px';
|
|
1643
|
+
const rect = document.getElementById('cropRect');
|
|
1644
|
+
rect.style.left = (cropDraft.x * r.width) + 'px';
|
|
1645
|
+
rect.style.top = (cropDraft.y * r.height) + 'px';
|
|
1646
|
+
rect.style.width = (cropDraft.w * r.width) + 'px';
|
|
1647
|
+
rect.style.height = (cropDraft.h * r.height) + 'px';
|
|
1648
|
+
document.getElementById('cropSizeLabel').textContent =
|
|
1649
|
+
Math.round(cropDraft.w * video.videoWidth) + '×' + Math.round(cropDraft.h * video.videoHeight);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function startCropDrag(e, dir) {
|
|
1653
|
+
e.preventDefault();
|
|
1654
|
+
e.stopPropagation();
|
|
1655
|
+
const r = getVideoContentRect();
|
|
1656
|
+
if (!r) return;
|
|
1657
|
+
const sx = e.clientX, sy = e.clientY;
|
|
1658
|
+
const o = { ...cropDraft };
|
|
1659
|
+
const MIN = 0.05;
|
|
1660
|
+
const onMove = ev => {
|
|
1661
|
+
const dx = (ev.clientX - sx) / r.width;
|
|
1662
|
+
const dy = (ev.clientY - sy) / r.height;
|
|
1663
|
+
let { x, y, w, h } = o;
|
|
1664
|
+
if (dir === 'move') {
|
|
1665
|
+
x = Math.min(Math.max(0, o.x + dx), 1 - o.w);
|
|
1666
|
+
y = Math.min(Math.max(0, o.y + dy), 1 - o.h);
|
|
1667
|
+
} else {
|
|
1668
|
+
if (dir.includes('w')) { const nx = Math.min(Math.max(0, o.x + dx), o.x + o.w - MIN); w = o.w + (o.x - nx); x = nx; }
|
|
1669
|
+
if (dir.includes('e')) { w = Math.min(Math.max(MIN, o.w + dx), 1 - o.x); }
|
|
1670
|
+
if (dir.includes('n')) { const ny = Math.min(Math.max(0, o.y + dy), o.y + o.h - MIN); h = o.h + (o.y - ny); y = ny; }
|
|
1671
|
+
if (dir.includes('s')) { h = Math.min(Math.max(MIN, o.h + dy), 1 - o.y); }
|
|
1672
|
+
}
|
|
1673
|
+
cropDraft = { x, y, w, h };
|
|
1674
|
+
layoutCropOverlay();
|
|
1675
|
+
};
|
|
1676
|
+
const onUp = () => {
|
|
1677
|
+
document.removeEventListener('mousemove', onMove);
|
|
1678
|
+
document.removeEventListener('mouseup', onUp);
|
|
1679
|
+
};
|
|
1680
|
+
document.addEventListener('mousemove', onMove);
|
|
1681
|
+
document.addEventListener('mouseup', onUp);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function resetCropDraft() {
|
|
1685
|
+
cropDraft = { x: 0, y: 0, w: 1, h: 1 };
|
|
1686
|
+
layoutCropOverlay();
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function applyCrop() {
|
|
1690
|
+
const clip = clips.find(c => c.id === cropClipId);
|
|
1691
|
+
if (clip && cropDraft) {
|
|
1692
|
+
const full = cropDraft.x <= 0.001 && cropDraft.y <= 0.001 && cropDraft.w >= 0.998 && cropDraft.h >= 0.998;
|
|
1693
|
+
clip.crop = full ? null : {
|
|
1694
|
+
x: +cropDraft.x.toFixed(4), y: +cropDraft.y.toFixed(4),
|
|
1695
|
+
w: +cropDraft.w.toFixed(4), h: +cropDraft.h.toFixed(4),
|
|
1696
|
+
};
|
|
1697
|
+
toast(clip.crop ? 'Crop applied' : 'Crop removed');
|
|
1698
|
+
}
|
|
1699
|
+
exitCropMode();
|
|
1700
|
+
renderTimeline();
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function removeCrop(clipId) {
|
|
1704
|
+
const clip = clips.find(c => c.id === clipId);
|
|
1705
|
+
if (!clip) return;
|
|
1706
|
+
clip.crop = null;
|
|
1707
|
+
applyPreviewCrop();
|
|
1708
|
+
renderTimeline();
|
|
1709
|
+
toast('Crop removed');
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function cancelCrop() { exitCropMode(); }
|
|
1713
|
+
|
|
1714
|
+
function exitCropMode() {
|
|
1715
|
+
cropMode = false;
|
|
1716
|
+
cropClipId = null;
|
|
1717
|
+
cropDraft = null;
|
|
1718
|
+
document.getElementById('cropOverlay').classList.remove('show');
|
|
1719
|
+
applyPreviewCrop();
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Show only the cropped region of the active clip in the preview,
|
|
1723
|
+
// scaled up to fit the preview area and centered.
|
|
1724
|
+
function applyPreviewCrop() {
|
|
1725
|
+
const clip = clips[activeClipIndex];
|
|
1726
|
+
if (!cropMode && clip && clip.crop && video.videoWidth) {
|
|
1727
|
+
const c = clip.crop;
|
|
1728
|
+
video.style.clipPath =
|
|
1729
|
+
`inset(${(c.y * 100).toFixed(2)}% ${((1 - c.x - c.w) * 100).toFixed(2)}% ` +
|
|
1730
|
+
`${((1 - c.y - c.h) * 100).toFixed(2)}% ${(c.x * 100).toFixed(2)}%)`;
|
|
1731
|
+
const pv = document.getElementById('previewArea');
|
|
1732
|
+
const ew = video.offsetWidth, eh = video.offsetHeight; // layout size, transform-independent
|
|
1733
|
+
if (ew && eh) {
|
|
1734
|
+
const s = Math.min(pv.clientWidth / (c.w * ew), pv.clientHeight / (c.h * eh));
|
|
1735
|
+
const cx = (c.x + c.w / 2) * ew, cy = (c.y + c.h / 2) * eh;
|
|
1736
|
+
// Scale about the crop's center, then move that center to the preview's
|
|
1737
|
+
// center (the untransformed element is already centered by flexbox).
|
|
1738
|
+
video.style.transformOrigin = `${cx.toFixed(2)}px ${cy.toFixed(2)}px`;
|
|
1739
|
+
video.style.transform =
|
|
1740
|
+
`translate(${(ew / 2 - cx).toFixed(2)}px, ${(eh / 2 - cy).toFixed(2)}px) scale(${s.toFixed(4)})`;
|
|
1741
|
+
}
|
|
1742
|
+
} else {
|
|
1743
|
+
video.style.clipPath = '';
|
|
1744
|
+
video.style.transform = '';
|
|
1745
|
+
video.style.transformOrigin = '';
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
// Element size settles once metadata is known — re-fit then.
|
|
1749
|
+
video.addEventListener('loadedmetadata', applyPreviewCrop);
|
|
1750
|
+
|
|
1751
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1752
|
+
const rect = document.getElementById('cropRect');
|
|
1753
|
+
rect.addEventListener('mousedown', e => {
|
|
1754
|
+
if (e.target.classList.contains('crop-handle')) return;
|
|
1755
|
+
startCropDrag(e, 'move');
|
|
1756
|
+
});
|
|
1757
|
+
document.querySelectorAll('.crop-handle').forEach(h =>
|
|
1758
|
+
h.addEventListener('mousedown', e => startCropDrag(e, h.dataset.dir)));
|
|
1759
|
+
});
|
|
1760
|
+
window.addEventListener('resize', () => { if (cropMode) layoutCropOverlay(); else applyPreviewCrop(); });
|
|
1761
|
+
|
|
1542
1762
|
// ── Clip Context Menu ──────────────────────────────────────────
|
|
1543
1763
|
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4];
|
|
1544
1764
|
let ctxMenuEl = null;
|
|
@@ -1570,6 +1790,20 @@ function showClipContextMenu(e, clipId, clipEl) {
|
|
|
1570
1790
|
split.onclick = () => { closeContextMenu(); splitClipAt(clipId, splitTime); };
|
|
1571
1791
|
menu.appendChild(split);
|
|
1572
1792
|
|
|
1793
|
+
const cropItem = document.createElement('div');
|
|
1794
|
+
cropItem.className = 'ctx-item';
|
|
1795
|
+
cropItem.innerHTML = `<span>⛶</span><span>${clip.crop ? 'Edit crop' : 'Crop'}</span><kbd>C</kbd>`;
|
|
1796
|
+
cropItem.onclick = () => { closeContextMenu(); selectClip(clipId); toggleCropMode(); };
|
|
1797
|
+
menu.appendChild(cropItem);
|
|
1798
|
+
|
|
1799
|
+
if (clip.crop) {
|
|
1800
|
+
const uncrop = document.createElement('div');
|
|
1801
|
+
uncrop.className = 'ctx-item';
|
|
1802
|
+
uncrop.innerHTML = `<span>⊘</span><span>Remove crop</span>`;
|
|
1803
|
+
uncrop.onclick = () => { closeContextMenu(); removeCrop(clipId); };
|
|
1804
|
+
menu.appendChild(uncrop);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1573
1807
|
const del = document.createElement('div');
|
|
1574
1808
|
del.className = 'ctx-item danger';
|
|
1575
1809
|
del.innerHTML = `<span>🗑</span><span>Delete</span><kbd>Del</kbd>`;
|
|
@@ -1667,7 +1901,8 @@ async function doExport() {
|
|
|
1667
1901
|
src: c.src,
|
|
1668
1902
|
startTime: c.startTime,
|
|
1669
1903
|
endTime: c.endTime,
|
|
1670
|
-
speed: c.speed
|
|
1904
|
+
speed: c.speed,
|
|
1905
|
+
crop: c.crop || null
|
|
1671
1906
|
})),
|
|
1672
1907
|
format: fmt,
|
|
1673
1908
|
filename: filename,
|
|
@@ -1698,10 +1933,17 @@ async function doExport() {
|
|
|
1698
1933
|
// ── Keyboard Shortcuts ─────────────────────────────────────────
|
|
1699
1934
|
document.addEventListener('keydown', e => {
|
|
1700
1935
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
|
1936
|
+
if (cropMode) {
|
|
1937
|
+
// Modal crop editing: Enter applies, Esc cancels, everything else is inert.
|
|
1938
|
+
if (e.code === 'Enter') { e.preventDefault(); applyCrop(); }
|
|
1939
|
+
else if (e.code === 'Escape') { cancelCrop(); }
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1701
1942
|
switch (e.code) {
|
|
1702
1943
|
case 'Escape': closeContextMenu(); break;
|
|
1703
1944
|
case 'Space': e.preventDefault(); togglePlay(); break;
|
|
1704
1945
|
case 'KeyS': splitAtPlayhead(); break;
|
|
1946
|
+
case 'KeyC': toggleCropMode(); break;
|
|
1705
1947
|
case 'Delete': case 'Backspace': deleteSelected(); break;
|
|
1706
1948
|
case 'ArrowLeft':
|
|
1707
1949
|
e.preventDefault();
|
|
@@ -1784,6 +2026,36 @@ def _atempo_factors(speed: float) -> list[float]:
|
|
|
1784
2026
|
return factors
|
|
1785
2027
|
|
|
1786
2028
|
|
|
2029
|
+
def _norm_crop(crop) -> dict | None:
|
|
2030
|
+
"""Validate a clip's crop spec ({x, y, w, h} as 0–1 fractions of the frame).
|
|
2031
|
+
|
|
2032
|
+
Returns a clamped copy, or None when absent, malformed, or a no-op
|
|
2033
|
+
(covering effectively the whole frame).
|
|
2034
|
+
"""
|
|
2035
|
+
if not isinstance(crop, dict):
|
|
2036
|
+
return None
|
|
2037
|
+
try:
|
|
2038
|
+
x = min(max(float(crop["x"]), 0.0), 0.95)
|
|
2039
|
+
y = min(max(float(crop["y"]), 0.0), 0.95)
|
|
2040
|
+
w = min(max(float(crop["w"]), 0.02), 1.0 - x)
|
|
2041
|
+
h = min(max(float(crop["h"]), 0.02), 1.0 - y)
|
|
2042
|
+
except (KeyError, TypeError, ValueError):
|
|
2043
|
+
return None
|
|
2044
|
+
if x <= 0.001 and y <= 0.001 and w >= 0.998 and h >= 0.998:
|
|
2045
|
+
return None
|
|
2046
|
+
return {"x": x, "y": y, "w": w, "h": h}
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
def _crop_frame(frame, crop: dict):
|
|
2050
|
+
"""Slice a BGR frame to a normalized crop region (fractions of the frame)."""
|
|
2051
|
+
fh, fw = frame.shape[:2]
|
|
2052
|
+
x1 = min(int(fw * crop["x"]), fw - 2)
|
|
2053
|
+
y1 = min(int(fh * crop["y"]), fh - 2)
|
|
2054
|
+
x2 = min(fw, x1 + max(2, int(fw * crop["w"])))
|
|
2055
|
+
y2 = min(fh, y1 + max(2, int(fh * crop["h"])))
|
|
2056
|
+
return frame[y1:y2, x1:x2]
|
|
2057
|
+
|
|
2058
|
+
|
|
1787
2059
|
class _Handler(BaseHTTPRequestHandler):
|
|
1788
2060
|
"""Request handler for the clip editor."""
|
|
1789
2061
|
|
|
@@ -1970,6 +2242,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
1970
2242
|
self._json_response({"error": "No clips"}, 400)
|
|
1971
2243
|
return
|
|
1972
2244
|
|
|
2245
|
+
for c in clip_defs:
|
|
2246
|
+
c["crop"] = _norm_crop(c.get("crop"))
|
|
2247
|
+
|
|
1973
2248
|
try:
|
|
1974
2249
|
if _ffmpeg_bin():
|
|
1975
2250
|
try:
|
|
@@ -2021,6 +2296,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
2021
2296
|
if orig_w <= 0 or orig_h <= 0:
|
|
2022
2297
|
raise RuntimeError("Could not determine source dimensions")
|
|
2023
2298
|
|
|
2299
|
+
# A crop on the first clip defines the output aspect ratio.
|
|
2300
|
+
first_crop = clip_defs[0].get("crop")
|
|
2301
|
+
if first_crop:
|
|
2302
|
+
orig_w = max(2, int(orig_w * first_crop["w"]))
|
|
2303
|
+
orig_h = max(2, int(orig_h * first_crop["h"]))
|
|
2304
|
+
|
|
2024
2305
|
if resolution > 0:
|
|
2025
2306
|
w = resolution
|
|
2026
2307
|
h = int(orig_h * (resolution / orig_w))
|
|
@@ -2052,9 +2333,15 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
2052
2333
|
end = float(c["endTime"])
|
|
2053
2334
|
speed = float(c.get("speed", 1.0)) or 1.0
|
|
2054
2335
|
vlbl = f"v{i}"
|
|
2336
|
+
crop = c.get("crop")
|
|
2337
|
+
cropf = (
|
|
2338
|
+
f"crop=iw*{crop['w']:.4f}:ih*{crop['h']:.4f}"
|
|
2339
|
+
f":iw*{crop['x']:.4f}:ih*{crop['y']:.4f},"
|
|
2340
|
+
if crop else ""
|
|
2341
|
+
)
|
|
2055
2342
|
parts.append(
|
|
2056
2343
|
f"[{i}:v]trim=start={start}:end={end},setpts=(PTS-STARTPTS)/{speed},"
|
|
2057
|
-
f"fps={out_fps},scale={w}:{h}:flags=bicubic,setsar=1[{vlbl}]"
|
|
2344
|
+
f"{cropf}fps={out_fps},scale={w}:{h}:flags=bicubic,setsar=1[{vlbl}]"
|
|
2058
2345
|
)
|
|
2059
2346
|
concat_labels.append(f"[{vlbl}]")
|
|
2060
2347
|
if want_audio:
|
|
@@ -2153,16 +2440,23 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
2153
2440
|
orig_h = int(first_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
2154
2441
|
first_cap.release()
|
|
2155
2442
|
|
|
2443
|
+
# A crop on the first clip defines the output aspect ratio.
|
|
2444
|
+
first_crop = clip_defs[0].get("crop")
|
|
2445
|
+
if first_crop and orig_w > 0:
|
|
2446
|
+
orig_w = max(2, int(orig_w * first_crop["w"]))
|
|
2447
|
+
orig_h = max(2, int(orig_h * first_crop["h"]))
|
|
2448
|
+
|
|
2156
2449
|
# Apply resolution scaling
|
|
2157
2450
|
if resolution > 0 and orig_w > 0:
|
|
2158
2451
|
scale = resolution / orig_w
|
|
2159
2452
|
w = resolution
|
|
2160
2453
|
h = int(orig_h * scale)
|
|
2161
|
-
# Ensure even dimensions for video codecs
|
|
2162
|
-
h = h + (h % 2)
|
|
2163
2454
|
else:
|
|
2164
2455
|
w = orig_w
|
|
2165
2456
|
h = orig_h
|
|
2457
|
+
# Ensure even dimensions for video codecs
|
|
2458
|
+
w = w + (w % 2)
|
|
2459
|
+
h = h + (h % 2)
|
|
2166
2460
|
|
|
2167
2461
|
# Pre-calculate total frames for progress tracking
|
|
2168
2462
|
total_frames = 0
|
|
@@ -2201,6 +2495,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
2201
2495
|
speed = clip.get("speed", 1.0)
|
|
2202
2496
|
sample_every = max(1, int(round(speed)))
|
|
2203
2497
|
|
|
2498
|
+
crop = clip.get("crop")
|
|
2204
2499
|
cap.set(cv2.CAP_PROP_POS_FRAMES, start_f)
|
|
2205
2500
|
fi = start_f
|
|
2206
2501
|
while fi <= end_f:
|
|
@@ -2208,6 +2503,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
2208
2503
|
if not ok:
|
|
2209
2504
|
break
|
|
2210
2505
|
if (fi - start_f) % sample_every == 0:
|
|
2506
|
+
if crop:
|
|
2507
|
+
frame = _crop_frame(frame, crop)
|
|
2211
2508
|
if frame.shape[1] != w or frame.shape[0] != h:
|
|
2212
2509
|
frame = cv2.resize(frame, (w, h))
|
|
2213
2510
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
@@ -2255,6 +2552,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
2255
2552
|
speed = clip.get("speed", 1.0)
|
|
2256
2553
|
step = max(1.0, speed)
|
|
2257
2554
|
|
|
2555
|
+
crop = clip.get("crop")
|
|
2258
2556
|
# Read sequentially (decoders are optimized for forward reads);
|
|
2259
2557
|
# seeking per-frame with cap.set() is orders of magnitude slower.
|
|
2260
2558
|
cap.set(cv2.CAP_PROP_POS_FRAMES, start_f)
|
|
@@ -2265,6 +2563,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
2265
2563
|
if not ok:
|
|
2266
2564
|
break
|
|
2267
2565
|
if fi >= next_write - 1e-9:
|
|
2566
|
+
if crop:
|
|
2567
|
+
frame = _crop_frame(frame, crop)
|
|
2268
2568
|
if frame.shape[1] != w or frame.shape[0] != h:
|
|
2269
2569
|
frame = cv2.resize(frame, (w, h))
|
|
2270
2570
|
writer.write(frame)
|
|
@@ -2301,9 +2601,9 @@ def _find_free_port() -> int:
|
|
|
2301
2601
|
|
|
2302
2602
|
|
|
2303
2603
|
def launch_gui(video_path: Path | None = None) -> None:
|
|
2304
|
-
"""Launch the
|
|
2604
|
+
"""Launch the EditVideo web editor."""
|
|
2305
2605
|
port = _find_free_port()
|
|
2306
|
-
upload_dir = Path(tempfile.mkdtemp(prefix="
|
|
2606
|
+
upload_dir = Path(tempfile.mkdtemp(prefix="editvideo_uploads_"))
|
|
2307
2607
|
export_dir = Path.cwd()
|
|
2308
2608
|
|
|
2309
2609
|
# Resolve to absolute so relative paths work for file serving
|
|
@@ -2317,7 +2617,7 @@ def launch_gui(video_path: Path | None = None) -> None:
|
|
|
2317
2617
|
server = _ThreadedHTTPServer(("127.0.0.1", port), _Handler)
|
|
2318
2618
|
url = f"http://127.0.0.1:{port}"
|
|
2319
2619
|
|
|
2320
|
-
print(f"
|
|
2620
|
+
print(f"EditVideo Editor running at {url}")
|
|
2321
2621
|
print("Press Ctrl+C to stop.")
|
|
2322
2622
|
|
|
2323
2623
|
# Open browser
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: A lightweight CLI toolkit for daily development utilities.
|
|
5
5
|
Author: Bruce Chuang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -42,8 +42,8 @@ devbits <command> [options]
|
|
|
42
42
|
Use `--help` on any command for detailed usage and parameter descriptions:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
devbits
|
|
46
|
-
|
|
45
|
+
devbits editvideo --help
|
|
46
|
+
editvideo --help
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
## Commands
|
|
@@ -52,7 +52,7 @@ clipvideo --help
|
|
|
52
52
|
|
|
53
53
|
| Command | Description |
|
|
54
54
|
|---------|-------------|
|
|
55
|
-
| `
|
|
55
|
+
| `editvideo` | Trim a video by time (seconds) or frame range. Includes `--gui` for browser-based editing. (Formerly `clipvideo`, which still works but prints a rename notice.) |
|
|
56
56
|
| `video2images` | Extract frames from a video. |
|
|
57
57
|
| `video2gif` | Convert a video (or a portion) to animated GIF. |
|
|
58
58
|
| `images2video` | Assemble an image sequence into an MP4 video. |
|
|
@@ -92,10 +92,10 @@ clipvideo --help
|
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
94
|
# Trim video from 5s to 20s
|
|
95
|
-
|
|
95
|
+
editvideo movie.mp4 --start 5.0 --end 20.0
|
|
96
96
|
|
|
97
97
|
# Open interactive clip editor in the browser
|
|
98
|
-
|
|
98
|
+
editvideo movie.mp4 --gui
|
|
99
99
|
|
|
100
100
|
# Convert video to GIF (3.5s–10s at 15 fps)
|
|
101
101
|
video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
|
|
@@ -290,7 +290,7 @@ dependency for one platform, devbits doesn't scan on macOS at all.
|
|
|
290
290
|
When `-o` / `--output` is omitted, the output filename is derived from the input:
|
|
291
291
|
|
|
292
292
|
```
|
|
293
|
-
|
|
293
|
+
editvideo movie.mp4 → movie_clip.mp4
|
|
294
294
|
video2gif movie.mp4 → movie.gif
|
|
295
295
|
resizeimage photo.jpg → photo_resized.jpg
|
|
296
296
|
recolor logo.png → logo_revised.png
|
|
@@ -5,6 +5,7 @@ clearcache = devbits.scripts:clearcache
|
|
|
5
5
|
clipvideo = devbits.scripts:clipvideo
|
|
6
6
|
contactsheet = devbits.scripts:contactsheet
|
|
7
7
|
devbits = devbits.cli:main
|
|
8
|
+
editvideo = devbits.scripts:editvideo
|
|
8
9
|
image2ico = devbits.scripts:image2ico
|
|
9
10
|
images2gif = devbits.scripts:images2gif
|
|
10
11
|
images2video = devbits.scripts:images2video
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devbits"
|
|
3
|
-
version = "1.4.
|
|
3
|
+
version = "1.4.1"
|
|
4
4
|
description = "A lightweight CLI toolkit for daily development utilities."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -26,7 +26,8 @@ images2video = "devbits.scripts:images2video"
|
|
|
26
26
|
video2images = "devbits.scripts:video2images"
|
|
27
27
|
images2gif = "devbits.scripts:images2gif"
|
|
28
28
|
video2gif = "devbits.scripts:video2gif"
|
|
29
|
-
|
|
29
|
+
editvideo = "devbits.scripts:editvideo"
|
|
30
|
+
clipvideo = "devbits.scripts:clipvideo" # deprecated alias for editvideo
|
|
30
31
|
resizevideo = "devbits.scripts:resizevideo"
|
|
31
32
|
image2ico = "devbits.scripts:image2ico"
|
|
32
33
|
resizeimage = "devbits.scripts:resizeimage"
|
|
@@ -3,29 +3,38 @@ from unittest.mock import patch
|
|
|
3
3
|
import pytest
|
|
4
4
|
from devbits.cli import main
|
|
5
5
|
|
|
6
|
-
def
|
|
7
|
-
# Running
|
|
8
|
-
exit_code = main(["
|
|
6
|
+
def test_editvideo_no_gui_requires_video(capsys) -> None:
|
|
7
|
+
# Running editvideo without GUI should return exit code 1 if video is missing
|
|
8
|
+
exit_code = main(["editvideo"])
|
|
9
9
|
assert exit_code == 1
|
|
10
10
|
captured = capsys.readouterr()
|
|
11
11
|
assert "video path is required when --gui is not specified" in captured.err
|
|
12
12
|
|
|
13
|
-
def
|
|
14
|
-
# Running
|
|
13
|
+
def test_editvideo_with_gui_no_video() -> None:
|
|
14
|
+
# Running editvideo with --gui and no video should launch GUI (which we mock)
|
|
15
15
|
with patch("devbits.gui.launch_gui") as mock_launch:
|
|
16
|
-
main(["
|
|
16
|
+
main(["editvideo", "--gui"])
|
|
17
17
|
mock_launch.assert_called_once_with(None)
|
|
18
18
|
|
|
19
|
-
def
|
|
20
|
-
# Running
|
|
19
|
+
def test_editvideo_with_gui_and_video(tmp_path) -> None:
|
|
20
|
+
# Running editvideo with --gui and a video should launch GUI with that path
|
|
21
21
|
video_file = tmp_path / "sample.mp4"
|
|
22
22
|
video_file.touch()
|
|
23
23
|
with patch("devbits.gui.launch_gui") as mock_launch:
|
|
24
|
-
main(["
|
|
24
|
+
main(["editvideo", "--gui", str(video_file)])
|
|
25
25
|
mock_launch.assert_called_once()
|
|
26
26
|
called_arg = mock_launch.call_args[0][0]
|
|
27
27
|
assert called_arg.name == "sample.mp4"
|
|
28
28
|
|
|
29
|
+
def test_clipvideo_alias_prints_rename_notice(capsys) -> None:
|
|
30
|
+
# The old clipvideo name still works but prints a rename notice
|
|
31
|
+
with patch("devbits.gui.launch_gui") as mock_launch:
|
|
32
|
+
exit_code = main(["clipvideo", "--gui"])
|
|
33
|
+
mock_launch.assert_called_once_with(None)
|
|
34
|
+
assert exit_code == 0
|
|
35
|
+
captured = capsys.readouterr()
|
|
36
|
+
assert "'clipvideo' has been renamed to 'editvideo'" in captured.err
|
|
37
|
+
|
|
29
38
|
def test_gui_upload(tmp_path) -> None:
|
|
30
39
|
from devbits.gui import _Handler
|
|
31
40
|
import http.server
|
|
@@ -83,3 +92,30 @@ def test_gui_upload(tmp_path) -> None:
|
|
|
83
92
|
finally:
|
|
84
93
|
server.shutdown()
|
|
85
94
|
server.server_close()
|
|
95
|
+
|
|
96
|
+
def test_norm_crop_valid_and_clamped() -> None:
|
|
97
|
+
from devbits.gui import _norm_crop
|
|
98
|
+
c = _norm_crop({"x": 0.25, "y": 0.1, "w": 0.5, "h": 0.5})
|
|
99
|
+
assert c == {"x": 0.25, "y": 0.1, "w": 0.5, "h": 0.5}
|
|
100
|
+
# Out-of-range values are clamped so x+w and y+h stay within the frame
|
|
101
|
+
c = _norm_crop({"x": 0.8, "y": 0.8, "w": 0.9, "h": 0.9})
|
|
102
|
+
assert c["x"] + c["w"] <= 1.0 and c["y"] + c["h"] <= 1.0
|
|
103
|
+
|
|
104
|
+
def test_norm_crop_rejects_noop_and_malformed() -> None:
|
|
105
|
+
from devbits.gui import _norm_crop
|
|
106
|
+
assert _norm_crop(None) is None
|
|
107
|
+
assert _norm_crop("junk") is None
|
|
108
|
+
assert _norm_crop({"x": 0}) is None
|
|
109
|
+
assert _norm_crop({"x": "a", "y": 0, "w": 1, "h": 1}) is None
|
|
110
|
+
# Full-frame crop is a no-op
|
|
111
|
+
assert _norm_crop({"x": 0, "y": 0, "w": 1, "h": 1}) is None
|
|
112
|
+
|
|
113
|
+
def test_crop_frame_slices_region() -> None:
|
|
114
|
+
import numpy as np
|
|
115
|
+
from devbits.gui import _crop_frame
|
|
116
|
+
frame = np.zeros((100, 200, 3), dtype=np.uint8)
|
|
117
|
+
out = _crop_frame(frame, {"x": 0.25, "y": 0.1, "w": 0.5, "h": 0.5})
|
|
118
|
+
assert out.shape[:2] == (50, 100)
|
|
119
|
+
# Tiny crops still yield at least 2px in each dimension
|
|
120
|
+
out = _crop_frame(frame, {"x": 0.99, "y": 0.99, "w": 0.001, "h": 0.001})
|
|
121
|
+
assert out.shape[0] >= 2 and out.shape[1] >= 2
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|