devbits 1.0.0__tar.gz → 1.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devbits-1.0.0 → devbits-1.1.2}/PKG-INFO +10 -1
- {devbits-1.0.0 → devbits-1.1.2}/README.md +9 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits/__init__.py +1 -1
- {devbits-1.0.0 → devbits-1.1.2}/devbits/cli.py +42 -6
- {devbits-1.0.0 → devbits-1.1.2}/devbits/gui.py +501 -54
- {devbits-1.0.0 → devbits-1.1.2}/devbits/image.py +53 -1
- {devbits-1.0.0 → devbits-1.1.2}/devbits/scripts.py +4 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits/utils.py +27 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits.egg-info/PKG-INFO +10 -1
- {devbits-1.0.0 → devbits-1.1.2}/devbits.egg-info/entry_points.txt +1 -0
- {devbits-1.0.0 → devbits-1.1.2}/pyproject.toml +2 -1
- {devbits-1.0.0 → devbits-1.1.2}/LICENSE +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits/cache.py +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits/media.py +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits/project.py +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits.egg-info/SOURCES.txt +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits.egg-info/dependency_links.txt +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits.egg-info/requires.txt +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/devbits.egg-info/top_level.txt +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/setup.cfg +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/tests/test_cli.py +0 -0
- {devbits-1.0.0 → devbits-1.1.2}/tests/test_gui_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: A lightweight CLI toolkit for daily development utilities.
|
|
5
5
|
Author: Bruce Chuang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -64,6 +64,7 @@ clipvideo --help
|
|
|
64
64
|
| Command | Description |
|
|
65
65
|
|---------|-------------|
|
|
66
66
|
| `resizeimage` | Resize a single image (preserves aspect ratio by default). |
|
|
67
|
+
| `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
|
|
67
68
|
| `image2ico` | Convert an image to a multi-size ICO file. |
|
|
68
69
|
| `batchimages` | Batch resize or convert all images in a folder. |
|
|
69
70
|
| `checkimages` | Scan for broken / corrupt image files. |
|
|
@@ -94,6 +95,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
|
|
|
94
95
|
# Extract every 5th frame as PNG
|
|
95
96
|
video2images movie.mp4 --every 5 --format png
|
|
96
97
|
|
|
98
|
+
# Recolor a logo's foreground to black (keeps the background)
|
|
99
|
+
recolor logo.png
|
|
100
|
+
|
|
101
|
+
# Recolor a logo's foreground to a custom color (hex or R,G,B)
|
|
102
|
+
recolor logo.png --color '#1a73e8'
|
|
103
|
+
recolor logo.png --color 0,178,179
|
|
104
|
+
|
|
97
105
|
# Batch resize images to 800×600
|
|
98
106
|
batchimages ./photos -o ./resized --size 800,600
|
|
99
107
|
|
|
@@ -109,6 +117,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
|
|
|
109
117
|
clipvideo movie.mp4 → movie_clip.mp4
|
|
110
118
|
video2gif movie.mp4 → movie.gif
|
|
111
119
|
resizeimage photo.jpg → photo_resized.jpg
|
|
120
|
+
recolor logo.png → logo_revised.png
|
|
112
121
|
contactsheet ./photos → photos_sheet.jpg
|
|
113
122
|
```
|
|
114
123
|
|
|
@@ -47,6 +47,7 @@ clipvideo --help
|
|
|
47
47
|
| Command | Description |
|
|
48
48
|
|---------|-------------|
|
|
49
49
|
| `resizeimage` | Resize a single image (preserves aspect ratio by default). |
|
|
50
|
+
| `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
|
|
50
51
|
| `image2ico` | Convert an image to a multi-size ICO file. |
|
|
51
52
|
| `batchimages` | Batch resize or convert all images in a folder. |
|
|
52
53
|
| `checkimages` | Scan for broken / corrupt image files. |
|
|
@@ -77,6 +78,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
|
|
|
77
78
|
# Extract every 5th frame as PNG
|
|
78
79
|
video2images movie.mp4 --every 5 --format png
|
|
79
80
|
|
|
81
|
+
# Recolor a logo's foreground to black (keeps the background)
|
|
82
|
+
recolor logo.png
|
|
83
|
+
|
|
84
|
+
# Recolor a logo's foreground to a custom color (hex or R,G,B)
|
|
85
|
+
recolor logo.png --color '#1a73e8'
|
|
86
|
+
recolor logo.png --color 0,178,179
|
|
87
|
+
|
|
80
88
|
# Batch resize images to 800×600
|
|
81
89
|
batchimages ./photos -o ./resized --size 800,600
|
|
82
90
|
|
|
@@ -92,6 +100,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
|
|
|
92
100
|
clipvideo movie.mp4 → movie_clip.mp4
|
|
93
101
|
video2gif movie.mp4 → movie.gif
|
|
94
102
|
resizeimage photo.jpg → photo_resized.jpg
|
|
103
|
+
recolor logo.png → logo_revised.png
|
|
95
104
|
contactsheet ./photos → photos_sheet.jpg
|
|
96
105
|
```
|
|
97
106
|
|
|
@@ -4,8 +4,9 @@ import argparse
|
|
|
4
4
|
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
from . import __version__
|
|
7
8
|
from .cache import clear_cache
|
|
8
|
-
from .image import batch_images, check_images, contact_sheet, image_to_ico, resize_image
|
|
9
|
+
from .image import batch_images, check_images, contact_sheet, image_to_ico, recolor_image, resize_image
|
|
9
10
|
from .media import clip_video, images_to_gif, images_to_video, resize_video, video_to_gif, video_to_images
|
|
10
11
|
from .project import print_tree, rename_files, sample_files, top_sizes
|
|
11
12
|
from .utils import ensure_exists
|
|
@@ -32,7 +33,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
32
33
|
description="Daily development utility CLI toolkit.",
|
|
33
34
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
34
35
|
)
|
|
35
|
-
parser.add_argument("--version", action="version", version="devbits
|
|
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
|
|
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
|
|
163
|
+
" devbits clipvideo --gui # GUI with no initial video"
|
|
163
164
|
),
|
|
164
165
|
)
|
|
165
166
|
p.add_argument("video", type=Path, nargs="?", default=None,
|
|
@@ -237,6 +238,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
237
238
|
help="Do not preserve aspect ratio; stretch to exact size.")
|
|
238
239
|
p.set_defaults(func=cmd_resizeimage)
|
|
239
240
|
|
|
241
|
+
# ── recolor ────────────────────────────────────────────────
|
|
242
|
+
p = sub.add_parser(
|
|
243
|
+
"recolor",
|
|
244
|
+
help="Recolor the foreground of a logo / icon image.",
|
|
245
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
246
|
+
description=(
|
|
247
|
+
"Recolor a logo or icon. The background (transparent or a lighter\n"
|
|
248
|
+
"surrounding color) is detected automatically and left untouched,\n"
|
|
249
|
+
"while every foreground pixel is repainted with the target color.\n"
|
|
250
|
+
"The result is always saved as an RGBA PNG.\n\n"
|
|
251
|
+
"Examples:\n"
|
|
252
|
+
" devbits recolor logo.png\n"
|
|
253
|
+
" devbits recolor logo.png --color '#1a73e8'\n"
|
|
254
|
+
" devbits recolor logo.png --color 0,178,179\n"
|
|
255
|
+
" devbits recolor icon.jpg --color white --threshold 90"
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
p.add_argument("image", type=Path, help="Input logo / icon image.")
|
|
259
|
+
p.add_argument("-o", "--output", type=Path, default=None,
|
|
260
|
+
help="Output image path. Default: <image_stem>_revised.png")
|
|
261
|
+
p.add_argument("--color", default="black",
|
|
262
|
+
help="Target foreground color: name, hex, or R,G,B (e.g. black, '#1a73e8', 0,178,179). Default: black")
|
|
263
|
+
p.add_argument("--threshold", type=int, default=60,
|
|
264
|
+
help="Color distance from the background for opaque images. Default: 60")
|
|
265
|
+
p.set_defaults(func=cmd_recolor)
|
|
266
|
+
|
|
240
267
|
# ── batchimages ────────────────────────────────────────────
|
|
241
268
|
p = sub.add_parser(
|
|
242
269
|
"batchimages",
|
|
@@ -423,7 +450,10 @@ def cmd_video2gif(args: argparse.Namespace) -> None:
|
|
|
423
450
|
|
|
424
451
|
|
|
425
452
|
def cmd_clipvideo(args: argparse.Namespace) -> None:
|
|
426
|
-
|
|
453
|
+
has_range = any(v is not None for v in (args.start, args.end, args.start_frame, args.end_frame))
|
|
454
|
+
# Open the interactive editor when --gui is set, or by default when a video
|
|
455
|
+
# is provided without an explicit trim range.
|
|
456
|
+
if args.gui or (args.video is not None and not has_range):
|
|
427
457
|
from .gui import launch_gui
|
|
428
458
|
launch_gui(args.video)
|
|
429
459
|
return
|
|
@@ -452,6 +482,12 @@ def cmd_resizeimage(args: argparse.Namespace) -> None:
|
|
|
452
482
|
print(resize_image(image, output, args.size, not args.no_keep_ratio))
|
|
453
483
|
|
|
454
484
|
|
|
485
|
+
def cmd_recolor(args: argparse.Namespace) -> None:
|
|
486
|
+
image = ensure_exists(args.image)
|
|
487
|
+
output = args.output or _derive_output(image, ".png", "revised")
|
|
488
|
+
print(recolor_image(image, output, args.color, args.threshold))
|
|
489
|
+
|
|
490
|
+
|
|
455
491
|
def cmd_batchimages(args: argparse.Namespace) -> None:
|
|
456
492
|
outputs = batch_images(ensure_exists(args.folder), args.output, args.size, args.format)
|
|
457
493
|
print(f"Saved {len(outputs)} image(s) to {args.output}")
|
|
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import mimetypes
|
|
11
11
|
import os
|
|
12
|
+
import shutil
|
|
12
13
|
import socket
|
|
14
|
+
import subprocess
|
|
13
15
|
import tempfile
|
|
14
16
|
import threading
|
|
15
17
|
import webbrowser
|
|
@@ -31,6 +33,7 @@ _HTML = r"""<!DOCTYPE html>
|
|
|
31
33
|
<meta charset="utf-8">
|
|
32
34
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
33
35
|
<title>Divbits.ClipVideo</title>
|
|
36
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA2NCA2NCc+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSdnJyB4MT0nMCcgeTE9JzAnIHgyPScxJyB5Mj0nMSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjN2M1Y2ZjJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjMDBkNGZmJy8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9JzY0JyBoZWlnaHQ9JzY0JyByeD0nMTUnIGZpbGw9J3VybCgjZyknLz48cGF0aCBkPSdNMjUgMTkgTDQ3IDMyIEwyNSA0NSBaJyBmaWxsPScjZmZmJy8+PC9zdmc+Cg==">
|
|
34
37
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
35
38
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
36
39
|
<style>
|
|
@@ -142,6 +145,18 @@ body{
|
|
|
142
145
|
cursor:pointer;transition:all 0.15s;
|
|
143
146
|
}
|
|
144
147
|
.media-add-btn:hover{background:#8d6eff;transform:scale(1.1)}
|
|
148
|
+
.media-del-btn{
|
|
149
|
+
width:24px;height:24px;border-radius:50%;border:none;
|
|
150
|
+
background:rgba(255,60,60,.14);color:#ff6b6b;font-size:.85rem;line-height:1;
|
|
151
|
+
display:flex;align-items:center;justify-content:center;
|
|
152
|
+
cursor:pointer;transition:all 0.15s;flex-shrink:0;
|
|
153
|
+
}
|
|
154
|
+
.media-del-btn:hover{background:rgba(255,60,60,.3);transform:scale(1.1)}
|
|
155
|
+
/* Sidebar drop-target highlight */
|
|
156
|
+
.sidebar.drag-over{
|
|
157
|
+
background:rgba(124,92,252,.08);
|
|
158
|
+
outline:2px dashed rgba(124,92,252,.45);outline-offset:-6px;
|
|
159
|
+
}
|
|
145
160
|
|
|
146
161
|
/* ── Content Area (Right side) ─────────────────────────────── */
|
|
147
162
|
.content-area{
|
|
@@ -388,6 +403,32 @@ kbd{
|
|
|
388
403
|
margin-left:4px;
|
|
389
404
|
}
|
|
390
405
|
|
|
406
|
+
/* ── Context Menu ──────────────────────────────────────────── */
|
|
407
|
+
.context-menu{
|
|
408
|
+
position:fixed;z-index:3000;min-width:172px;
|
|
409
|
+
background:#16162b;border:1px solid rgba(255,255,255,.1);
|
|
410
|
+
border-radius:10px;padding:6px;
|
|
411
|
+
box-shadow:0 12px 40px rgba(0,0,0,.55);
|
|
412
|
+
font-size:.82rem;user-select:none;
|
|
413
|
+
}
|
|
414
|
+
.ctx-item{
|
|
415
|
+
display:flex;align-items:center;gap:9px;
|
|
416
|
+
padding:8px 10px;border-radius:6px;cursor:pointer;color:#d0d0e4;
|
|
417
|
+
}
|
|
418
|
+
.ctx-item:hover{background:rgba(124,92,252,.2)}
|
|
419
|
+
.ctx-item.danger{color:#ff6b6b}
|
|
420
|
+
.ctx-item.danger:hover{background:rgba(255,60,60,.18)}
|
|
421
|
+
.ctx-item kbd{margin-left:auto}
|
|
422
|
+
.ctx-sep{height:1px;background:rgba(255,255,255,.08);margin:6px 4px}
|
|
423
|
+
.ctx-label{font-size:.66rem;color:#777799;text-transform:uppercase;letter-spacing:.6px;padding:6px 10px 2px}
|
|
424
|
+
.ctx-speeds{display:flex;flex-wrap:wrap;gap:5px;padding:4px 8px 6px}
|
|
425
|
+
.ctx-speed{
|
|
426
|
+
padding:4px 9px;border-radius:5px;background:rgba(255,255,255,.06);
|
|
427
|
+
color:#c0c0da;cursor:pointer;font-size:.74rem;transition:all .12s;
|
|
428
|
+
}
|
|
429
|
+
.ctx-speed:hover{background:rgba(124,92,252,.35)}
|
|
430
|
+
.ctx-speed.active{background:#7c5cfc;color:#fff}
|
|
431
|
+
|
|
391
432
|
/* ── Responsive ────────────────────────────────────────────── */
|
|
392
433
|
@media(max-width:850px){
|
|
393
434
|
.main{flex-direction:column}
|
|
@@ -410,7 +451,7 @@ kbd{
|
|
|
410
451
|
<!-- Main Layout -->
|
|
411
452
|
<div class="main">
|
|
412
453
|
<!-- Sidebar (Media Library) -->
|
|
413
|
-
<aside class="sidebar">
|
|
454
|
+
<aside class="sidebar" id="sidebar">
|
|
414
455
|
<h3>Media Library</h3>
|
|
415
456
|
<button class="btn btn-primary" onclick="openFile()" style="margin-bottom: 12px; justify-content: center; width: 100%;">
|
|
416
457
|
📂 Import media
|
|
@@ -434,11 +475,15 @@ kbd{
|
|
|
434
475
|
|
|
435
476
|
<!-- Playback Controls -->
|
|
436
477
|
<div class="controls-bar">
|
|
437
|
-
<button class="step-btn" onclick="seekTimeline(0)" title="Jump to start"
|
|
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"
|
|
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
|
-
|
|
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
|
-
//
|
|
1045
|
-
|
|
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 clip — selection 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
|
-
|
|
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
|
-
|
|
1467
|
+
pendingSeek = clip.startTime;
|
|
1289
1468
|
} else {
|
|
1290
1469
|
clip.endTime = Math.max(clip.startTime + 0.1, Math.min(clip.duration, origEnd + dt));
|
|
1291
|
-
|
|
1470
|
+
pendingSeek = clip.endTime;
|
|
1292
1471
|
}
|
|
1293
|
-
|
|
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
|
-
|
|
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
|
|
1504
|
+
toast('Move the cut point inside the clip to split');
|
|
1316
1505
|
return;
|
|
1317
1506
|
}
|
|
1318
1507
|
|
|
1319
|
-
const newClip1 = {
|
|
1320
|
-
|
|
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(
|
|
1342
|
-
activeClipIndex =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1823
|
-
frame
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2267
|
+
if fi >= next_write - 1e-9:
|
|
2268
|
+
if frame.shape[1] != w or frame.shape[0] != h:
|
|
2269
|
+
frame = cv2.resize(frame, (w, h))
|
|
2270
|
+
writer.write(frame)
|
|
2271
|
+
processed_frames += 1
|
|
2272
|
+
_export_progress["progress"] = int(processed_frames / total_frames * 95)
|
|
2273
|
+
next_write += step
|
|
2274
|
+
fi += 1
|
|
1828
2275
|
cap.release()
|
|
1829
2276
|
finally:
|
|
1830
2277
|
writer.release()
|
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
|
|
6
6
|
|
|
7
|
-
from .utils import ensure_dir, list_images, parse_size
|
|
7
|
+
from .utils import ensure_dir, list_images, parse_color, parse_size
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def image_to_ico(input_path: Path, output_path: Path, sizes: str = "16,32,48,64,128,256") -> Path:
|
|
@@ -81,6 +81,58 @@ def resize_image(input_path: Path, output_path: Path, size: str, keep_ratio: boo
|
|
|
81
81
|
return output_path
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def recolor_image(
|
|
85
|
+
input_path: Path,
|
|
86
|
+
output_path: Path,
|
|
87
|
+
color: str = "black",
|
|
88
|
+
threshold: int = 60,
|
|
89
|
+
) -> Path:
|
|
90
|
+
"""Recolor the foreground of a logo / icon while keeping its background.
|
|
91
|
+
|
|
92
|
+
The background is detected either from transparency (transparent pixels are
|
|
93
|
+
treated as background) or, for fully opaque images, from the dominant border
|
|
94
|
+
color (the lighter surrounding area). Every foreground pixel is repainted
|
|
95
|
+
with ``color`` (a name like ``black``, a hex value like ``#1a73e8``, or an
|
|
96
|
+
``R,G,B`` triple like ``0,178,179``). The result is always saved as an RGBA
|
|
97
|
+
PNG so soft, anti-aliased edges are kept.
|
|
98
|
+
"""
|
|
99
|
+
import numpy as np
|
|
100
|
+
|
|
101
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
target_rgb = parse_color(color)
|
|
104
|
+
|
|
105
|
+
with Image.open(input_path) as image:
|
|
106
|
+
arr = np.array(image.convert("RGBA"))
|
|
107
|
+
|
|
108
|
+
rgb = arr[..., :3].astype(np.int16)
|
|
109
|
+
alpha = arr[..., 3]
|
|
110
|
+
|
|
111
|
+
# Step 1: distinguish background from foreground.
|
|
112
|
+
transparent = alpha < 16
|
|
113
|
+
if transparent.mean() > 0.02:
|
|
114
|
+
# Transparency marks the background; opaque pixels are the foreground.
|
|
115
|
+
foreground = alpha >= 16
|
|
116
|
+
else:
|
|
117
|
+
# Opaque image: estimate the (lighter) background from the border color.
|
|
118
|
+
border = np.concatenate(
|
|
119
|
+
[rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0
|
|
120
|
+
)
|
|
121
|
+
bg_color = np.median(border, axis=0)
|
|
122
|
+
distance = np.sqrt(((rgb - bg_color) ** 2).sum(axis=2))
|
|
123
|
+
foreground = distance > threshold
|
|
124
|
+
|
|
125
|
+
# Step 2: paint the foreground with the target color (alpha is preserved,
|
|
126
|
+
# so anti-aliased edges keep their soft blend).
|
|
127
|
+
arr[..., 0][foreground] = target_rgb[0]
|
|
128
|
+
arr[..., 1][foreground] = target_rgb[1]
|
|
129
|
+
arr[..., 2][foreground] = target_rgb[2]
|
|
130
|
+
|
|
131
|
+
# Step 3: export as RGBA PNG.
|
|
132
|
+
Image.fromarray(arr, mode="RGBA").save(output_path, format="PNG")
|
|
133
|
+
return output_path
|
|
134
|
+
|
|
135
|
+
|
|
84
136
|
def batch_images(folder: Path, output_folder: Path, size: str | None = None, fmt: str | None = None) -> list[Path]:
|
|
85
137
|
ensure_dir(output_folder)
|
|
86
138
|
outputs: list[Path] = []
|
|
@@ -46,6 +46,33 @@ def parse_size(size: str) -> tuple[int, int]:
|
|
|
46
46
|
return width_i, height_i
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def parse_color(value: str) -> tuple[int, int, int]:
|
|
50
|
+
"""Parse a color into an ``(R, G, B)`` tuple.
|
|
51
|
+
|
|
52
|
+
Accepts a CSS name (``black``), a hex value (``#1a73e8``), or a comma-/
|
|
53
|
+
space-separated ``R,G,B`` triple (``0,178,179``).
|
|
54
|
+
"""
|
|
55
|
+
from PIL import ImageColor
|
|
56
|
+
|
|
57
|
+
text = value.strip()
|
|
58
|
+
if "," in text:
|
|
59
|
+
parts = [p.strip() for p in text.split(",") if p.strip()]
|
|
60
|
+
if len(parts) != 3:
|
|
61
|
+
raise ValueError(f"RGB color must have 3 components, got: {value!r}")
|
|
62
|
+
try:
|
|
63
|
+
channels = tuple(int(p) for p in parts)
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise ValueError(f"RGB components must be integers: {value!r}") from exc
|
|
66
|
+
if any(c < 0 or c > 255 for c in channels):
|
|
67
|
+
raise ValueError(f"RGB components must be in 0-255: {value!r}")
|
|
68
|
+
return channels # type: ignore[return-value]
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return ImageColor.getrgb(text)[:3]
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise ValueError(f"Unrecognized color: {value!r}") from exc
|
|
74
|
+
|
|
75
|
+
|
|
49
76
|
def parse_int_tuple(value: str, expected: int, name: str) -> tuple[int, ...]:
|
|
50
77
|
try:
|
|
51
78
|
items = tuple(int(x.strip()) for x in value.split(","))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: A lightweight CLI toolkit for daily development utilities.
|
|
5
5
|
Author: Bruce Chuang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -64,6 +64,7 @@ clipvideo --help
|
|
|
64
64
|
| Command | Description |
|
|
65
65
|
|---------|-------------|
|
|
66
66
|
| `resizeimage` | Resize a single image (preserves aspect ratio by default). |
|
|
67
|
+
| `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
|
|
67
68
|
| `image2ico` | Convert an image to a multi-size ICO file. |
|
|
68
69
|
| `batchimages` | Batch resize or convert all images in a folder. |
|
|
69
70
|
| `checkimages` | Scan for broken / corrupt image files. |
|
|
@@ -94,6 +95,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
|
|
|
94
95
|
# Extract every 5th frame as PNG
|
|
95
96
|
video2images movie.mp4 --every 5 --format png
|
|
96
97
|
|
|
98
|
+
# Recolor a logo's foreground to black (keeps the background)
|
|
99
|
+
recolor logo.png
|
|
100
|
+
|
|
101
|
+
# Recolor a logo's foreground to a custom color (hex or R,G,B)
|
|
102
|
+
recolor logo.png --color '#1a73e8'
|
|
103
|
+
recolor logo.png --color 0,178,179
|
|
104
|
+
|
|
97
105
|
# Batch resize images to 800×600
|
|
98
106
|
batchimages ./photos -o ./resized --size 800,600
|
|
99
107
|
|
|
@@ -109,6 +117,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
|
|
|
109
117
|
clipvideo movie.mp4 → movie_clip.mp4
|
|
110
118
|
video2gif movie.mp4 → movie.gif
|
|
111
119
|
resizeimage photo.jpg → photo_resized.jpg
|
|
120
|
+
recolor logo.png → logo_revised.png
|
|
112
121
|
contactsheet ./photos → photos_sheet.jpg
|
|
113
122
|
```
|
|
114
123
|
|
|
@@ -8,6 +8,7 @@ devbits = devbits.cli:main
|
|
|
8
8
|
image2ico = devbits.scripts:image2ico
|
|
9
9
|
images2gif = devbits.scripts:images2gif
|
|
10
10
|
images2video = devbits.scripts:images2video
|
|
11
|
+
recolor = devbits.scripts:recolor
|
|
11
12
|
renamefiles = devbits.scripts:renamefiles
|
|
12
13
|
resizeimage = devbits.scripts:resizeimage
|
|
13
14
|
resizevideo = devbits.scripts:resizevideo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devbits"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.1.2"
|
|
4
4
|
description = "A lightweight CLI toolkit for daily development utilities."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -30,6 +30,7 @@ clipvideo = "devbits.scripts:clipvideo"
|
|
|
30
30
|
resizevideo = "devbits.scripts:resizevideo"
|
|
31
31
|
image2ico = "devbits.scripts:image2ico"
|
|
32
32
|
resizeimage = "devbits.scripts:resizeimage"
|
|
33
|
+
recolor = "devbits.scripts:recolor"
|
|
33
34
|
batchimages = "devbits.scripts:batchimages"
|
|
34
35
|
checkimages = "devbits.scripts:checkimages"
|
|
35
36
|
contactsheet = "devbits.scripts:contactsheet"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|