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.
- {devbits-0.1.2 → devbits-0.1.4}/PKG-INFO +1 -1
- {devbits-0.1.2 → devbits-0.1.4}/devbits/__init__.py +1 -1
- {devbits-0.1.2 → devbits-0.1.4}/devbits/gui.py +376 -59
- {devbits-0.1.2 → devbits-0.1.4}/devbits.egg-info/PKG-INFO +1 -1
- {devbits-0.1.2 → devbits-0.1.4}/pyproject.toml +4 -1
- {devbits-0.1.2 → devbits-0.1.4}/LICENSE +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/README.md +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits/cache.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits/cli.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits/image.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits/media.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits/project.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits/scripts.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits/utils.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits.egg-info/SOURCES.txt +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits.egg-info/dependency_links.txt +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits.egg-info/entry_points.txt +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits.egg-info/requires.txt +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/devbits.egg-info/top_level.txt +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/setup.cfg +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/tests/test_cli.py +0 -0
- {devbits-0.1.2 → devbits-0.1.4}/tests/test_gui_cli.py +0 -0
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
1436
|
-
|
|
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 =
|
|
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
|
[project]
|
|
2
2
|
name = "devbits"
|
|
3
|
-
version = "0.1.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|