devbits 1.0.0__tar.gz → 1.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.0.0
3
+ Version: 1.1.2
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -64,6 +64,7 @@ clipvideo --help
64
64
  | Command | Description |
65
65
  |---------|-------------|
66
66
  | `resizeimage` | Resize a single image (preserves aspect ratio by default). |
67
+ | `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
67
68
  | `image2ico` | Convert an image to a multi-size ICO file. |
68
69
  | `batchimages` | Batch resize or convert all images in a folder. |
69
70
  | `checkimages` | Scan for broken / corrupt image files. |
@@ -94,6 +95,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
94
95
  # Extract every 5th frame as PNG
95
96
  video2images movie.mp4 --every 5 --format png
96
97
 
98
+ # Recolor a logo's foreground to black (keeps the background)
99
+ recolor logo.png
100
+
101
+ # Recolor a logo's foreground to a custom color (hex or R,G,B)
102
+ recolor logo.png --color '#1a73e8'
103
+ recolor logo.png --color 0,178,179
104
+
97
105
  # Batch resize images to 800×600
98
106
  batchimages ./photos -o ./resized --size 800,600
99
107
 
@@ -109,6 +117,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
109
117
  clipvideo movie.mp4 → movie_clip.mp4
110
118
  video2gif movie.mp4 → movie.gif
111
119
  resizeimage photo.jpg → photo_resized.jpg
120
+ recolor logo.png → logo_revised.png
112
121
  contactsheet ./photos → photos_sheet.jpg
113
122
  ```
114
123
 
@@ -47,6 +47,7 @@ clipvideo --help
47
47
  | Command | Description |
48
48
  |---------|-------------|
49
49
  | `resizeimage` | Resize a single image (preserves aspect ratio by default). |
50
+ | `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
50
51
  | `image2ico` | Convert an image to a multi-size ICO file. |
51
52
  | `batchimages` | Batch resize or convert all images in a folder. |
52
53
  | `checkimages` | Scan for broken / corrupt image files. |
@@ -77,6 +78,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
77
78
  # Extract every 5th frame as PNG
78
79
  video2images movie.mp4 --every 5 --format png
79
80
 
81
+ # Recolor a logo's foreground to black (keeps the background)
82
+ recolor logo.png
83
+
84
+ # Recolor a logo's foreground to a custom color (hex or R,G,B)
85
+ recolor logo.png --color '#1a73e8'
86
+ recolor logo.png --color 0,178,179
87
+
80
88
  # Batch resize images to 800×600
81
89
  batchimages ./photos -o ./resized --size 800,600
82
90
 
@@ -92,6 +100,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
92
100
  clipvideo movie.mp4 → movie_clip.mp4
93
101
  video2gif movie.mp4 → movie.gif
94
102
  resizeimage photo.jpg → photo_resized.jpg
103
+ recolor logo.png → logo_revised.png
95
104
  contactsheet ./photos → photos_sheet.jpg
96
105
  ```
97
106
 
@@ -1,3 +1,3 @@
1
1
  """devbits: A lightweight CLI toolkit for daily development utilities."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.2"
@@ -4,8 +4,9 @@ import argparse
4
4
  import sys
5
5
  from pathlib import Path
6
6
 
7
+ from . import __version__
7
8
  from .cache import clear_cache
8
- from .image import batch_images, check_images, contact_sheet, image_to_ico, resize_image
9
+ from .image import batch_images, check_images, contact_sheet, image_to_ico, recolor_image, resize_image
9
10
  from .media import clip_video, images_to_gif, images_to_video, resize_video, video_to_gif, video_to_images
10
11
  from .project import print_tree, rename_files, sample_files, top_sizes
11
12
  from .utils import ensure_exists
@@ -32,7 +33,7 @@ def build_parser() -> argparse.ArgumentParser:
32
33
  description="Daily development utility CLI toolkit.",
33
34
  formatter_class=argparse.RawDescriptionHelpFormatter,
34
35
  )
35
- parser.add_argument("--version", action="version", version="devbits 0.1.0")
36
+ parser.add_argument("--version", action="version", version=f"devbits {__version__}")
36
37
  sub = parser.add_subparsers(dest="command", required=True)
37
38
 
38
39
  # ── clearcache ─────────────────────────────────────────────
@@ -154,12 +155,12 @@ def build_parser() -> argparse.ArgumentParser:
154
155
  description=(
155
156
  "Trim a portion of a video. You can specify the range in seconds\n"
156
157
  "(--start / --end) or in frame indices (--start-frame / --end-frame).\n"
157
- "If both are omitted, the full video is copied.\n\n"
158
- "Use --gui to open the interactive browser-based clip editor.\n\n"
158
+ "If no range is given, the interactive browser-based editor opens.\n\n"
159
159
  "Examples:\n"
160
+ " devbits clipvideo movie.mp4 # opens the GUI editor\n"
160
161
  " devbits clipvideo movie.mp4 --start 5.0 --end 20.0\n"
161
162
  " devbits clipvideo movie.mp4 --start-frame 150 --end-frame 600\n"
162
- " devbits clipvideo movie.mp4 --gui"
163
+ " devbits clipvideo --gui # GUI with no initial video"
163
164
  ),
164
165
  )
165
166
  p.add_argument("video", type=Path, nargs="?", default=None,
@@ -237,6 +238,32 @@ def build_parser() -> argparse.ArgumentParser:
237
238
  help="Do not preserve aspect ratio; stretch to exact size.")
238
239
  p.set_defaults(func=cmd_resizeimage)
239
240
 
241
+ # ── recolor ────────────────────────────────────────────────
242
+ p = sub.add_parser(
243
+ "recolor",
244
+ help="Recolor the foreground of a logo / icon image.",
245
+ formatter_class=argparse.RawDescriptionHelpFormatter,
246
+ description=(
247
+ "Recolor a logo or icon. The background (transparent or a lighter\n"
248
+ "surrounding color) is detected automatically and left untouched,\n"
249
+ "while every foreground pixel is repainted with the target color.\n"
250
+ "The result is always saved as an RGBA PNG.\n\n"
251
+ "Examples:\n"
252
+ " devbits recolor logo.png\n"
253
+ " devbits recolor logo.png --color '#1a73e8'\n"
254
+ " devbits recolor logo.png --color 0,178,179\n"
255
+ " devbits recolor icon.jpg --color white --threshold 90"
256
+ ),
257
+ )
258
+ p.add_argument("image", type=Path, help="Input logo / icon image.")
259
+ p.add_argument("-o", "--output", type=Path, default=None,
260
+ help="Output image path. Default: <image_stem>_revised.png")
261
+ p.add_argument("--color", default="black",
262
+ help="Target foreground color: name, hex, or R,G,B (e.g. black, '#1a73e8', 0,178,179). Default: black")
263
+ p.add_argument("--threshold", type=int, default=60,
264
+ help="Color distance from the background for opaque images. Default: 60")
265
+ p.set_defaults(func=cmd_recolor)
266
+
240
267
  # ── batchimages ────────────────────────────────────────────
241
268
  p = sub.add_parser(
242
269
  "batchimages",
@@ -423,7 +450,10 @@ def cmd_video2gif(args: argparse.Namespace) -> None:
423
450
 
424
451
 
425
452
  def cmd_clipvideo(args: argparse.Namespace) -> None:
426
- if args.gui:
453
+ has_range = any(v is not None for v in (args.start, args.end, args.start_frame, args.end_frame))
454
+ # Open the interactive editor when --gui is set, or by default when a video
455
+ # is provided without an explicit trim range.
456
+ if args.gui or (args.video is not None and not has_range):
427
457
  from .gui import launch_gui
428
458
  launch_gui(args.video)
429
459
  return
@@ -452,6 +482,12 @@ def cmd_resizeimage(args: argparse.Namespace) -> None:
452
482
  print(resize_image(image, output, args.size, not args.no_keep_ratio))
453
483
 
454
484
 
485
+ def cmd_recolor(args: argparse.Namespace) -> None:
486
+ image = ensure_exists(args.image)
487
+ output = args.output or _derive_output(image, ".png", "revised")
488
+ print(recolor_image(image, output, args.color, args.threshold))
489
+
490
+
455
491
  def cmd_batchimages(args: argparse.Namespace) -> None:
456
492
  outputs = batch_images(ensure_exists(args.folder), args.output, args.size, args.format)
457
493
  print(f"Saved {len(outputs)} image(s) to {args.output}")
@@ -9,7 +9,9 @@ from __future__ import annotations
9
9
  import json
10
10
  import mimetypes
11
11
  import os
12
+ import shutil
12
13
  import socket
14
+ import subprocess
13
15
  import tempfile
14
16
  import threading
15
17
  import webbrowser
@@ -31,6 +33,7 @@ _HTML = r"""<!DOCTYPE html>
31
33
  <meta charset="utf-8">
