devbits 1.1.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.1.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
@@ -1,3 +1,3 @@
1
1
  """devbits: A lightweight CLI toolkit for daily development utilities."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.1.2"
@@ -4,6 +4,7 @@ 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
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
@@ -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,
@@ -449,7 +450,10 @@ def cmd_video2gif(args: argparse.Namespace) -> None:
449
450
 
450
451
 
451
452
  def cmd_clipvideo(args: argparse.Namespace) -> None:
452
- 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):
453
457
  from .gui import launch_gui
454
458
  launch_gui(args.video)
455
459
  return
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.1.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
@@ -17,5 +17,4 @@ devbits.egg-info/entry_points.txt
17
17
  devbits.egg-info/requires.txt
18
18
  devbits.egg-info/top_level.txt
19
19
  tests/test_cli.py
20
- tests/test_gui_cli.py
21
- tests/test_recolor.py
20
+ tests/test_gui_cli.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devbits"
3
- version = "1.1.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"
@@ -1,67 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from PIL import Image
4
-
5
- from devbits.cli import main
6
- from devbits.image import recolor_image
7
-
8
-
9
- def _make_transparent_logo(path) -> None:
10
- """A transparent background with an opaque red square in the middle."""
11
- img = Image.new("RGBA", (20, 20), (0, 0, 0, 0))
12
- for y in range(5, 15):
13
- for x in range(5, 15):
14
- img.putpixel((x, y), (200, 0, 0, 255))
15
- img.save(path)
16
-
17
-
18
- def _make_opaque_logo(path) -> None:
19
- """A light-gray background with a dark blue square in the middle."""
20
- img = Image.new("RGB", (20, 20), (230, 230, 230))
21
- for y in range(5, 15):
22
- for x in range(5, 15):
23
- img.putpixel((x, y), (10, 10, 120))
24
- img.save(path)
25
-
26
-
27
- def test_recolor_transparent(tmp_path) -> None:
28
- src = tmp_path / "logo.png"
29
- _make_transparent_logo(src)
30
- out = recolor_image(src, tmp_path / "logo_revised.png", "black")
31
-
32
- result = Image.open(out)
33
- assert result.mode == "RGBA"
34
- # Foreground becomes black and stays opaque.
35
- assert result.getpixel((10, 10)) == (0, 0, 0, 255)
36
- # Background stays transparent.
37
- assert result.getpixel((0, 0))[3] == 0
38
-
39
-
40
- def test_recolor_opaque_custom_color(tmp_path) -> None:
41
- src = tmp_path / "icon.jpg"
42
- _make_opaque_logo(src)
43
- out = recolor_image(src, tmp_path / "icon_revised.png", "#00ff00")
44
-
45
- result = Image.open(out)
46
- assert result.mode == "RGBA"
47
- # Foreground becomes the requested green.
48
- assert result.getpixel((10, 10))[:3] == (0, 255, 0)
49
- # Light background is left roughly unchanged.
50
- assert result.getpixel((0, 0))[0] > 200
51
-
52
-
53
- def test_recolor_rgb_triple(tmp_path) -> None:
54
- src = tmp_path / "logo.png"
55
- _make_transparent_logo(src)
56
- out = recolor_image(src, tmp_path / "logo_revised.png", "0,178,179")
57
-
58
- result = Image.open(out)
59
- assert result.mode == "RGBA"
60
- assert result.getpixel((10, 10)) == (0, 178, 179, 255)
61
-
62
-
63
- def test_recolor_cli_default_output(tmp_path) -> None:
64
- src = tmp_path / "brand.png"
65
- _make_transparent_logo(src)
66
- assert main(["recolor", str(src)]) == 0
67
- assert (tmp_path / "brand_revised.png").exists()
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