devbits 0.1.2__tar.gz → 0.1.4__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: 0.1.2
3
+ Version: 0.1.4
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__ = "0.1.2"
3
+ __version__ = "0.1.4"
@@ -14,6 +14,7 @@ import tempfile
14
14
  import threading
15
15
  import webbrowser
16
16
  from http.server import HTTPServer, BaseHTTPRequestHandler
17
+ from socketserver import ThreadingMixIn
17
18
  from pathlib import Path
18
19
  from urllib.parse import unquote
19
20
 
@@ -29,7 +30,7 @@ _HTML = r"""<!DOCTYPE html>
29
30
  <head>
30
31
  <meta charset="utf-8">
31
32
  <meta name="viewport" content="width=device-width,initial-scale=1">
32
- <title>ClipVideo Editor — devbits</title>
33
+ <title>Divbits.ClipVideo</title>
33
34
  <link rel="preconnect" href="https://fonts.googleapis.com">
34
35
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
35
36
  <style>
@@ -69,6 +70,7 @@ body{
69
70
  transition:all .15s ease;display:inline-flex;align-items:center;gap:6px;
70
71
  }
71
72
  .btn:active{transform:scale(.96)}
73
+ .btn:disabled{opacity:.45;cursor:not-allowed;pointer-events:none}
72
74
  .btn-ghost{background:rgba(255,255,255,.06);color:#c0c0da}
73
75
  .btn-ghost:hover{background:rgba(255,255,255,.12)}
74
76
  .btn-primary{
@@ -116,10 +118,12 @@ body{
116
118
  display:flex;align-items:center;gap:10px;
117
119
  background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);
118
120
  border-radius:8px;padding:8px;transition:all 0.2s ease;
121
+ cursor:grab;
119
122
  }
120
123
  .media-item:hover{
121
124
  background:rgba(255,255,255,0.05);border-color:rgba(124,92,252,0.3);
122
125
  }
126
+ .media-item.dragging-media{opacity:.4}
123
127
  .media-thumb{
124
128
  width:50px;height:36px;background:rgba(0,0,0,0.3);
125
129
  border-radius:4px;display:flex;align-items:center;justify-content:center;
@@ -251,11 +255,33 @@ body{
251
255
  background:linear-gradient(180deg,#2a2a55 0%,#1e1e45 100%);
252
256
  border:2px solid transparent;
253
257
  border-radius:8px;
254
- cursor:pointer;
255
- transition:border-color .15s,box-shadow .15s;
258
+ cursor:grab;
259
+ transition:border-color .15s,box-shadow .15s,opacity .15s;
256
260
  display:flex;align-items:center;justify-content:center;
257
261
  overflow:hidden;
258
262
  }
263
+ .clip-block.dragging{opacity:.3;cursor:grabbing}
264
+ .clip-block.drag-over-left{border-left:3px solid #7c5cfc}
265
+ .clip-block.drag-over-right{border-right:3px solid #7c5cfc}
266
+
267
+ /* Floating ghost while dragging a clip */
268
+ .clip-drag-ghost{
269
+ position:fixed;pointer-events:none;z-index:9999;
270
+ border-radius:8px;border:2px solid #7c5cfc;
271
+ display:flex;align-items:center;justify-content:center;
272
+ box-shadow:0 8px 32px rgba(124,92,252,.5);
273
+ opacity:.85;transform:scale(1.04);
274
+ font-size:.75rem;font-weight:600;color:rgba(255,255,255,.9);
275
+ padding:0 12px;white-space:nowrap;
276
+ }
277
+
278
+ /* Timeline drop indicator for media library drag */
279
+ .timeline-drop-indicator{
280
+ position:absolute;top:0;bottom:0;width:3px;
281
+ background:#7c5cfc;z-index:20;border-radius:2px;
282
+ pointer-events:none;
283
+ box-shadow:0 0 8px rgba(124,92,252,.6);
284
+ }
259
285
  .clip-block:hover{border-color:rgba(124,92,252,.4)}
260
286
  .clip-block.selected{
261
287
  border-color:#7c5cfc;
@@ -374,8 +400,7 @@ kbd{
374
400
  <!-- Top Bar -->
375
401
  <header class="topbar">
376
402
  <div style="display:flex;align-items:center">
377
- <span class="logo">✂ ClipVideo</span>
378
- <span class="filename" id="filename">No clips in timeline</span>
403
+ <span class="logo">✂ Divbits.ClipVideo</span>
379
404
  </div>
380
405
  <div class="topbar-actions">
381
406
  <button class="btn btn-primary" onclick="showExportModal()">⬇ Export</button>
@@ -409,9 +434,11 @@ kbd{
409
434
 
410
435
  <!-- Playback Controls -->
411
436
  <div class="controls-bar">
437
+ <button class="step-btn" onclick="seekTimeline(0)" title="Jump to start">⏮⏮</button>
412
438
  <button class="step-btn" onclick="stepFrame(-1)" title="Previous frame">⏮</button>
413
439
  <button class="play-btn" id="playBtn" onclick="togglePlay()" title="Play/Pause (Space)">▶</button>
414
440
  <button class="step-btn" onclick="stepFrame(1)" title="Next frame">⏭</button>
441
+ <button class="step-btn" onclick="seekTimeline(getTotalDuration())" title="Jump to end">⏭⏭</button>
415
442
  <span class="time" id="timeDisplay">0:00.000 / 0:00.000</span>
416
443
 
417
444
  <div class="speed-group">
@@ -434,8 +461,7 @@ kbd{
434
461
  <div class="timeline-toolbar">
435
462
  <button class="btn btn-ghost" onclick="splitAtPlayhead()" title="Split (S)">✂ Split<kbd>S</kbd></button>
436
463
  <button class="btn btn-danger" onclick="deleteSelected()" title="Delete (Del)">🗑 Delete<kbd>Del</kbd></button>
437
- <button class="btn btn-ghost" onclick="moveClip(-1)" title="Move left">← Move</button>
438
- <button class="btn btn-ghost" onclick="moveClip(1)" title="Move right">Move →</button>
464
+
439
465
  <div style="flex:1"></div>
440
466
  <span style="font-size:.75rem;color:#777799" id="clipInfo"></span>
441
467
  </div>
@@ -461,6 +487,15 @@ kbd{
461
487
  <option value="webm">WebM (.webm)</option>
462
488
  <option value="gif">GIF (.gif)</option>
463
489
  </select>
490
+ <label>Resolution</label>
491
+ <select id="exportResolution">
492
+ <option value="3840">4K (3840×2160)</option>
493
+ <option value="2560">1440p (2560×1440)</option>
494
+ <option value="1920" selected>1080p (1920×1080)</option>
495
+ <option value="1280">720p (1280×720)</option>
496
+ <option value="854">480p (854×480)</option>
497
+ <option value="0">Original</option>
498
+ </select>
464
499
  <label>Filename</label>
465
500
  <input type="text" id="exportFilename" value="output">
466
501
  <div class="progress-wrap" id="progressWrap">
@@ -483,16 +518,18 @@ kbd{
483
518
  // ── State ──────────────────────────────────────────────────────
484
519
  const video = document.getElementById('video');
485
520
  let mediaLibrary = []; // {id, src, name, duration}
486
- let clips = []; // {id, src, name, startTime, endTime, speed, duration}
521
+ let clips = []; // {id, src, name, startTime, endTime, speed, duration, hue}
487
522
  let selectedClipId = null;
488
523
  let activeClipIndex = 0;
489
524
  let timelineTime = 0;
490
525
  let clipIdCounter = 0;
526
+ let hueCounter = 0; // stable color assignment
491
527
  let isChangingSource = false;
492
528
  let isScrubbing = false;
493
529
  let wasPlayingBeforeScrub = false;
494
530
 
495
531
  const PX_PER_SEC = 80;
532
+ const CLIP_GAP = 3; // px gap between clips
496
533
 
497
534
  // ── Duration Helper ────────────────────────────────────────────
498
535
  function getMediaDuration(src) {
@@ -511,7 +548,23 @@ function getMediaDuration(src) {
511
548
 
512
549
  // ── Media Library Operations ──────────────────────────────────
513
550
  function addMediaToLibrary(src, name, duration, autoAddToTimeline = false) {
514
- if (mediaLibrary.some(item => item.src === src)) {
551
+ // Normalize src for dedup (resolve to absolute URL)
552
+ const a = document.createElement('a');
553
+ a.href = src;
554
+ const normalizedSrc = a.href;
555
+ if (mediaLibrary.some(item => {
556
+ const b = document.createElement('a');
557
+ b.href = item.src;
558
+ return b.href === normalizedSrc;
559
+ })) {
560
+ if (autoAddToTimeline) {
561
+ const existing = mediaLibrary.find(item => {
562
+ const b = document.createElement('a');
563
+ b.href = item.src;
564
+ return b.href === normalizedSrc;
565
+ });
566
+ if (existing) addMediaToTimeline(existing);
567
+ }
515
568
  return;
516
569
  }
517
570
  const item = {
@@ -545,6 +598,7 @@ function renderMediaLibrary() {
545
598
  mediaLibrary.forEach(item => {
546
599
  const card = document.createElement('div');
547
600
  card.className = 'media-item';
601
+ card.draggable = false; // we use custom mousedown drag
548
602
 
549
603
  const thumb = document.createElement('div');
550
604
  thumb.className = 'media-thumb';
@@ -553,16 +607,16 @@ function renderMediaLibrary() {
553
607
  const info = document.createElement('div');
554
608
  info.className = 'media-info';
555
609
 
556
- const name = document.createElement('div');
557
- name.className = 'media-name';
558
- name.textContent = item.name;
559
- name.title = item.name;
610
+ const nameEl = document.createElement('div');
611
+ nameEl.className = 'media-name';
612
+ nameEl.textContent = item.name;
613
+ nameEl.title = item.name;
560
614
 
561
615
  const duration = document.createElement('div');
562
616
  duration.className = 'media-duration';
563
617
  duration.textContent = fmtTime(item.duration);
564
618
 
565
- info.appendChild(name);
619
+ info.appendChild(nameEl);
566
620
  info.appendChild(duration);
567
621
 
568
622
  const addBtn = document.createElement('button');
@@ -574,6 +628,12 @@ function renderMediaLibrary() {
574
628
  addMediaToTimeline(item);
575
629
  };
576
630
 
631
+ // Drag from media library to timeline
632
+ card.addEventListener('mousedown', (e) => {
633
+ if (e.target.closest('.media-add-btn')) return;
634
+ startMediaLibraryDrag(e, item, card);
635
+ });
636
+
577
637
  card.appendChild(thumb);
578
638
  card.appendChild(info);
579
639
  card.appendChild(addBtn);
@@ -582,7 +642,7 @@ function renderMediaLibrary() {
582
642
  });
583
643
  }
584
644
 
585
- function addMediaToTimeline(media) {
645
+ function addMediaToTimeline(media, insertAtIndex = -1) {
586
646
  const newClip = {
587
647
  id: ++clipIdCounter,
588
648
  src: media.src,
@@ -590,17 +650,21 @@ function addMediaToTimeline(media) {
590
650
  startTime: 0,
591
651
  endTime: media.duration,
592
652
  speed: 1,
593
- duration: media.duration
653
+ duration: media.duration,
654
+ hue: (hueCounter++ * 37 + 230) % 360
594
655
  };
595
- clips.push(newClip);
656
+ if (insertAtIndex >= 0 && insertAtIndex <= clips.length) {
657
+ clips.splice(insertAtIndex, 0, newClip);
658
+ } else {
659
+ clips.push(newClip);
660
+ }
596
661
  selectedClipId = newClip.id;
597
662
 
598
663
  if (clips.length === 1) {
599
664
  activeClipIndex = 0;
600
665
  seekTimeline(0);
601
- } else {
602
- renderTimeline();
603
666
  }
667
+ renderTimeline();
604
668
  toast(`Added to timeline`);
605
669
  }
606
670
 
@@ -705,7 +769,6 @@ function setSpeed(val) {
705
769
  function updateTimeDisplay() {
706
770
  if (clips.length === 0) {
707
771
  document.getElementById('timeDisplay').textContent = '0:00.000 / 0:00.000';
708
- document.getElementById('filename').textContent = 'No clips in timeline';
709
772
  document.getElementById('noVideo').style.display = 'block';
710
773
  video.style.display = 'none';
711
774
  updatePlayhead();
@@ -875,11 +938,14 @@ function setupTimelineInteraction() {
875
938
  const onMouseDown = (e) => {
876
939
  if (e.button !== 0) return;
877
940
  if (e.target.closest('.trim-handle') || e.target.closest('.btn-danger')) return;
878
-
879
- const clipEl = e.target.closest('.clip-block');
880
- if (clipEl) {
941
+ // Don't start scrubbing if a drag is starting on a clip block
942
+ if (e.target.closest('.clip-block') && !e.target.closest('.trim-handle')) {
943
+ const clipEl = e.target.closest('.clip-block');
881
944
  const clipId = parseInt(clipEl.dataset.clipId);
882
945
  selectClip(clipId);
946
+ // Start potential drag
947
+ startClipDrag(e, clipEl, clipId);
948
+ return;
883
949
  }
884
950
 
885
951
  isScrubbing = true;
@@ -912,6 +978,196 @@ function setupTimelineInteraction() {
912
978
  wrapper.addEventListener('mousedown', onMouseDown);
913
979
  }
914
980
 
981
+ // ── Clip Drag & Drop Reorder ──────────────────────────────────
982
+ function createDragGhost(text, hue, width, height) {
983
+ const ghost = document.createElement('div');
984
+ ghost.className = 'clip-drag-ghost';
985
+ ghost.textContent = text;
986
+ ghost.style.width = Math.min(width, 200) + 'px';
987
+ ghost.style.height = height + 'px';
988
+ ghost.style.background = `linear-gradient(180deg, hsl(${hue},45%,32%) 0%, hsl(${hue},40%,22%) 100%)`;
989
+ document.body.appendChild(ghost);
990
+ return ghost;
991
+ }
992
+
993
+ function startClipDrag(e, clipEl, clipId) {
994
+ const startX = e.clientX;
995
+ const startY = e.clientY;
996
+ let dragging = false;
997
+ let ghost = null;
998
+ const DRAG_THRESHOLD = 5;
999
+ const srcIdx = clips.findIndex(c => c.id === clipId);
1000
+ if (srcIdx === -1) return;
1001
+ const clip = clips[srcIdx];
1002
+
1003
+ const onMove = (ev) => {
1004
+ const dx = ev.clientX - startX;
1005
+ const dy = ev.clientY - startY;
1006
+ if (!dragging && Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD) {
1007
+ dragging = true;
1008
+ clipEl.classList.add('dragging');
1009
+ const rect = clipEl.getBoundingClientRect();
1010
+ ghost = createDragGhost(clip.name, clip.hue, rect.width, rect.height);
1011
+ }
1012
+ if (!dragging) return;
1013
+
1014
+ // Move ghost
1015
+ ghost.style.left = (ev.clientX - 60) + 'px';
1016
+ ghost.style.top = (ev.clientY - 20) + 'px';
1017
+
1018
+ // Find which clip we're hovering over
1019
+ const allClips = document.querySelectorAll('.clip-block');
1020
+ allClips.forEach(el => {
1021
+ el.classList.remove('drag-over-left', 'drag-over-right');
1022
+ });
1023
+ for (const el of allClips) {
1024
+ if (el === clipEl) continue;
1025
+ const rect = el.getBoundingClientRect();
1026
+ const mid = rect.left + rect.width / 2;
1027
+ if (ev.clientX >= rect.left && ev.clientX <= rect.right) {
1028
+ if (ev.clientX < mid) {
1029
+ el.classList.add('drag-over-left');
1030
+ } else {
1031
+ el.classList.add('drag-over-right');
1032
+ }
1033
+ }
1034
+ }
1035
+ };
1036
+
1037
+ const onUp = (ev) => {
1038
+ document.removeEventListener('mousemove', onMove);
1039
+ document.removeEventListener('mouseup', onUp);
1040
+ clipEl.classList.remove('dragging');
1041
+ if (ghost) { ghost.remove(); ghost = null; }
1042
+
1043
+ if (!dragging) {
1044
+ // Was just a click, not a drag — seek 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
+ }
1053
+ return;
1054
+ }
1055
+
1056
+ // Find drop target
1057
+ const allClips = document.querySelectorAll('.clip-block');
1058
+ let targetIdx = -1;
1059
+ let insertAfter = false;
1060
+ for (const el of allClips) {
1061
+ el.classList.remove('drag-over-left', 'drag-over-right');
1062
+ if (el === clipEl) continue;
1063
+ const rect = el.getBoundingClientRect();
1064
+ const mid = rect.left + rect.width / 2;
1065
+ if (ev.clientX >= rect.left && ev.clientX <= rect.right) {
1066
+ const tId = parseInt(el.dataset.clipId);
1067
+ targetIdx = clips.findIndex(c => c.id === tId);
1068
+ insertAfter = ev.clientX >= mid;
1069
+ break;
1070
+ }
1071
+ }
1072
+
1073
+ if (targetIdx !== -1 && targetIdx !== srcIdx) {
1074
+ const [removed] = clips.splice(srcIdx, 1);
1075
+ let insertIdx = targetIdx;
1076
+ if (srcIdx < targetIdx) insertIdx--;
1077
+ if (insertAfter) insertIdx++;
1078
+ clips.splice(insertIdx, 0, removed);
1079
+ selectedClipId = removed.id;
1080
+ activeClipIndex = clips.findIndex(c => c.id === removed.id);
1081
+ renderTimeline();
1082
+ seekTimeline(getTimelineStartOfClip(activeClipIndex));
1083
+ toast('Clip moved');
1084
+ } else {
1085
+ renderTimeline();
1086
+ }
1087
+ };
1088
+
1089
+ document.addEventListener('mousemove', onMove);
1090
+ document.addEventListener('mouseup', onUp);
1091
+ }
1092
+
1093
+ // ── Media Library Drag to Timeline ────────────────────────────
1094
+ function startMediaLibraryDrag(e, media, card) {
1095
+ const startX = e.clientX;
1096
+ const startY = e.clientY;
1097
+ let dragging = false;
1098
+ let ghost = null;
1099
+ let dropIndicator = null;
1100
+ const DRAG_THRESHOLD = 5;
1101
+
1102
+ const onMove = (ev) => {
1103
+ const dx = ev.clientX - startX;
1104
+ const dy = ev.clientY - startY;
1105
+ if (!dragging && Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD) {
1106
+ dragging = true;
1107
+ card.classList.add('dragging-media');
1108
+ ghost = createDragGhost(media.name, (hueCounter * 37 + 230) % 360, 160, 40);
1109
+ }
1110
+ if (!dragging) return;
1111
+
1112
+ ghost.style.left = (ev.clientX - 60) + 'px';
1113
+ ghost.style.top = (ev.clientY - 20) + 'px';
1114
+
1115
+ // Show drop position on timeline
1116
+ const track = document.getElementById('track');
1117
+ const trackRect = track.getBoundingClientRect();
1118
+ if (ev.clientY >= trackRect.top - 40 && ev.clientY <= trackRect.bottom + 40) {
1119
+ if (!dropIndicator) {
1120
+ dropIndicator = document.createElement('div');
1121
+ dropIndicator.className = 'timeline-drop-indicator';
1122
+ document.getElementById('timelineContent').appendChild(dropIndicator);
1123
+ }
1124
+ const insertIdx = getInsertIndexAtX(ev.clientX);
1125
+ const pos = getTimelineStartOfClip(insertIdx) * PX_PER_SEC;
1126
+ dropIndicator.style.left = pos + 'px';
1127
+ } else if (dropIndicator) {
1128
+ dropIndicator.remove();
1129
+ dropIndicator = null;
1130
+ }
1131
+ };
1132
+
1133
+ const onUp = (ev) => {
1134
+ document.removeEventListener('mousemove', onMove);
1135
+ document.removeEventListener('mouseup', onUp);
1136
+ card.classList.remove('dragging-media');
1137
+ if (ghost) { ghost.remove(); ghost = null; }
1138
+ if (dropIndicator) { dropIndicator.remove(); dropIndicator = null; }
1139
+
1140
+ if (!dragging) return; // was just a click
1141
+
1142
+ // Check if dropped on timeline area
1143
+ const track = document.getElementById('track');
1144
+ const trackRect = track.getBoundingClientRect();
1145
+ if (ev.clientY >= trackRect.top - 40 && ev.clientY <= trackRect.bottom + 40) {
1146
+ const insertIdx = getInsertIndexAtX(ev.clientX);
1147
+ addMediaToTimeline(media, insertIdx);
1148
+ }
1149
+ };
1150
+
1151
+ document.addEventListener('mousemove', onMove);
1152
+ document.addEventListener('mouseup', onUp);
1153
+ }
1154
+
1155
+ function getInsertIndexAtX(clientX) {
1156
+ const content = document.getElementById('timelineContent');
1157
+ const rect = content.getBoundingClientRect();
1158
+ const x = Math.max(0, clientX - rect.left);
1159
+ const t = x / PX_PER_SEC;
1160
+
1161
+ let acc = 0;
1162
+ for (let i = 0; i < clips.length; i++) {
1163
+ const clipDur = (clips[i].endTime - clips[i].startTime) / clips[i].speed;
1164
+ const mid = acc + clipDur / 2;
1165
+ if (t < mid) return i;
1166
+ acc += clipDur;
1167
+ }
1168
+ return clips.length;
1169
+ }
1170
+
915
1171
  document.addEventListener('DOMContentLoaded', setupTimelineInteraction);
916
1172
 
917
1173
  // ── Timeline Rendering ─────────────────────────────────────────
@@ -928,10 +1184,12 @@ function renderTimeline() {
928
1184
  content.style.width = trackWidth + 'px';
929
1185
  track.style.width = totalWidth + 'px';
930
1186
 
1187
+ const totalGap = clips.length > 1 ? (clips.length - 1) * CLIP_GAP : 0;
1188
+
931
1189
  let accTime = 0;
932
1190
  clips.forEach((clip, i) => {
933
1191
  const clipDur = (clip.endTime - clip.startTime) / clip.speed;
934
- const left = accTime * PX_PER_SEC;
1192
+ const left = accTime * PX_PER_SEC + i * CLIP_GAP;
935
1193
  const w = clipDur * PX_PER_SEC;
936
1194
 
937
1195
  const el = document.createElement('div');
@@ -940,7 +1198,8 @@ function renderTimeline() {
940
1198
  el.style.width = w + 'px';
941
1199
  el.dataset.clipId = clip.id;
942
1200
 
943
- const hue = (i * 37 + 230) % 360;
1201
+ // Use stable per-clip hue instead of index-based
1202
+ const hue = clip.hue !== undefined ? clip.hue : (i * 37 + 230) % 360;
944
1203
  el.style.background = `linear-gradient(180deg, hsl(${hue},45%,32%) 0%, hsl(${hue},40%,22%) 100%)`;
945
1204
 
946
1205
  const label = document.createElement('div');
@@ -962,20 +1221,14 @@ function renderTimeline() {
962
1221
  accTime += clipDur;
963
1222
  });
964
1223
 
1224
+ // Account for gaps in total track width
1225
+ track.style.width = (totalDuration * PX_PER_SEC + totalGap) + 'px';
1226
+
965
1227
  renderRuler();
966
1228
  updateClipInfo();
967
- updateFilenameDisplay();
968
1229
  }
969
1230
 
970
- function updateFilenameDisplay() {
971
- const fileEl = document.getElementById('filename');
972
- if (clips.length === 0) {
973
- fileEl.textContent = 'No clips in timeline';
974
- } else {
975
- const names = [...new Set(clips.map(c => c.name))];
976
- fileEl.textContent = names.join(' + ');
977
- }
978
- }
1231
+
979
1232
 
980
1233
  function updateClipInfo() {
981
1234
  const el = document.getElementById('clipInfo');
@@ -1070,7 +1323,8 @@ function splitAtPlayhead() {
1070
1323
  startTime: clip.startTime,
1071
1324
  endTime: t,
1072
1325
  speed: clip.speed,
1073
- duration: clip.duration
1326
+ duration: clip.duration,
1327
+ hue: clip.hue
1074
1328
  };
1075
1329
 
1076
1330
  const newClip2 = {
@@ -1080,7 +1334,8 @@ function splitAtPlayhead() {
1080
1334
  startTime: t,
1081
1335
  endTime: clip.endTime,
1082
1336
  speed: clip.speed,
1083
- duration: clip.duration
1337
+ duration: clip.duration,
1338
+ hue: (hueCounter++ * 37 + 230) % 360
1084
1339
  };
1085
1340
 
1086
1341
  clips.splice(activeClipIndex, 1, newClip1, newClip2);
@@ -1114,19 +1369,7 @@ function deleteSelected() {
1114
1369
  toast('Clip deleted');
1115
1370
  }
1116
1371
 
1117
- function moveClip(dir) {
1118
- const idx = clips.findIndex(c => c.id === selectedClipId);
1119
- if (idx === -1) return;
1120
- const newIdx = idx + dir;
1121
- if (newIdx < 0 || newIdx >= clips.length) return;
1122
-
1123
- [clips[idx], clips[newIdx]] = [clips[newIdx], clips[idx]];
1124
- selectedClipId = clips[newIdx].id;
1125
- activeClipIndex = newIdx;
1126
1372
 
1127
- renderTimeline();
1128
- seekTimeline(getTimelineStartOfClip(newIdx));
1129
- }
1130
1373
 
1131
1374
  // ── Export ──────────────────────────────────────────────────────
1132
1375
  function showExportModal() {
@@ -1138,17 +1381,34 @@ function hideExportModal() {
1138
1381
  document.getElementById('progressWrap').classList.remove('show');
1139
1382
  }
1140
1383
 
1384
+ let exportPollTimer = null;
1385
+
1141
1386
  async function doExport() {
1142
1387
  const fmt = document.getElementById('exportFormat').value;
1143
1388
  const filename = document.getElementById('exportFilename').value || 'output';
1144
1389
  const btn = document.getElementById('exportBtn');
1390
+ const cancelBtn = document.querySelector('#exportModal .btn-ghost');
1145
1391
  const pw = document.getElementById('progressWrap');
1146
1392
  const pb = document.getElementById('progressBar');
1147
1393
 
1148
1394
  btn.disabled = true;
1149
1395
  btn.textContent = 'Exporting...';
1396
+ cancelBtn.style.display = 'none';
1150
1397
  pw.classList.add('show');
1151
- pb.style.width = '30%';
1398
+ pb.style.width = '0%';
1399
+
1400
+ // Poll progress
1401
+ exportPollTimer = setInterval(async () => {
1402
+ try {
1403
+ const r = await fetch('/api/export/progress');
1404
+ const d = await r.json();
1405
+ if (d.progress !== undefined) {
1406
+ pb.style.width = Math.min(99, d.progress) + '%';
1407
+ }
1408
+ } catch(_) {}
1409
+ }, 300);
1410
+
1411
+ const resolution = parseInt(document.getElementById('exportResolution').value);
1152
1412
 
1153
1413
  try {
1154
1414
  const resp = await fetch('/api/export', {
@@ -1162,10 +1422,11 @@ async function doExport() {
1162
1422
  speed: c.speed
1163
1423
  })),
1164
1424
  format: fmt,
1165
- filename: filename
1425
+ filename: filename,
1426
+ resolution: resolution
1166
1427
  })
1167
1428
  });
1168
- pb.style.width = '80%';
1429
+ clearInterval(exportPollTimer);
1169
1430
  const data = await resp.json();
1170
1431
  pb.style.width = '100%';
1171
1432
 
@@ -1176,10 +1437,13 @@ async function doExport() {
1176
1437
  toast('Export error: ' + data.error, true);
1177
1438
  }
1178
1439
  } catch(e) {
1440
+ clearInterval(exportPollTimer);
1179
1441
  toast('Export failed: ' + e.message, true);
1180
1442
  } finally {
1443
+ clearInterval(exportPollTimer);
1181
1444
  btn.disabled = false;
1182
1445
  btn.textContent = 'Export';
1446
+ cancelBtn.style.display = '';
1183
1447
  }
1184
1448
  }
1185
1449
 
@@ -1220,6 +1484,10 @@ function toast(msg, isError) {
1220
1484
  # HTTP Server
1221
1485
  # ---------------------------------------------------------------------------
1222
1486
 
1487
+ # Shared mutable export progress (written by export thread, read by poll handler)
1488
+ _export_progress = {"progress": 0}
1489
+
1490
+
1223
1491
  class _Handler(BaseHTTPRequestHandler):
1224
1492
  """Request handler for the clip editor."""
1225
1493
 
@@ -1237,6 +1505,8 @@ class _Handler(BaseHTTPRequestHandler):
1237
1505
 
1238
1506
  if path == "/" or path == "":
1239
1507
  self._serve_html()
1508
+ elif path == "/api/export/progress":
1509
+ self._json_response(_export_progress)
1240
1510
  elif path.startswith("/video/"):
1241
1511
  self._serve_file(path[7:])
1242
1512
  elif path.startswith("/uploads/"):
@@ -1244,7 +1514,7 @@ class _Handler(BaseHTTPRequestHandler):
1244
1514
  self._serve_static(fpath)
1245
1515
  else:
1246
1516
  self.send_error(404)
1247
- except (BrokenPipeError, ConnectionResetError):
1517
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
1248
1518
  pass
1249
1519
 
1250
1520
  # ── POST ───────────────────────────────────────────────────
@@ -1258,7 +1528,7 @@ class _Handler(BaseHTTPRequestHandler):
1258
1528
  self._handle_export()
1259
1529
  else:
1260
1530
  self.send_error(404)
1261
- except (BrokenPipeError, ConnectionResetError):
1531
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
1262
1532
  pass
1263
1533
 
1264
1534
  # ── Serve HTML (inject initial video) ──────────────────────
@@ -1398,13 +1668,14 @@ class _Handler(BaseHTTPRequestHandler):
1398
1668
  clip_defs = params.get("clips", [])
1399
1669
  fmt = params.get("format", "mp4")
1400
1670
  filename = params.get("filename", "output")
1671
+ resolution = params.get("resolution", 0)
1401
1672
 
1402
1673
  if not clip_defs:
1403
1674
  self._json_response({"error": "No clips"}, 400)
1404
1675
  return
1405
1676
 
1406
1677
  try:
1407
- out_path = self._do_export(clip_defs, fmt, filename)
1678
+ out_path = self._do_export(clip_defs, fmt, filename, resolution)
1408
1679
  self._json_response({"success": True, "path": str(out_path)})
1409
1680
  except Exception as exc:
1410
1681
  self._json_response({"error": str(exc)}, 500)
@@ -1421,8 +1692,11 @@ class _Handler(BaseHTTPRequestHandler):
1421
1692
  name = src.split("/")[-1]
1422
1693
  return Path(self.upload_dir) / name
1423
1694
 
1424
- def _do_export(self, clip_defs: list[dict], fmt: str, filename: str) -> Path:
1695
+ def _do_export(self, clip_defs: list[dict], fmt: str, filename: str, resolution: int = 0) -> Path:
1425
1696
  """Run the actual export using cv2."""
1697
+ global _export_progress
1698
+ _export_progress["progress"] = 0
1699
+
1426
1700
  if not clip_defs:
1427
1701
  raise ValueError("No clips to export")
1428
1702
 
@@ -1432,10 +1706,41 @@ class _Handler(BaseHTTPRequestHandler):
1432
1706
  raise RuntimeError(f"Cannot open video: {first_clip_src}")
1433
1707
 
1434
1708
  fps = first_cap.get(cv2.CAP_PROP_FPS) or 30.0
1435
- w = int(first_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
1436
- h = int(first_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
1709
+ orig_w = int(first_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
1710
+ orig_h = int(first_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
1437
1711
  first_cap.release()
1438
1712
 
1713
+ # Apply resolution scaling
1714
+ if resolution > 0 and orig_w > 0:
1715
+ scale = resolution / orig_w
1716
+ w = resolution
1717
+ h = int(orig_h * scale)
1718
+ # Ensure even dimensions for video codecs
1719
+ h = h + (h % 2)
1720
+ else:
1721
+ w = orig_w
1722
+ h = orig_h
1723
+
1724
+ # Pre-calculate total frames for progress tracking
1725
+ total_frames = 0
1726
+ for clip in clip_defs:
1727
+ clip_src = self._resolve_clip_src(clip["src"])
1728
+ cap = cv2.VideoCapture(str(clip_src))
1729
+ if cap.isOpened():
1730
+ clip_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
1731
+ start_f = int(clip["startTime"] * clip_fps)
1732
+ end_f = int(clip["endTime"] * clip_fps)
1733
+ speed = clip.get("speed", 1.0)
1734
+ if fmt == "gif":
1735
+ sample_every = max(1, int(round(speed)))
1736
+ total_frames += (end_f - start_f + 1) // sample_every
1737
+ else:
1738
+ step = max(1.0, speed)
1739
+ total_frames += int((end_f - start_f) / step) + 1
1740
+ cap.release()
1741
+ total_frames = max(1, total_frames)
1742
+ processed_frames = 0
1743
+
1439
1744
  ext = fmt if fmt != "gif" else "gif"
1440
1745
  out_path = Path(self.export_dir) / f"{filename}.{ext}"
1441
1746
 
@@ -1464,12 +1769,16 @@ class _Handler(BaseHTTPRequestHandler):
1464
1769
  frame = cv2.resize(frame, (w, h))
1465
1770
  frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
1466
1771
  frames_for_gif.append(Image.fromarray(frame_rgb))
1772
+ processed_frames += 1
1773
+ # GIF: frame extraction is ~70% of the work, saving is ~30%
1774
+ _export_progress["progress"] = int(processed_frames / total_frames * 70)
1467
1775
  fi += 1
1468
1776
  cap.release()
1469
1777
 
1470
1778
  if not frames_for_gif:
1471
1779
  raise ValueError("No frames extracted for GIF")
1472
1780
 
1781
+ _export_progress["progress"] = 75
1473
1782
  gif_fps = fps / max(1, int(round(clip_defs[0].get("speed", 1.0))))
1474
1783
  out_path.parent.mkdir(parents=True, exist_ok=True)
1475
1784
  frames_for_gif[0].save(
@@ -1513,11 +1822,14 @@ class _Handler(BaseHTTPRequestHandler):
1513
1822
  if frame.shape[1] != w or frame.shape[0] != h:
1514
1823
  frame = cv2.resize(frame, (w, h))
1515
1824
  writer.write(frame)
1825
+ processed_frames += 1
1826
+ _export_progress["progress"] = int(processed_frames / total_frames * 95)
1516
1827
  fi += step
1517
1828
  cap.release()
1518
1829
  finally:
1519
1830
  writer.release()
1520
1831
 
1832
+ _export_progress["progress"] = 100
1521
1833
  return out_path
1522
1834
 
1523
1835
  # ── Helpers ────────────────────────────────────────────────
@@ -1530,6 +1842,11 @@ class _Handler(BaseHTTPRequestHandler):
1530
1842
  self.wfile.write(body)
1531
1843
 
1532
1844
 
1845
+ class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
1846
+ """Handle requests in separate threads so progress polling works during export."""
1847
+ daemon_threads = True
1848
+
1849
+
1533
1850
  def _find_free_port() -> int:
1534
1851
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1535
1852
  s.bind(("", 0))
@@ -1550,7 +1867,7 @@ def launch_gui(video_path: Path | None = None) -> None:
1550
1867
  _Handler.upload_dir = upload_dir
1551
1868
  _Handler.export_dir = export_dir
1552
1869
 
1553
- server = HTTPServer(("127.0.0.1", port), _Handler)
1870
+ server = _ThreadedHTTPServer(("127.0.0.1", port), _Handler)
1554
1871
  url = f"http://127.0.0.1:{port}"
1555
1872
 
1556
1873
  print(f"ClipVideo Editor running at {url}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devbits"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "A lightweight CLI toolkit for daily development utilities."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -38,6 +38,9 @@ size = "devbits.scripts:size"
38
38
  renamefiles = "devbits.scripts:renamefiles"
39
39
  samplefiles = "devbits.scripts:samplefiles"
40
40
 
41
+ [tool.setuptools.packages.find]
42
+ include = ["devbits*"]
43
+
41
44
  [build-system]
42
45
  requires = ["setuptools>=77.0.3", "wheel"]
43
46
  build-backend = "setuptools.build_meta"
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