32
34
  <meta name="viewport" content="width=device-width,initial-scale=1">
33
35
  <title>Divbits.ClipVideo</title>
36
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA2NCA2NCc+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSdnJyB4MT0nMCcgeTE9JzAnIHgyPScxJyB5Mj0nMSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjN2M1Y2ZjJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjMDBkNGZmJy8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9JzY0JyBoZWlnaHQ9JzY0JyByeD0nMTUnIGZpbGw9J3VybCgjZyknLz48cGF0aCBkPSdNMjUgMTkgTDQ3IDMyIEwyNSA0NSBaJyBmaWxsPScjZmZmJy8+PC9zdmc+Cg==">
34
37
  <link rel="preconnect" href="https://fonts.googleapis.com">
35
38
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
36
39
  <style>
@@ -142,6 +145,18 @@ body{
142
145
  cursor:pointer;transition:all 0.15s;
143
146
  }
144
147
  .media-add-btn:hover{background:#8d6eff;transform:scale(1.1)}
148
+ .media-del-btn{
149
+ width:24px;height:24px;border-radius:50%;border:none;
150
+ background:rgba(255,60,60,.14);color:#ff6b6b;font-size:.85rem;line-height:1;
151
+ display:flex;align-items:center;justify-content:center;
152
+ cursor:pointer;transition:all 0.15s;flex-shrink:0;
153
+ }
154
+ .media-del-btn:hover{background:rgba(255,60,60,.3);transform:scale(1.1)}
155
+ /* Sidebar drop-target highlight */
156
+ .sidebar.drag-over{
157
+ background:rgba(124,92,252,.08);
158
+ outline:2px dashed rgba(124,92,252,.45);outline-offset:-6px;
159
+ }
145
160
 
146
161
  /* ── Content Area (Right side) ─────────────────────────────── */
147
162
  .content-area{
@@ -388,6 +403,32 @@ kbd{
388
403
  margin-left:4px;
389
404
  }
390
405
 
406
+ /* ── Context Menu ──────────────────────────────────────────── */
407
+ .context-menu{
408
+ position:fixed;z-index:3000;min-width:172px;
409
+ background:#16162b;border:1px solid rgba(255,255,255,.1);
410
+ border-radius:10px;padding:6px;
411
+ box-shadow:0 12px 40px rgba(0,0,0,.55);
412
+ font-size:.82rem;user-select:none;
413
+ }
414
+ .ctx-item{
415
+ display:flex;align-items:center;gap:9px;
416
+ padding:8px 10px;border-radius:6px;cursor:pointer;color:#d0d0e4;
417
+ }
418
+ .ctx-item:hover{background:rgba(124,92,252,.2)}
419
+ .ctx-item.danger{color:#ff6b6b}
420
+ .ctx-item.danger:hover{background:rgba(255,60,60,.18)}
421
+ .ctx-item kbd{margin-left:auto}
422
+ .ctx-sep{height:1px;background:rgba(255,255,255,.08);margin:6px 4px}
423
+ .ctx-label{font-size:.66rem;color:#777799;text-transform:uppercase;letter-spacing:.6px;padding:6px 10px 2px}
424
+ .ctx-speeds{display:flex;flex-wrap:wrap;gap:5px;padding:4px 8px 6px}
425
+ .ctx-speed{
426
+ padding:4px 9px;border-radius:5px;background:rgba(255,255,255,.06);
427
+ color:#c0c0da;cursor:pointer;font-size:.74rem;transition:all .12s;
428
+ }
429
+ .ctx-speed:hover{background:rgba(124,92,252,.35)}
430
+ .ctx-speed.active{background:#7c5cfc;color:#fff}
431
+
391
432
  /* ── Responsive ────────────────────────────────────────────── */
392
433
  @media(max-width:850px){
393
434
  .main{flex-direction:column}
@@ -410,7 +451,7 @@ kbd{
410
451
  <!-- Main Layout -->
411
452
  <div class="main">
412
453
  <!-- Sidebar (Media Library) -->
413
- <aside class="sidebar">
454
+ <aside class="sidebar" id="sidebar">
414
455
  <h3>Media Library</h3>
415
456
  <button class="btn btn-primary" onclick="openFile()" style="margin-bottom: 12px; justify-content: center; width: 100%;">
416
457
  📂 Import media
@@ -434,11 +475,15 @@ kbd{
434
475
 
435
476
  <!-- Playback Controls -->
436
477
  <div class="controls-bar">
437
- <button class="step-btn" onclick="seekTimeline(0)" title="Jump to start">⏮⏮</button>
478
+ <button class="step-btn" onclick="seekTimeline(0)" title="Jump to start">
479
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="5" y="5" width="2.6" height="14" rx="1"/><path d="M20 5v14L9.5 12z"/></svg>
480
+ </button>
438
481
  <button class="step-btn" onclick="stepFrame(-1)" title="Previous frame">⏮</button>
439
482
  <button class="play-btn" id="playBtn" onclick="togglePlay()" title="Play/Pause (Space)">▶</button>
440
483
  <button class="step-btn" onclick="stepFrame(1)" title="Next frame">⏭</button>
441
- <button class="step-btn" onclick="seekTimeline(getTotalDuration())" title="Jump to end">⏭⏭</button>
484
+ <button class="step-btn" onclick="seekTimeline(getTotalDuration())" title="Jump to end">
485
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M4 5v14l10.5-7z"/><rect x="16.4" y="5" width="2.6" height="14" rx="1"/></svg>
486
+ </button>
442
487
  <span class="time" id="timeDisplay">0:00.000 / 0:00.000</span>
443
488
 
444
489
  <div class="speed-group">
@@ -508,6 +553,18 @@ kbd{
508
553
  </div>
509
554
  </div>
510
555
 
556
+ <!-- Confirm Modal -->
557
+ <div class="modal-overlay" id="confirmModal">
558
+ <div class="modal" style="min-width:340px">
559
+ <h2 id="confirmTitle">Confirm</h2>
560
+ <p id="confirmMessage" style="color:#b0b0c8;font-size:.9rem;line-height:1.5;margin-top:4px"></p>
561
+ <div class="modal-actions">
562
+ <button class="btn btn-ghost" onclick="hideConfirm()">Cancel</button>
563
+ <button class="btn btn-danger" id="confirmOk" onclick="runConfirm()">Delete</button>
564
+ </div>
565
+ </div>
566
+ </div>
567
+
511
568
  <!-- Toast -->
512
569
  <div class="toast" id="toast"></div>
513
570
 
@@ -528,7 +585,10 @@ let isChangingSource = false;
528
585
  let isScrubbing = false;
529
586
  let wasPlayingBeforeScrub = false;
530
587
 
531
- const PX_PER_SEC = 80;
588
+ let PX_PER_SEC = 80; // timeline scale (px per second); changed by Ctrl+wheel zoom
589
+ const PX_PER_SEC_MIN = 10;
590
+ const PX_PER_SEC_MAX = 800;
591
+ const TIMELINE_PAD = 16; // matches .timeline-track-wrapper horizontal padding
532
592
  const CLIP_GAP = 3; // px gap between clips
533
593
 
534
594
  // ── Duration Helper ────────────────────────────────────────────
@@ -628,20 +688,56 @@ function renderMediaLibrary() {
628
688
  addMediaToTimeline(item);
629
689
  };
630
690
 
691
+ const delBtn = document.createElement('button');
692
+ delBtn.className = 'media-del-btn';
693
+ delBtn.textContent = '✕';
694
+ delBtn.title = 'Remove from library';
695
+ delBtn.onclick = (e) => {
696
+ e.stopPropagation();
697
+ showConfirm(`Remove "${item.name}" from the media library?`, () => removeMediaFromLibrary(item.id));
698
+ };
699
+
631
700
  // Drag from media library to timeline
632
701
  card.addEventListener('mousedown', (e) => {
633
- if (e.target.closest('.media-add-btn')) return;
702
+ if (e.target.closest('.media-add-btn') || e.target.closest('.media-del-btn')) return;
634
703
  startMediaLibraryDrag(e, item, card);
635
704
  });
636
705
 
637
706
  card.appendChild(thumb);
638
707
  card.appendChild(info);
639
708
  card.appendChild(addBtn);
709
+ card.appendChild(delBtn);
640
710
 
641
711
  list.appendChild(card);
642
712
  });
643
713
  }
644
714
 
715
+ function removeMediaFromLibrary(id) {
716
+ const idx = mediaLibrary.findIndex(m => m.id === id);
717
+ if (idx === -1) return;
718
+ const [removed] = mediaLibrary.splice(idx, 1);
719
+ renderMediaLibrary();
720
+ toast(`Removed "${removed.name}"`);
721
+ }
722
+
723
+ // ── Confirm Modal ──────────────────────────────────────────────
724
+ let _confirmCb = null;
725
+ function showConfirm(message, onYes, okLabel = 'Delete') {
726
+ _confirmCb = onYes;
727
+ document.getElementById('confirmMessage').textContent = message;
728
+ document.getElementById('confirmOk').textContent = okLabel;
729
+ document.getElementById('confirmModal').classList.add('show');
730
+ }
731
+ function hideConfirm() {
732
+ document.getElementById('confirmModal').classList.remove('show');
733
+ _confirmCb = null;
734
+ }
735
+ function runConfirm() {
736
+ const cb = _confirmCb;
737
+ hideConfirm();
738
+ if (cb) cb();
739
+ }
740
+
645
741
  function addMediaToTimeline(media, insertAtIndex = -1) {
646
742
  const newClip = {
647
743
  id: ++clipIdCounter,
@@ -678,6 +774,11 @@ function handleFileInput(e) {
678
774
  }
679
775
 
680
776
  async function uploadAndLoad(file) {
777
+ // Skip files already present in the library (match by name).
778
+ if (mediaLibrary.some(m => m.name === file.name)) {
779
+ toast(`"${file.name}" is already in the library`);
780
+ return;
781
+ }
681
782
  const formData = new FormData();
682
783
  formData.append('file', file);
683
784
  try {
@@ -710,6 +811,28 @@ previewArea.addEventListener('drop', e => {
710
811
  });
711
812
  });
712
813
 
814
+ // Drag & drop onto the Media Library sidebar (import area)
815
+ const sidebar = document.getElementById('sidebar');
816
+ ['dragenter','dragover'].forEach(ev => {
817
+ sidebar.addEventListener(ev, e => { e.preventDefault(); sidebar.classList.add('drag-over'); });
818
+ });
819
+ ['dragleave','drop'].forEach(ev => {
820
+ sidebar.addEventListener(ev, e => {
821
+ e.preventDefault();
822
+ // Ignore dragleave that fires while moving over child elements.
823
+ if (ev === 'dragleave' && e.relatedTarget && sidebar.contains(e.relatedTarget)) return;
824
+ sidebar.classList.remove('drag-over');
825
+ });
826
+ });
827
+ sidebar.addEventListener('drop', e => {
828
+ const files = Array.from(e.dataTransfer.files);
829
+ files.forEach(file => {
830
+ if (file.type.startsWith('video/')) {
831
+ uploadAndLoad(file);
832
+ }
833
+ });
834
+ });
835
+
713
836
  // ── Initial Video Load ─────────────────────────────────────────
714
837
  if (window.__INITIAL_VIDEO__) {
715
838
  const src = window.__INITIAL_VIDEO__;
@@ -937,6 +1060,12 @@ function setupTimelineInteraction() {
937
1060
 
938
1061
  const onMouseDown = (e) => {
939
1062
  if (e.button !== 0) return;
1063
+ // Ignore clicks on the horizontal scrollbar (below the client area) so
1064
+ // dragging the scrollbar doesn't scrub the timeline.
1065
+ if (e.target === wrapper) {
1066
+ const r = wrapper.getBoundingClientRect();
1067
+ if (e.clientY > r.top + wrapper.clientHeight || e.clientX > r.left + wrapper.clientWidth) return;
1068
+ }
940
1069
  if (e.target.closest('.trim-handle') || e.target.closest('.btn-danger')) return;
941
1070
  // Don't start scrubbing if a drag is starting on a clip block
942
1071
  if (e.target.closest('.clip-block') && !e.target.closest('.trim-handle')) {
@@ -976,6 +1105,29 @@ function setupTimelineInteraction() {
976
1105
  };
977
1106
 
978
1107
  wrapper.addEventListener('mousedown', onMouseDown);
1108
+
1109
+ // Ctrl/⌘ + wheel (or trackpad pinch) zooms the timeline around the cursor.
1110
+ wrapper.addEventListener('wheel', (e) => {
1111
+ if (!(e.ctrlKey || e.metaKey)) return; // plain scroll: let the browser scroll
1112
+ e.preventDefault();
1113
+ const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
1114
+ zoomTimelineAt(e.clientX, factor);
1115
+ }, { passive: false });
1116
+ }
1117
+
1118
+ // Zoom the timeline keeping the time under `clientX` pinned to the cursor.
1119
+ function zoomTimelineAt(clientX, factor) {
1120
+ const wrapper = document.getElementById('trackWrapper');
1121
+ const content = document.getElementById('timelineContent');
1122
+ const t = (clientX - content.getBoundingClientRect().left) / PX_PER_SEC;
1123
+
1124
+ PX_PER_SEC = Math.max(PX_PER_SEC_MIN, Math.min(PX_PER_SEC_MAX, PX_PER_SEC * factor));
1125
+
1126
+ renderTimeline();
1127
+ updatePlayhead();
1128
+
1129
+ const wrapRect = wrapper.getBoundingClientRect();
1130
+ wrapper.scrollLeft = Math.max(0, wrapRect.left + TIMELINE_PAD + t * PX_PER_SEC - clientX);
979
1131
  }
980
1132
 
981
1133
  // ── Clip Drag & Drop Reorder ──────────────────────────────────
@@ -1041,15 +1193,8 @@ function startClipDrag(e, clipEl, clipId) {
1041
1193
  if (ghost) { ghost.remove(); ghost = null; }
1042
1194
 
1043
1195
  if (!dragging) {
1044
- // Was just a click, not a dragseek to clip
1045
- isScrubbing = true;
1046
- wasPlayingBeforeScrub = !video.paused;
1047
- if (wasPlayingBeforeScrub) video.pause();
1048
- handleTimelineClick(ev);
1049
- isScrubbing = false;
1050
- if (wasPlayingBeforeScrub) {
1051
- video.play().catch(e => console.log("Play interrupted:", e));
1052
- }
1196
+ // Just a click on a clipselection already happened on mousedown.
1197
+ // Do NOT move the playhead; only clicking the empty timeline scrubs.
1053
1198
  return;
1054
1199
  }
1055
1200
 
@@ -1217,6 +1362,8 @@ function renderTimeline() {
1217
1362
  rh.addEventListener('mousedown', e => startTrim(e, clip, 'right'));
1218
1363
  el.appendChild(rh);
1219
1364
 
1365
+ el.addEventListener('contextmenu', e => showClipContextMenu(e, clip.id, el));
1366
+
1220
1367
  track.appendChild(el);
1221
1368
  accTime += clipDur;
1222
1369
  });
@@ -1230,6 +1377,23 @@ function renderTimeline() {
1230
1377
 
1231
1378
 
1232
1379
 
1380
+ // Lightweight re-layout of existing clip DOM nodes (no rebuild → smooth dragging)
1381
+ function layoutClips() {
1382
+ const track = document.getElementById('track');
1383
+ let accTime = 0;
1384
+ clips.forEach((clip, i) => {
1385
+ const el = track.querySelector(`.clip-block[data-clip-id="${clip.id}"]`);
1386
+ const clipDur = (clip.endTime - clip.startTime) / clip.speed;
1387
+ if (el) {
1388
+ el.style.left = (accTime * PX_PER_SEC + i * CLIP_GAP) + 'px';
1389
+ el.style.width = (clipDur * PX_PER_SEC) + 'px';
1390
+ const lbl = el.querySelector('.clip-label');
1391
+ if (lbl) lbl.innerHTML = `${clip.name}<small>${clipDur.toFixed(1)}s · ${clip.speed}×</small>`;
1392
+ }
1393
+ accTime += clipDur;
1394
+ });
1395
+ }
1396
+
1233
1397
  function updateClipInfo() {
1234
1398
  const el = document.getElementById('clipInfo');
1235
1399
  if (selectedClipId === null) { el.textContent = ''; return; }
@@ -1244,9 +1408,13 @@ function renderRuler() {
1244
1408
  const ruler = document.getElementById('ruler');
1245
1409
  ruler.innerHTML = '';
1246
1410
  const totalDuration = getTotalDuration();
1247
- const step = totalDuration > 120 ? 30 : totalDuration > 60 ? 10 : totalDuration > 10 ? 2 : 1;
1411
+ // Pick a step so labels keep at least ~60px apart at the current zoom.
1412
+ const minPx = 60;
1413
+ const candidates = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
1414
+ let step = candidates[candidates.length - 1];
1415
+ for (const c of candidates) { if (c * PX_PER_SEC >= minPx) { step = c; break; } }
1248
1416
 
1249
- for (let t = 0; t <= totalDuration; t += step) {
1417
+ for (let t = 0; t <= totalDuration + 1e-9; t += step) {
1250
1418
  const mk = document.createElement('div');
1251
1419
  mk.className = 'ruler-mark';
1252
1420
  mk.style.left = (t * PX_PER_SEC) + 'px';
@@ -1279,23 +1447,39 @@ function startTrim(e, clip, side) {
1279
1447
 
1280
1448
  loadVideoSource(clip.src, side === 'left' ? clip.startTime : clip.endTime);
1281
1449
 
1450
+ // Throttle preview seeks to one per animation frame to avoid decode thrash.
1451
+ let pendingSeek = null;
1452
+ let rafId = null;
1453
+ const flushSeek = () => {
1454
+ rafId = null;
1455
+ if (pendingSeek !== null && !isChangingSource) {
1456
+ video.currentTime = pendingSeek;
1457
+ pendingSeek = null;
1458
+ }
1459
+ };
1460
+
1282
1461
  const onMove = ev => {
1283
1462
  const dx = ev.clientX - startX;
1284
1463
  const dt = dx / PX_PER_SEC;
1285
1464
 
1286
1465
  if (side === 'left') {
1287
1466
  clip.startTime = Math.max(0, Math.min(clip.endTime - 0.1, origStart + dt));
1288
- video.currentTime = clip.startTime;
1467
+ pendingSeek = clip.startTime;
1289
1468
  } else {
1290
1469
  clip.endTime = Math.max(clip.startTime + 0.1, Math.min(clip.duration, origEnd + dt));
1291
- video.currentTime = clip.endTime;
1470
+ pendingSeek = clip.endTime;
1292
1471
  }
1293
- renderTimeline();
1472
+ // Re-layout existing nodes instead of rebuilding the whole timeline.
1473
+ layoutClips();
1474
+ updateClipInfo();
1475
+ if (rafId === null) rafId = requestAnimationFrame(flushSeek);
1294
1476
  };
1295
1477
 
1296
1478
  const onUp = () => {
1297
1479
  document.removeEventListener('mousemove', onMove);
1298
1480
  document.removeEventListener('mouseup', onUp);
1481
+ if (rafId !== null) cancelAnimationFrame(rafId);
1482
+ renderTimeline();
1299
1483
  seekTimeline(getTimelineStartOfClip(clipIndex) + (side === 'left' ? 0 : (clip.endTime - clip.startTime) / clip.speed));
1300
1484
  };
1301
1485
 
@@ -1306,40 +1490,26 @@ function startTrim(e, clip, side) {
1306
1490
  // ── Clip Operations ────────────────────────────────────────────
1307
1491
  function splitAtPlayhead() {
1308
1492
  if (clips.length === 0 || isChangingSource) return;
1309
-
1310
1493
  const clip = clips[activeClipIndex];
1311
1494
  if (!clip) return;
1495
+ splitClipAt(clip.id, video.currentTime);
1496
+ }
1312
1497
 
1313
- const t = video.currentTime;
1498
+ // Split a specific clip at a source-time `t` (seconds within the source video).
1499
+ function splitClipAt(clipId, t) {
1500
+ const idx = clips.findIndex(c => c.id === clipId);
1501
+ if (idx === -1) return;
1502
+ const clip = clips[idx];
1314
1503
  if (t <= clip.startTime + 0.1 || t >= clip.endTime - 0.1) {
1315
- toast('Move playhead inside a clip to split');
1504
+ toast('Move the cut point inside the clip to split');
1316
1505
  return;
1317
1506
  }
1318
1507
 
1319
- const newClip1 = {
1320
- id: ++clipIdCounter,
1321
- src: clip.src,
1322
- name: clip.name,
1323
- startTime: clip.startTime,
1324
- endTime: t,
1325
- speed: clip.speed,
1326
- duration: clip.duration,
1327
- hue: clip.hue
1328
- };
1329
-
1330
- const newClip2 = {
1331
- id: ++clipIdCounter,
1332
- src: clip.src,
1333
- name: clip.name,
1334
- startTime: t,
1335
- endTime: clip.endTime,
1336
- speed: clip.speed,
1337
- duration: clip.duration,
1338
- hue: (hueCounter++ * 37 + 230) % 360
1339
- };
1508
+ const newClip1 = { ...clip, id: ++clipIdCounter, endTime: t, hue: clip.hue };
1509
+ const newClip2 = { ...clip, id: ++clipIdCounter, startTime: t, hue: (hueCounter++ * 37 + 230) % 360 };
1340
1510
 
1341
- clips.splice(activeClipIndex, 1, newClip1, newClip2);
1342
- activeClipIndex = activeClipIndex + 1;
1511
+ clips.splice(idx, 1, newClip1, newClip2);
1512
+ activeClipIndex = idx + 1;
1343
1513
  selectedClipId = newClip2.id;
1344
1514
 
1345
1515
  renderTimeline();
@@ -1369,6 +1539,84 @@ function deleteSelected() {
1369
1539
  toast('Clip deleted');
1370
1540
  }
1371
1541
 
1542
+ // ── Clip Context Menu ──────────────────────────────────────────
1543
+ const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4];
1544
+ let ctxMenuEl = null;
1545
+
1546
+ function closeContextMenu() {
1547
+ if (ctxMenuEl) { ctxMenuEl.remove(); ctxMenuEl = null; }
1548
+ }
1549
+
1550
+ function showClipContextMenu(e, clipId, clipEl) {
1551
+ e.preventDefault();
1552
+ e.stopPropagation();
1553
+ closeContextMenu();
1554
+
1555
+ const clip = clips.find(c => c.id === clipId);
1556
+ if (!clip) return;
1557
+ selectClip(clipId);
1558
+
1559
+ // Source time at the cursor (used as the split point).
1560
+ const r = clipEl.getBoundingClientRect();
1561
+ const frac = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
1562
+ const splitTime = clip.startTime + frac * (clip.endTime - clip.startTime);
1563
+
1564
+ const menu = document.createElement('div');
1565
+ menu.className = 'context-menu';
1566
+
1567
+ const split = document.createElement('div');
1568
+ split.className = 'ctx-item';
1569
+ split.innerHTML = `<span>✂</span><span>Split here</span><kbd>S</kbd>`;
1570
+ split.onclick = () => { closeContextMenu(); splitClipAt(clipId, splitTime); };
1571
+ menu.appendChild(split);
1572
+
1573
+ const del = document.createElement('div');
1574
+ del.className = 'ctx-item danger';
1575
+ del.innerHTML = `<span>🗑</span><span>Delete</span><kbd>Del</kbd>`;
1576
+ del.onclick = () => {
1577
+ closeContextMenu();
1578
+ showConfirm(`Delete this clip ("${clip.name}") from the timeline?`, () => {
1579
+ selectedClipId = clipId;
1580
+ deleteSelected();
1581
+ });
1582
+ };
1583
+ menu.appendChild(del);
1584
+
1585
+ menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
1586
+ menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-label', textContent: 'Speed' }));
1587
+
1588
+ const speeds = document.createElement('div');
1589
+ speeds.className = 'ctx-speeds';
1590
+ SPEED_OPTIONS.forEach(spd => {
1591
+ const chip = document.createElement('div');
1592
+ chip.className = 'ctx-speed' + (clip.speed === spd ? ' active' : '');
1593
+ chip.textContent = spd + '×';
1594
+ chip.onclick = () => { selectClip(clipId); setSpeed(spd); closeContextMenu(); };
1595
+ speeds.appendChild(chip);
1596
+ });
1597
+ menu.appendChild(speeds);
1598
+
1599
+ // Position within the viewport.
1600
+ document.body.appendChild(menu);
1601
+ const mw = menu.offsetWidth, mh = menu.offsetHeight;
1602
+ const x = Math.min(e.clientX, window.innerWidth - mw - 8);
1603
+ const y = Math.min(e.clientY, window.innerHeight - mh - 8);
1604
+ menu.style.left = Math.max(8, x) + 'px';
1605
+ menu.style.top = Math.max(8, y) + 'px';
1606
+ ctxMenuEl = menu;
1607
+ }
1608
+
1609
+ // Dismiss the context menu on outside interaction.
1610
+ document.addEventListener('mousedown', e => {
1611
+ if (ctxMenuEl && !ctxMenuEl.contains(e.target)) closeContextMenu();
1612
+ });
1613
+ window.addEventListener('blur', closeContextMenu);
1614
+ window.addEventListener('resize', closeContextMenu);
1615
+ document.addEventListener('DOMContentLoaded', () => {
1616
+ const tw = document.getElementById('trackWrapper');
1617
+ if (tw) tw.addEventListener('scroll', closeContextMenu);
1618
+ });
1619
+
1372
1620
 
1373
1621
 
1374
1622
  // ── Export ──────────────────────────────────────────────────────
@@ -1451,6 +1699,7 @@ async function doExport() {
1451
1699
  document.addEventListener('keydown', e => {
1452
1700
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
1453
1701
  switch (e.code) {
1702
+ case 'Escape': closeContextMenu(); break;
1454
1703
  case 'Space': e.preventDefault(); togglePlay(); break;
1455
1704
  case 'KeyS': splitAtPlayhead(); break;
1456
1705
  case 'Delete': case 'Backspace': deleteSelected(); break;
@@ -1488,6 +1737,53 @@ function toast(msg, isError) {
1488
1737
  _export_progress = {"progress": 0}
1489
1738
 
1490
1739
 
1740
+ # ---------------------------------------------------------------------------
1741
+ # ffmpeg fast-path helpers
1742
+ # ---------------------------------------------------------------------------
1743
+
1744
+ def _ffmpeg_bin() -> str | None:
1745
+ """Return the ffmpeg executable path if available, else None (cached)."""
1746
+ if not hasattr(_ffmpeg_bin, "_cached"):
1747
+ _ffmpeg_bin._cached = shutil.which("ffmpeg")
1748
+ return _ffmpeg_bin._cached
1749
+
1750
+
1751
+ def _ffprobe_bin() -> str | None:
1752
+ if not hasattr(_ffprobe_bin, "_cached"):
1753
+ _ffprobe_bin._cached = shutil.which("ffprobe")
1754
+ return _ffprobe_bin._cached
1755
+
1756
+
1757
+ def _has_audio(path: Path) -> bool:
1758
+ """Return True if the media file contains at least one audio stream."""
1759
+ probe = _ffprobe_bin()
1760
+ if not probe:
1761
+ return False
1762
+ try:
1763
+ out = subprocess.run(
1764
+ [probe, "-v", "error", "-select_streams", "a",
1765
+ "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)],
1766
+ capture_output=True, text=True, timeout=15,
1767
+ )
1768
+ return "audio" in out.stdout
1769
+ except (subprocess.SubprocessError, OSError):
1770
+ return False
1771
+
1772
+
1773
+ def _atempo_factors(speed: float) -> list[float]:
1774
+ """Decompose a playback-rate change into atempo factors within [0.5, 2.0]."""
1775
+ factors: list[float] = []
1776
+ s = speed
1777
+ while s > 2.0 + 1e-9:
1778
+ factors.append(2.0)
1779
+ s /= 2.0
1780
+ while s < 0.5 - 1e-9:
1781
+ factors.append(0.5)
1782
+ s /= 0.5
1783
+ factors.append(round(s, 6))
1784
+ return factors
1785
+
1786
+
1491
1787
  class _Handler(BaseHTTPRequestHandler):
1492
1788
  """Request handler for the clip editor."""
1493
1789
 
@@ -1675,7 +1971,14 @@ class _Handler(BaseHTTPRequestHandler):
1675
1971
  return
1676
1972
 
1677
1973
  try:
1678
- out_path = self._do_export(clip_defs, fmt, filename, resolution)
1974
+ if _ffmpeg_bin():
1975
+ try:
1976
+ out_path = self._ffmpeg_export(clip_defs, fmt, filename, resolution)
1977
+ except Exception:
1978
+ # ffmpeg path failed — fall back to the pure-Python encoder
1979
+ out_path = self._do_export(clip_defs, fmt, filename, resolution)
1980
+ else:
1981
+ out_path = self._do_export(clip_defs, fmt, filename, resolution)
1679
1982
  self._json_response({"success": True, "path": str(out_path)})
1680
1983
  except Exception as exc:
1681
1984
  self._json_response({"error": str(exc)}, 500)
@@ -1692,6 +1995,146 @@ class _Handler(BaseHTTPRequestHandler):
1692
1995
  name = src.split("/")[-1]
1693
1996
  return Path(self.upload_dir) / name
1694
1997
 
1998
+ def _ffmpeg_export(self, clip_defs: list[dict], fmt: str, filename: str, resolution: int = 0) -> Path:
1999
+ """Fast export via ffmpeg: trim + speed + scale + concat in one multithreaded pass.
2000
+
2001
+ Far faster than the frame-by-frame cv2 path (hardware/SIMD encoders, no
2002
+ per-frame Python overhead) and preserves audio when every clip has it.
2003
+ """
2004
+ global _export_progress
2005
+ _export_progress["progress"] = 0
2006
+
2007
+ ffmpeg = _ffmpeg_bin()
2008
+ if not ffmpeg:
2009
+ raise RuntimeError("ffmpeg not available")
2010
+
2011
+ srcs = [self._resolve_clip_src(c["src"]) for c in clip_defs]
2012
+
2013
+ # Target geometry / fps come from the first clip (concat needs uniform size).
2014
+ first = cv2.VideoCapture(str(srcs[0]))
2015
+ if not first.isOpened():
2016
+ raise RuntimeError(f"Cannot open video: {srcs[0]}")
2017
+ src_fps = first.get(cv2.CAP_PROP_FPS) or 30.0
2018
+ orig_w = int(first.get(cv2.CAP_PROP_FRAME_WIDTH))
2019
+ orig_h = int(first.get(cv2.CAP_PROP_FRAME_HEIGHT))
2020
+ first.release()
2021
+ if orig_w <= 0 or orig_h <= 0:
2022
+ raise RuntimeError("Could not determine source dimensions")
2023
+
2024
+ if resolution > 0:
2025
+ w = resolution
2026
+ h = int(orig_h * (resolution / orig_w))
2027
+ else:
2028
+ w, h = orig_w, orig_h
2029
+ w += w % 2
2030
+ h += h % 2 # codecs require even dimensions
2031
+
2032
+ is_gif = fmt == "gif"
2033
+ out_fps = min(src_fps, 15.0) if is_gif else src_fps
2034
+ # Audio only when every clip has it (concat needs matching stream sets).
2035
+ want_audio = (not is_gif) and all(_has_audio(s) for s in srcs)
2036
+
2037
+ total_out_dur = 0.0
2038
+ for c in clip_defs:
2039
+ speed = float(c.get("speed", 1.0)) or 1.0
2040
+ total_out_dur += max(0.0, (c["endTime"] - c["startTime"]) / speed)
2041
+ total_out_dur = max(total_out_dur, 0.001)
2042
+
2043
+ # ── Build filter graph ──────────────────────────────────────
2044
+ inputs: list[str] = []
2045
+ for s in srcs:
2046
+ inputs += ["-i", str(s)]
2047
+
2048
+ parts: list[str] = []
2049
+ concat_labels: list[str] = []
2050
+ for i, c in enumerate(clip_defs):
2051
+ start = float(c["startTime"])
2052
+ end = float(c["endTime"])
2053
+ speed = float(c.get("speed", 1.0)) or 1.0
2054
+ vlbl = f"v{i}"
2055
+ parts.append(
2056
+ 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}]"
2058
+ )
2059
+ concat_labels.append(f"[{vlbl}]")
2060
+ if want_audio:
2061
+ albl = f"a{i}"
2062
+ atempo = ",".join(f"atempo={f}" for f in _atempo_factors(speed))
2063
+ parts.append(
2064
+ f"[{i}:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS,{atempo}[{albl}]"
2065
+ )
2066
+ concat_labels.append(f"[{albl}]")
2067
+
2068
+ n = len(clip_defs)
2069
+ if want_audio:
2070
+ parts.append("".join(concat_labels) + f"concat=n={n}:v=1:a=1[vc][outa]")
2071
+ vcat = "[vc]"
2072
+ else:
2073
+ parts.append("".join(concat_labels) + f"concat=n={n}:v=1:a=0[vc]")
2074
+ vcat = "[vc]"
2075
+
2076
+ if is_gif:
2077
+ parts.append(
2078
+ f"{vcat}split[s0][s1];[s0]palettegen=stats_mode=diff[p];"
2079
+ f"[s1][p]paletteuse=dither=bayer[outv]"
2080
+ )
2081
+ vmap = "[outv]"
2082
+ else:
2083
+ vmap = vcat
2084
+
2085
+ filtergraph = ";".join(parts)
2086
+
2087
+ out_path = Path(self.export_dir) / f"{filename}.{fmt}"
2088
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2089
+
2090
+ cmd = [ffmpeg, "-y", "-v", "error", "-progress", "pipe:1", "-nostats",
2091
+ *inputs, "-filter_complex", filtergraph, "-map", vmap]
2092
+ if want_audio:
2093
+ cmd += ["-map", "[outa]"]
2094
+
2095
+ if not is_gif:
2096
+ codec = {"mp4": "libx264", "avi": "mpeg4", "webm": "libvpx"}.get(fmt, "libx264")
2097
+ cmd += ["-c:v", codec]
2098
+ if codec == "libx264":
2099
+ cmd += ["-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p",
2100
+ "-movflags", "+faststart"]
2101
+ elif codec == "libvpx":
2102
+ cmd += ["-b:v", "2M", "-deadline", "realtime", "-cpu-used", "5"]
2103
+ else: # mpeg4 / avi
2104
+ cmd += ["-qscale:v", "4"]
2105
+ if want_audio:
2106
+ acodec = {"webm": "libvorbis"}.get(fmt, "aac")
2107
+ cmd += ["-c:a", acodec]
2108
+ cmd.append(str(out_path))
2109
+
2110
+ self._run_ffmpeg(cmd, total_out_dur)
2111
+ _export_progress["progress"] = 100
2112
+ return out_path
2113
+
2114
+ def _run_ffmpeg(self, cmd: list[str], total_dur: float) -> None:
2115
+ """Run ffmpeg, streaming -progress output into _export_progress."""
2116
+ global _export_progress
2117
+ proc = subprocess.Popen(
2118
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
2119
+ text=True, bufsize=1,
2120
+ )
2121
+ try:
2122
+ for line in proc.stdout:
2123
+ line = line.strip()
2124
+ if line.startswith("out_time_ms=") or line.startswith("out_time_us="):
2125
+ try:
2126
+ secs = int(line.split("=", 1)[1]) / 1_000_000.0
2127
+ _export_progress["progress"] = min(99, int(secs / total_dur * 100))
2128
+ except ValueError:
2129
+ pass
2130
+ finally:
2131
+ proc.stdout.close()
2132
+ stderr = proc.stderr.read()
2133
+ proc.stderr.close()
2134
+ ret = proc.wait()
2135
+ if ret != 0:
2136
+ raise RuntimeError(f"ffmpeg failed: {stderr.strip()[-500:]}")
2137
+
1695
2138
  def _do_export(self, clip_defs: list[dict], fmt: str, filename: str, resolution: int = 0) -> Path:
1696
2139
  """Run the actual export using cv2."""
1697
2140
  global _export_progress
@@ -1812,19 +2255,23 @@ class _Handler(BaseHTTPRequestHandler):
1812
2255
  speed = clip.get("speed", 1.0)
1813
2256
  step = max(1.0, speed)
1814
2257
 
2258
+ # Read sequentially (decoders are optimized for forward reads);
2259
+ # seeking per-frame with cap.set() is orders of magnitude slower.
1815
2260
  cap.set(cv2.CAP_PROP_POS_FRAMES, start_f)
1816
- fi = float(start_f)
2261
+ fi = start_f
2262
+ next_write = float(start_f)
1817
2263
  while fi <= end_f:
1818
- cap.set(cv2.CAP_PROP_POS_FRAMES, int(fi))
1819
2264
  ok, frame = cap.read()
1820
2265
  if not ok:
1821
2266
  break
1822
- if frame.shape[1] != w or frame.shape[0] != h:
1823
- frame = cv2.resize(frame, (w, h))
1824
- writer.write(frame)
1825
- processed_frames += 1
1826
- _export_progress["progress"] = int(processed_frames / total_frames * 95)
1827
- fi += step
2267
+ if fi >= next_write - 1e-9:
2268
+ if frame.shape[1] != w or frame.shape[0] != h:
2269
+ frame = cv2.resize(frame, (w, h))
2270
+ writer.write(frame)
2271
+ processed_frames += 1
2272
+ _export_progress["progress"] = int(processed_frames / total_frames * 95)
2273
+ next_write += step
2274
+ fi += 1
1828
2275
  cap.release()
1829
2276
  finally:
1830
2277
  writer.release()
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
6
6
 
7
- from .utils import ensure_dir, list_images, parse_size
7
+ from .utils import ensure_dir, list_images, parse_color, parse_size
8
8
 
9
9
 
10
10
  def image_to_ico(input_path: Path, output_path: Path, sizes: str = "16,32,48,64,128,256") -> Path:
@@ -81,6 +81,58 @@ def resize_image(input_path: Path, output_path: Path, size: str, keep_ratio: boo
81
81
  return output_path
82
82
 
83
83
 
84
+ def recolor_image(
85
+ input_path: Path,
86
+ output_path: Path,
87
+ color: str = "black",
88
+ threshold: int = 60,
89
+ ) -> Path:
90
+ """Recolor the foreground of a logo / icon while keeping its background.
91
+
92
+ The background is detected either from transparency (transparent pixels are
93
+ treated as background) or, for fully opaque images, from the dominant border
94
+ color (the lighter surrounding area). Every foreground pixel is repainted
95
+ with ``color`` (a name like ``black``, a hex value like ``#1a73e8``, or an
96
+ ``R,G,B`` triple like ``0,178,179``). The result is always saved as an RGBA
97
+ PNG so soft, anti-aliased edges are kept.
98
+ """
99
+ import numpy as np
100
+
101
+ output_path.parent.mkdir(parents=True, exist_ok=True)
102
+
103
+ target_rgb = parse_color(color)
104
+
105
+ with Image.open(input_path) as image:
106
+ arr = np.array(image.convert("RGBA"))
107
+
108
+ rgb = arr[..., :3].astype(np.int16)
109
+ alpha = arr[..., 3]
110
+
111
+ # Step 1: distinguish background from foreground.
112
+ transparent = alpha < 16
113
+ if transparent.mean() > 0.02:
114
+ # Transparency marks the background; opaque pixels are the foreground.
115
+ foreground = alpha >= 16
116
+ else:
117
+ # Opaque image: estimate the (lighter) background from the border color.
118
+ border = np.concatenate(
119
+ [rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0
120
+ )
121
+ bg_color = np.median(border, axis=0)
122
+ distance = np.sqrt(((rgb - bg_color) ** 2).sum(axis=2))
123
+ foreground = distance > threshold
124
+
125
+ # Step 2: paint the foreground with the target color (alpha is preserved,
126
+ # so anti-aliased edges keep their soft blend).
127
+ arr[..., 0][foreground] = target_rgb[0]
128
+ arr[..., 1][foreground] = target_rgb[1]
129
+ arr[..., 2][foreground] = target_rgb[2]
130
+
131
+ # Step 3: export as RGBA PNG.
132
+ Image.fromarray(arr, mode="RGBA").save(output_path, format="PNG")
133
+ return output_path
134
+
135
+
84
136
  def batch_images(folder: Path, output_folder: Path, size: str | None = None, fmt: str | None = None) -> list[Path]:
85
137
  ensure_dir(output_folder)
86
138
  outputs: list[Path] = []
@@ -47,6 +47,10 @@ def resizeimage() -> int:
47
47
  return _run("resizeimage")
48
48
 
49
49
 
50
+ def recolor() -> int:
51
+ return _run("recolor")
52
+
53
+
50
54
  def batchimages() -> int:
51
55
  return _run("batchimages")
52
56
 
@@ -46,6 +46,33 @@ def parse_size(size: str) -> tuple[int, int]:
46
46
  return width_i, height_i
47
47
 
48
48
 
49
+ def parse_color(value: str) -> tuple[int, int, int]:
50
+ """Parse a color into an ``(R, G, B)`` tuple.
51
+
52
+ Accepts a CSS name (``black``), a hex value (``#1a73e8``), or a comma-/
53
+ space-separated ``R,G,B`` triple (``0,178,179``).
54
+ """
55
+ from PIL import ImageColor
56
+
57
+ text = value.strip()
58
+ if "," in text:
59
+ parts = [p.strip() for p in text.split(",") if p.strip()]
60
+ if len(parts) != 3:
61
+ raise ValueError(f"RGB color must have 3 components, got: {value!r}")
62
+ try:
63
+ channels = tuple(int(p) for p in parts)
64
+ except ValueError as exc:
65
+ raise ValueError(f"RGB components must be integers: {value!r}") from exc
66
+ if any(c < 0 or c > 255 for c in channels):
67
+ raise ValueError(f"RGB components must be in 0-255: {value!r}")
68
+ return channels # type: ignore[return-value]
69
+
70
+ try:
71
+ return ImageColor.getrgb(text)[:3]
72
+ except ValueError as exc:
73
+ raise ValueError(f"Unrecognized color: {value!r}") from exc
74
+
75
+
49
76
  def parse_int_tuple(value: str, expected: int, name: str) -> tuple[int, ...]:
50
77
  try:
51
78
  items = tuple(int(x.strip()) for x in value.split(","))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.0.0
3
+ Version: 1.1.2
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -64,6 +64,7 @@ clipvideo --help
64
64
  | Command | Description |
65
65
  |---------|-------------|
66
66
  | `resizeimage` | Resize a single image (preserves aspect ratio by default). |
67
+ | `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
67
68
  | `image2ico` | Convert an image to a multi-size ICO file. |
68
69
  | `batchimages` | Batch resize or convert all images in a folder. |
69
70
  | `checkimages` | Scan for broken / corrupt image files. |
@@ -94,6 +95,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
94
95
  # Extract every 5th frame as PNG
95
96
  video2images movie.mp4 --every 5 --format png
96
97
 
98
+ # Recolor a logo's foreground to black (keeps the background)
99
+ recolor logo.png
100
+
101
+ # Recolor a logo's foreground to a custom color (hex or R,G,B)
102
+ recolor logo.png --color '#1a73e8'
103
+ recolor logo.png --color 0,178,179
104
+
97
105
  # Batch resize images to 800×600
98
106
  batchimages ./photos -o ./resized --size 800,600
99
107
 
@@ -109,6 +117,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
109
117
  clipvideo movie.mp4 → movie_clip.mp4
110
118
  video2gif movie.mp4 → movie.gif
111
119
  resizeimage photo.jpg → photo_resized.jpg
120
+ recolor logo.png → logo_revised.png
112
121
  contactsheet ./photos → photos_sheet.jpg
113
122
  ```
114
123
 
@@ -8,6 +8,7 @@ devbits = devbits.cli:main
8
8
  image2ico = devbits.scripts:image2ico
9
9
  images2gif = devbits.scripts:images2gif
10
10
  images2video = devbits.scripts:images2video
11
+ recolor = devbits.scripts:recolor
11
12
  renamefiles = devbits.scripts:renamefiles
12
13
  resizeimage = devbits.scripts:resizeimage
13
14
  resizevideo = devbits.scripts:resizevideo
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devbits"
3
- version = "1.0.0"
3
+ version = "1.1.2"
4
4
  description = "A lightweight CLI toolkit for daily development utilities."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -30,6 +30,7 @@ clipvideo = "devbits.scripts:clipvideo"
30
30
  resizevideo = "devbits.scripts:resizevideo"
31
31
  image2ico = "devbits.scripts:image2ico"
32
32
  resizeimage = "devbits.scripts:resizeimage"
33
+ recolor = "devbits.scripts:recolor"
33
34
  batchimages = "devbits.scripts:batchimages"
34
35
  checkimages = "devbits.scripts:checkimages"
35
36
  contactsheet = "devbits.scripts:contactsheet"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes