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.
Files changed (28) hide show
  1. {devbits-1.4.0 → devbits-1.4.1}/PKG-INFO +7 -7
  2. {devbits-1.4.0 → devbits-1.4.1}/README.md +6 -6
  3. {devbits-1.4.0 → devbits-1.4.1}/devbits/__init__.py +1 -1
  4. {devbits-1.4.0 → devbits-1.4.1}/devbits/cli.py +18 -9
  5. {devbits-1.4.0 → devbits-1.4.1}/devbits/gui.py +313 -13
  6. {devbits-1.4.0 → devbits-1.4.1}/devbits/scripts.py +5 -0
  7. {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/PKG-INFO +7 -7
  8. {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/entry_points.txt +1 -0
  9. {devbits-1.4.0 → devbits-1.4.1}/pyproject.toml +3 -2
  10. {devbits-1.4.0 → devbits-1.4.1}/tests/test_gui_cli.py +45 -9
  11. {devbits-1.4.0 → devbits-1.4.1}/LICENSE +0 -0
  12. {devbits-1.4.0 → devbits-1.4.1}/devbits/cache.py +0 -0
  13. {devbits-1.4.0 → devbits-1.4.1}/devbits/image.py +0 -0
  14. {devbits-1.4.0 → devbits-1.4.1}/devbits/media.py +0 -0
  15. {devbits-1.4.0 → devbits-1.4.1}/devbits/network.py +0 -0
  16. {devbits-1.4.0 → devbits-1.4.1}/devbits/project.py +0 -0
  17. {devbits-1.4.0 → devbits-1.4.1}/devbits/tui.py +0 -0
  18. {devbits-1.4.0 → devbits-1.4.1}/devbits/utils.py +0 -0
  19. {devbits-1.4.0 → devbits-1.4.1}/devbits/wifi.py +0 -0
  20. {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/SOURCES.txt +0 -0
  21. {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/dependency_links.txt +0 -0
  22. {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/requires.txt +0 -0
  23. {devbits-1.4.0 → devbits-1.4.1}/devbits.egg-info/top_level.txt +0 -0
  24. {devbits-1.4.0 → devbits-1.4.1}/setup.cfg +0 -0
  25. {devbits-1.4.0 → devbits-1.4.1}/tests/test_cli.py +0 -0
  26. {devbits-1.4.0 → devbits-1.4.1}/tests/test_netsurvey.py +0 -0
  27. {devbits-1.4.0 → devbits-1.4.1}/tests/test_tui.py +0 -0
  28. {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.0
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 clipvideo --help
46
- clipvideo --help
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
- | `clipvideo` | Trim a video by time (seconds) or frame range. Includes `--gui` for browser-based editing. |
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
- clipvideo movie.mp4 --start 5.0 --end 20.0
95
+ editvideo movie.mp4 --start 5.0 --end 20.0
96
96
 
97
97
  # Open interactive clip editor in the browser
98
- clipvideo movie.mp4 --gui
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
- clipvideo movie.mp4 → movie_clip.mp4
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 clipvideo --help
29
- clipvideo --help
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
- | `clipvideo` | Trim a video by time (seconds) or frame range. Includes `--gui` for browser-based editing. |
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
- clipvideo movie.mp4 --start 5.0 --end 20.0
78
+ editvideo movie.mp4 --start 5.0 --end 20.0
79
79
 
80
80
  # Open interactive clip editor in the browser
81
- clipvideo movie.mp4 --gui
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
- clipvideo movie.mp4 → movie_clip.mp4
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
@@ -1,3 +1,3 @@
1
1
  """devbits: A lightweight CLI toolkit for daily development utilities."""
2
2
 
3
- __version__ = "1.4.0"
3
+ __version__ = "1.4.1"
@@ -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
- # ── clipvideo ──────────────────────────────────────────────
198
+ # ── editvideo ──────────────────────────────────────────────
199
199
  p = sub.add_parser(
200
- "clipvideo",
201
- help="Clip (trim) a video by time range or frame range.",
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 clipvideo movie.mp4 # opens the GUI editor\n"
209
- " devbits clipvideo movie.mp4 --start 5.0 --end 20.0\n"
210
- " devbits clipvideo movie.mp4 --start-frame 150 --end-frame 600\n"
211
- " devbits clipvideo --gui # GUI with no initial video"
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=cmd_clipvideo)
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 cmd_clipvideo(args: argparse.Namespace) -> None:
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 clipvideo.
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>Divbits.ClipVideo</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">✂ Divbits.ClipVideo</span>
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}×</small>`;
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}×</small>`;
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 ClipVideo web editor."""
2604
+ """Launch the EditVideo web editor."""
2305
2605
  port = _find_free_port()
2306
- upload_dir = Path(tempfile.mkdtemp(prefix="clipvideo_uploads_"))
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"ClipVideo Editor running at {url}")
2620
+ print(f"EditVideo Editor running at {url}")
2321
2621
  print("Press Ctrl+C to stop.")
2322
2622
 
2323
2623
  # Open browser
@@ -31,7 +31,12 @@ def video2gif() -> int:
31
31
  return _run("video2gif")
32
32
 
33
33
 
34
+ def editvideo() -> int:
35
+ return _run("editvideo")
36
+
37
+
34
38
  def clipvideo() -> int:
39
+ # Deprecated alias; main() prints the rename notice.
35
40
  return _run("clipvideo")
36
41
 
37
42
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.4.0
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 clipvideo --help
46
- clipvideo --help
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
- | `clipvideo` | Trim a video by time (seconds) or frame range. Includes `--gui` for browser-based editing. |
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
- clipvideo movie.mp4 --start 5.0 --end 20.0
95
+ editvideo movie.mp4 --start 5.0 --end 20.0
96
96
 
97
97
  # Open interactive clip editor in the browser
98
- clipvideo movie.mp4 --gui
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
- clipvideo movie.mp4 → movie_clip.mp4
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.0"
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
- clipvideo = "devbits.scripts:clipvideo"
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 test_clipvideo_no_gui_requires_video(capsys) -> None:
7
- # Running clipvideo without GUI should return exit code 1 if video is missing
8
- exit_code = main(["clipvideo"])
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 test_clipvideo_with_gui_no_video() -> None:
14
- # Running clipvideo with --gui and no video should launch GUI (which we mock)
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(["clipvideo", "--gui"])
16
+ main(["editvideo", "--gui"])
17
17
  mock_launch.assert_called_once_with(None)
18
18
 
19
- def test_clipvideo_with_gui_and_video(tmp_path) -> None:
20
- # Running clipvideo with --gui and a video should launch GUI with that path
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(["clipvideo", "--gui", str(video_file)])
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