devbits 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- devbits/cache.py +32 -0
- devbits/cli.py +228 -0
- devbits/image.py +96 -0
- devbits/media.py +186 -0
- devbits/project.py +61 -0
- devbits/scripts.py +75 -0
- devbits/utils.py +85 -0
- devbits-0.1.0.dist-info/METADATA +175 -0
- devbits-0.1.0.dist-info/RECORD +14 -0
- devbits-0.1.0.dist-info/WHEEL +5 -0
- devbits-0.1.0.dist-info/entry_points.txt +18 -0
- devbits-0.1.0.dist-info/licenses/LICENSE +21 -0
- devbits-0.1.0.dist-info/top_level.txt +1 -0
devbits/__init__.py
ADDED
devbits/cache.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
CACHE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
|
|
7
|
+
PYC_PATTERNS = ["*.pyc", "*.pyo"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def clear_cache(root: Path, include_extra: bool = False, dry_run: bool = False) -> list[Path]:
|
|
11
|
+
targets: list[Path] = []
|
|
12
|
+
dir_names = set(CACHE_DIR_NAMES if include_extra else {"__pycache__"})
|
|
13
|
+
|
|
14
|
+
for path in root.rglob("*"):
|
|
15
|
+
if path.is_dir() and path.name in dir_names:
|
|
16
|
+
targets.append(path)
|
|
17
|
+
|
|
18
|
+
for pattern in PYC_PATTERNS:
|
|
19
|
+
targets.extend(root.rglob(pattern))
|
|
20
|
+
|
|
21
|
+
unique_targets = sorted(set(targets), key=lambda p: len(p.parts), reverse=True)
|
|
22
|
+
if dry_run:
|
|
23
|
+
return unique_targets
|
|
24
|
+
|
|
25
|
+
for target in unique_targets:
|
|
26
|
+
if not target.exists():
|
|
27
|
+
continue
|
|
28
|
+
if target.is_dir():
|
|
29
|
+
shutil.rmtree(target)
|
|
30
|
+
else:
|
|
31
|
+
target.unlink()
|
|
32
|
+
return unique_targets
|
devbits/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .cache import clear_cache
|
|
8
|
+
from .image import batch_images, check_images, contact_sheet, image_to_ico, resize_image
|
|
9
|
+
from .media import clip_video, images_to_gif, images_to_video, resize_video, video_to_gif, video_to_images
|
|
10
|
+
from .project import print_tree, rename_files, sample_files, top_sizes
|
|
11
|
+
from .utils import ensure_exists
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="devbits", description="Daily development utility CLI toolkit.")
|
|
16
|
+
parser.add_argument("--version", action="version", version="devbits 0.1.0")
|
|
17
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
|
|
19
|
+
p = sub.add_parser("clearcache", help="Clear Python cache files under a folder.")
|
|
20
|
+
p.add_argument("folder", type=Path)
|
|
21
|
+
p.add_argument("--all", action="store_true", help="Also remove pytest, mypy, and ruff caches.")
|
|
22
|
+
p.add_argument("--dry-run", action="store_true")
|
|
23
|
+
p.set_defaults(func=cmd_clearcache)
|
|
24
|
+
|
|
25
|
+
p = sub.add_parser("images2video", help="Convert image sequence to MP4 video.")
|
|
26
|
+
p.add_argument("folder", type=Path)
|
|
27
|
+
p.add_argument("-o", "--output", type=Path, default=Path("output.mp4"))
|
|
28
|
+
p.add_argument("--fps", type=float, default=30.0)
|
|
29
|
+
p.add_argument("--pattern", default="*")
|
|
30
|
+
p.set_defaults(func=cmd_images2video)
|
|
31
|
+
|
|
32
|
+
p = sub.add_parser("video2images", help="Extract frames from a video.")
|
|
33
|
+
p.add_argument("video", type=Path)
|
|
34
|
+
p.add_argument("-o", "--output", type=Path, default=Path("frames"))
|
|
35
|
+
p.add_argument("--every", type=int, default=1)
|
|
36
|
+
p.add_argument("--prefix", default="frame")
|
|
37
|
+
p.add_argument("--digits", type=int, default=6)
|
|
38
|
+
p.add_argument("--format", default="jpg")
|
|
39
|
+
p.set_defaults(func=cmd_video2images)
|
|
40
|
+
|
|
41
|
+
p = sub.add_parser("images2gif", help="Convert image sequence to GIF.")
|
|
42
|
+
p.add_argument("folder", type=Path)
|
|
43
|
+
p.add_argument("-o", "--output", type=Path, default=Path("output.gif"))
|
|
44
|
+
p.add_argument("--fps", type=float, default=10.0)
|
|
45
|
+
p.add_argument("--pattern", default="*")
|
|
46
|
+
p.set_defaults(func=cmd_images2gif)
|
|
47
|
+
|
|
48
|
+
p = sub.add_parser("video2gif", help="Convert video to GIF.")
|
|
49
|
+
p.add_argument("video", type=Path)
|
|
50
|
+
p.add_argument("-o", "--output", type=Path, default=Path("output.gif"))
|
|
51
|
+
p.add_argument("--fps", type=float, default=10.0)
|
|
52
|
+
p.add_argument("--start", type=float)
|
|
53
|
+
p.add_argument("--end", type=float)
|
|
54
|
+
p.add_argument("--size", help="Output size, e.g. 640,360")
|
|
55
|
+
p.set_defaults(func=cmd_video2gif)
|
|
56
|
+
|
|
57
|
+
p = sub.add_parser("clipvideo", help="Clip video by seconds or frame indices.")
|
|
58
|
+
p.add_argument("video", type=Path)
|
|
59
|
+
p.add_argument("-o", "--output", type=Path, default=Path("clip.mp4"))
|
|
60
|
+
p.add_argument("--start", type=float)
|
|
61
|
+
p.add_argument("--end", type=float)
|
|
62
|
+
p.add_argument("--start-frame", type=int)
|
|
63
|
+
p.add_argument("--end-frame", type=int)
|
|
64
|
+
p.add_argument("--gui", action="store_true", help="Reserved for future GUI clip selection.")
|
|
65
|
+
p.set_defaults(func=cmd_clipvideo)
|
|
66
|
+
|
|
67
|
+
p = sub.add_parser("resizevideo", help="Resize a video.")
|
|
68
|
+
p.add_argument("video", type=Path)
|
|
69
|
+
p.add_argument("-o", "--output", type=Path, default=Path("resized.mp4"))
|
|
70
|
+
p.add_argument("--size", required=True, help="Output size, e.g. 1280,720")
|
|
71
|
+
p.set_defaults(func=cmd_resizevideo)
|
|
72
|
+
|
|
73
|
+
p = sub.add_parser("image2ico", help="Convert an image to ICO.")
|
|
74
|
+
p.add_argument("image", type=Path)
|
|
75
|
+
p.add_argument("-o", "--output", type=Path, default=Path("image.ico"))
|
|
76
|
+
p.add_argument("--sizes", default="16,32,48,64,128,256")
|
|
77
|
+
p.set_defaults(func=cmd_image2ico)
|
|
78
|
+
|
|
79
|
+
p = sub.add_parser("resizeimage", help="Resize an image.")
|
|
80
|
+
p.add_argument("image", type=Path)
|
|
81
|
+
p.add_argument("-o", "--output", type=Path, default=Path("resized.jpg"))
|
|
82
|
+
p.add_argument("--size", required=True)
|
|
83
|
+
p.add_argument("--no-keep-ratio", action="store_true")
|
|
84
|
+
p.set_defaults(func=cmd_resizeimage)
|
|
85
|
+
|
|
86
|
+
p = sub.add_parser("batchimages", help="Batch resize or convert images.")
|
|
87
|
+
p.add_argument("folder", type=Path)
|
|
88
|
+
p.add_argument("-o", "--output", type=Path, required=True)
|
|
89
|
+
p.add_argument("--size")
|
|
90
|
+
p.add_argument("--format")
|
|
91
|
+
p.set_defaults(func=cmd_batchimages)
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("checkimages", help="Check broken image files.")
|
|
94
|
+
p.add_argument("folder", type=Path)
|
|
95
|
+
p.add_argument("--recursive", action="store_true")
|
|
96
|
+
p.add_argument("--remove-broken", action="store_true")
|
|
97
|
+
p.set_defaults(func=cmd_checkimages)
|
|
98
|
+
|
|
99
|
+
p = sub.add_parser("contactsheet", help="Create a contact sheet from images.")
|
|
100
|
+
p.add_argument("folder", type=Path)
|
|
101
|
+
p.add_argument("-o", "--output", type=Path, default=Path("sheet.jpg"))
|
|
102
|
+
p.add_argument("--cols", type=int, default=5)
|
|
103
|
+
p.add_argument("--thumb-size", default="256,256")
|
|
104
|
+
p.add_argument("--labels", action="store_true")
|
|
105
|
+
p.set_defaults(func=cmd_contactsheet)
|
|
106
|
+
|
|
107
|
+
p = sub.add_parser("tree", help="Print project tree.")
|
|
108
|
+
p.add_argument("folder", type=Path, nargs="?", default=Path("."))
|
|
109
|
+
p.add_argument("--depth", type=int, default=3)
|
|
110
|
+
p.set_defaults(func=cmd_tree)
|
|
111
|
+
|
|
112
|
+
p = sub.add_parser("size", help="Show top-level folder sizes.")
|
|
113
|
+
p.add_argument("folder", type=Path, nargs="?", default=Path("."))
|
|
114
|
+
p.add_argument("--top", type=int, default=20)
|
|
115
|
+
p.set_defaults(func=cmd_size)
|
|
116
|
+
|
|
117
|
+
p = sub.add_parser("renamefiles", help="Batch rename files in a folder.")
|
|
118
|
+
p.add_argument("folder", type=Path)
|
|
119
|
+
p.add_argument("--prefix", default="file")
|
|
120
|
+
p.add_argument("--digits", type=int, default=6)
|
|
121
|
+
p.add_argument("--start", type=int, default=1)
|
|
122
|
+
p.add_argument("--dry-run", action="store_true")
|
|
123
|
+
p.set_defaults(func=cmd_renamefiles)
|
|
124
|
+
|
|
125
|
+
p = sub.add_parser("samplefiles", help="Copy first N files into another folder.")
|
|
126
|
+
p.add_argument("folder", type=Path)
|
|
127
|
+
p.add_argument("-o", "--output", type=Path, required=True)
|
|
128
|
+
p.add_argument("--num", type=int, required=True)
|
|
129
|
+
p.add_argument("--move", action="store_true")
|
|
130
|
+
p.set_defaults(func=cmd_samplefiles)
|
|
131
|
+
return parser
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_clearcache(args: argparse.Namespace) -> None:
|
|
135
|
+
targets = clear_cache(ensure_exists(args.folder), include_extra=args.all, dry_run=args.dry_run)
|
|
136
|
+
for target in targets:
|
|
137
|
+
print(target)
|
|
138
|
+
print(f"{'Found' if args.dry_run else 'Removed'} {len(targets)} cache item(s).")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_images2video(args: argparse.Namespace) -> None:
|
|
142
|
+
print(images_to_video(ensure_exists(args.folder), args.output, args.fps, args.pattern))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_video2images(args: argparse.Namespace) -> None:
|
|
146
|
+
outputs = video_to_images(ensure_exists(args.video), args.output, args.every, args.prefix, args.digits, args.format)
|
|
147
|
+
print(f"Saved {len(outputs)} frame(s) to {args.output}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_images2gif(args: argparse.Namespace) -> None:
|
|
151
|
+
print(images_to_gif(ensure_exists(args.folder), args.output, args.fps, args.pattern))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_video2gif(args: argparse.Namespace) -> None:
|
|
155
|
+
print(video_to_gif(ensure_exists(args.video), args.output, args.fps, args.start, args.end, args.size))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_clipvideo(args: argparse.Namespace) -> None:
|
|
159
|
+
if args.gui:
|
|
160
|
+
print("Warning: --gui is reserved for a future interactive clip selector; using CLI options now.")
|
|
161
|
+
print(clip_video(ensure_exists(args.video), args.output, args.start, args.end, args.start_frame, args.end_frame))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_resizevideo(args: argparse.Namespace) -> None:
|
|
165
|
+
print(resize_video(ensure_exists(args.video), args.output, args.size))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_image2ico(args: argparse.Namespace) -> None:
|
|
169
|
+
print(image_to_ico(ensure_exists(args.image), args.output, args.sizes))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_resizeimage(args: argparse.Namespace) -> None:
|
|
173
|
+
print(resize_image(ensure_exists(args.image), args.output, args.size, not args.no_keep_ratio))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_batchimages(args: argparse.Namespace) -> None:
|
|
177
|
+
outputs = batch_images(ensure_exists(args.folder), args.output, args.size, args.format)
|
|
178
|
+
print(f"Saved {len(outputs)} image(s) to {args.output}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_checkimages(args: argparse.Namespace) -> None:
|
|
182
|
+
broken = check_images(ensure_exists(args.folder), args.recursive, args.remove_broken)
|
|
183
|
+
if broken:
|
|
184
|
+
print("Broken images:")
|
|
185
|
+
for path in broken:
|
|
186
|
+
print(path)
|
|
187
|
+
print(f"Found {len(broken)} broken image(s).")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_contactsheet(args: argparse.Namespace) -> None:
|
|
191
|
+
print(contact_sheet(ensure_exists(args.folder), args.output, args.cols, args.thumb_size, labels=args.labels))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def cmd_tree(args: argparse.Namespace) -> None:
|
|
195
|
+
for line in print_tree(ensure_exists(args.folder), args.depth):
|
|
196
|
+
print(line)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_size(args: argparse.Namespace) -> None:
|
|
200
|
+
for path, _, size_text in top_sizes(ensure_exists(args.folder), args.top):
|
|
201
|
+
print(f"{size_text:>10} {path}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_renamefiles(args: argparse.Namespace) -> None:
|
|
205
|
+
mappings = rename_files(ensure_exists(args.folder), args.prefix, args.digits, args.start, args.dry_run)
|
|
206
|
+
for old, new in mappings:
|
|
207
|
+
print(f"{old.name} -> {new.name}")
|
|
208
|
+
print(f"{'Planned' if args.dry_run else 'Renamed'} {len(mappings)} file(s).")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_samplefiles(args: argparse.Namespace) -> None:
|
|
212
|
+
outputs = sample_files(ensure_exists(args.folder), args.output, args.num, copy=not args.move)
|
|
213
|
+
print(f"Saved {len(outputs)} file(s) to {args.output}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main(argv: list[str] | None = None) -> int:
|
|
217
|
+
parser = build_parser()
|
|
218
|
+
args = parser.parse_args(argv)
|
|
219
|
+
try:
|
|
220
|
+
args.func(args)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
223
|
+
return 1
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
devbits/image.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
|
|
6
|
+
|
|
7
|
+
from .utils import ensure_dir, list_images, parse_size
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def image_to_ico(input_path: Path, output_path: Path, sizes: str = "16,32,48,64,128,256") -> Path:
|
|
11
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
size_values = [int(item.strip()) for item in sizes.split(",") if item.strip()]
|
|
13
|
+
ico_sizes = [(size, size) for size in size_values]
|
|
14
|
+
with Image.open(input_path) as image:
|
|
15
|
+
image = image.convert("RGBA")
|
|
16
|
+
image.save(output_path, format="ICO", sizes=ico_sizes)
|
|
17
|
+
return output_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def contact_sheet(
|
|
21
|
+
folder: Path,
|
|
22
|
+
output_path: Path,
|
|
23
|
+
cols: int = 5,
|
|
24
|
+
thumb_size: str = "256,256",
|
|
25
|
+
pattern: str = "*",
|
|
26
|
+
labels: bool = False,
|
|
27
|
+
) -> Path:
|
|
28
|
+
images = list_images(folder, pattern=pattern)
|
|
29
|
+
if not images:
|
|
30
|
+
raise ValueError(f"No images found in {folder}")
|
|
31
|
+
|
|
32
|
+
thumb_w, thumb_h = parse_size(thumb_size)
|
|
33
|
+
label_h = 24 if labels else 0
|
|
34
|
+
rows = (len(images) + cols - 1) // cols
|
|
35
|
+
sheet = Image.new("RGB", (cols * thumb_w, rows * (thumb_h + label_h)), "white")
|
|
36
|
+
draw = ImageDraw.Draw(sheet)
|
|
37
|
+
|
|
38
|
+
for index, image_path in enumerate(images):
|
|
39
|
+
row, col = divmod(index, cols)
|
|
40
|
+
x = col * thumb_w
|
|
41
|
+
y = row * (thumb_h + label_h)
|
|
42
|
+
try:
|
|
43
|
+
with Image.open(image_path) as image:
|
|
44
|
+
thumb = ImageOps.contain(image.convert("RGB"), (thumb_w, thumb_h))
|
|
45
|
+
except UnidentifiedImageError:
|
|
46
|
+
continue
|
|
47
|
+
paste_x = x + (thumb_w - thumb.width) // 2
|
|
48
|
+
paste_y = y + (thumb_h - thumb.height) // 2
|
|
49
|
+
sheet.paste(thumb, (paste_x, paste_y))
|
|
50
|
+
if labels:
|
|
51
|
+
draw.text((x + 4, y + thumb_h + 4), image_path.name[:40], fill="black")
|
|
52
|
+
|
|
53
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
sheet.save(output_path)
|
|
55
|
+
return output_path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_images(folder: Path, recursive: bool = False, remove_broken: bool = False) -> list[Path]:
|
|
59
|
+
broken: list[Path] = []
|
|
60
|
+
for image_path in list_images(folder, recursive=recursive):
|
|
61
|
+
try:
|
|
62
|
+
with Image.open(image_path) as image:
|
|
63
|
+
image.verify()
|
|
64
|
+
except Exception:
|
|
65
|
+
broken.append(image_path)
|
|
66
|
+
if remove_broken:
|
|
67
|
+
image_path.unlink(missing_ok=True)
|
|
68
|
+
return broken
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resize_image(input_path: Path, output_path: Path, size: str, keep_ratio: bool = True) -> Path:
|
|
72
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
target_size = parse_size(size)
|
|
74
|
+
with Image.open(input_path) as image:
|
|
75
|
+
image = image.convert("RGB")
|
|
76
|
+
if keep_ratio:
|
|
77
|
+
image = ImageOps.contain(image, target_size)
|
|
78
|
+
else:
|
|
79
|
+
image = image.resize(target_size)
|
|
80
|
+
image.save(output_path)
|
|
81
|
+
return output_path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def batch_images(folder: Path, output_folder: Path, size: str | None = None, fmt: str | None = None) -> list[Path]:
|
|
85
|
+
ensure_dir(output_folder)
|
|
86
|
+
outputs: list[Path] = []
|
|
87
|
+
for image_path in list_images(folder):
|
|
88
|
+
with Image.open(image_path) as image:
|
|
89
|
+
image = image.convert("RGB")
|
|
90
|
+
if size:
|
|
91
|
+
image = ImageOps.contain(image, parse_size(size))
|
|
92
|
+
suffix = f".{fmt.lower()}" if fmt else image_path.suffix
|
|
93
|
+
out = output_folder / f"{image_path.stem}{suffix}"
|
|
94
|
+
image.save(out)
|
|
95
|
+
outputs.append(out)
|
|
96
|
+
return outputs
|
devbits/media.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from .utils import ensure_dir, list_images, parse_size
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def images_to_video(folder: Path, output_path: Path, fps: float = 30.0, pattern: str = "*") -> Path:
|
|
12
|
+
images = list_images(folder, pattern=pattern)
|
|
13
|
+
if not images:
|
|
14
|
+
raise ValueError(f"No images found in {folder}")
|
|
15
|
+
|
|
16
|
+
first = cv2.imread(str(images[0]))
|
|
17
|
+
if first is None:
|
|
18
|
+
raise ValueError(f"Cannot read image: {images[0]}")
|
|
19
|
+
height, width = first.shape[:2]
|
|
20
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
|
|
22
|
+
if not writer.isOpened():
|
|
23
|
+
raise RuntimeError(f"Cannot create video writer: {output_path}")
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
for image_path in images:
|
|
27
|
+
frame = cv2.imread(str(image_path))
|
|
28
|
+
if frame is None:
|
|
29
|
+
continue
|
|
30
|
+
if frame.shape[:2] != (height, width):
|
|
31
|
+
frame = cv2.resize(frame, (width, height))
|
|
32
|
+
writer.write(frame)
|
|
33
|
+
finally:
|
|
34
|
+
writer.release()
|
|
35
|
+
return output_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def video_to_images(
|
|
39
|
+
video_path: Path,
|
|
40
|
+
output_folder: Path,
|
|
41
|
+
every: int = 1,
|
|
42
|
+
prefix: str = "frame",
|
|
43
|
+
digits: int = 6,
|
|
44
|
+
fmt: str = "jpg",
|
|
45
|
+
) -> list[Path]:
|
|
46
|
+
ensure_dir(output_folder)
|
|
47
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
48
|
+
if not cap.isOpened():
|
|
49
|
+
raise RuntimeError(f"Cannot open video: {video_path}")
|
|
50
|
+
|
|
51
|
+
outputs: list[Path] = []
|
|
52
|
+
frame_index = 0
|
|
53
|
+
saved_index = 1
|
|
54
|
+
try:
|
|
55
|
+
while True:
|
|
56
|
+
ok, frame = cap.read()
|
|
57
|
+
if not ok:
|
|
58
|
+
break
|
|
59
|
+
if frame_index % every == 0:
|
|
60
|
+
output = output_folder / f"{prefix}_{saved_index:0{digits}d}.{fmt}"
|
|
61
|
+
cv2.imwrite(str(output), frame)
|
|
62
|
+
outputs.append(output)
|
|
63
|
+
saved_index += 1
|
|
64
|
+
frame_index += 1
|
|
65
|
+
finally:
|
|
66
|
+
cap.release()
|
|
67
|
+
return outputs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def images_to_gif(folder: Path, output_path: Path, fps: float = 10.0, pattern: str = "*") -> Path:
|
|
71
|
+
images = list_images(folder, pattern=pattern)
|
|
72
|
+
if not images:
|
|
73
|
+
raise ValueError(f"No images found in {folder}")
|
|
74
|
+
frames = [Image.open(path).convert("RGB") for path in images]
|
|
75
|
+
duration = int(1000 / fps)
|
|
76
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
frames[0].save(output_path, save_all=True, append_images=frames[1:], duration=duration, loop=0)
|
|
78
|
+
for frame in frames:
|
|
79
|
+
frame.close()
|
|
80
|
+
return output_path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def video_to_gif(
|
|
84
|
+
video_path: Path,
|
|
85
|
+
output_path: Path,
|
|
86
|
+
fps: float = 10.0,
|
|
87
|
+
start: float | None = None,
|
|
88
|
+
end: float | None = None,
|
|
89
|
+
size: str | None = None,
|
|
90
|
+
) -> Path:
|
|
91
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
92
|
+
if not cap.isOpened():
|
|
93
|
+
raise RuntimeError(f"Cannot open video: {video_path}")
|
|
94
|
+
|
|
95
|
+
source_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
96
|
+
start_frame = int(start * source_fps) if start is not None else 0
|
|
97
|
+
end_frame = int(end * source_fps) if end is not None else None
|
|
98
|
+
sample_every = max(1, int(round(source_fps / fps)))
|
|
99
|
+
target_size = parse_size(size) if size else None
|
|
100
|
+
frames: list[Image.Image] = []
|
|
101
|
+
|
|
102
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
|
103
|
+
frame_index = start_frame
|
|
104
|
+
try:
|
|
105
|
+
while True:
|
|
106
|
+
if end_frame is not None and frame_index > end_frame:
|
|
107
|
+
break
|
|
108
|
+
ok, frame = cap.read()
|
|
109
|
+
if not ok:
|
|
110
|
+
break
|
|
111
|
+
if (frame_index - start_frame) % sample_every == 0:
|
|
112
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
113
|
+
if target_size:
|
|
114
|
+
frame = cv2.resize(frame, target_size)
|
|
115
|
+
frames.append(Image.fromarray(frame))
|
|
116
|
+
frame_index += 1
|
|
117
|
+
finally:
|
|
118
|
+
cap.release()
|
|
119
|
+
|
|
120
|
+
if not frames:
|
|
121
|
+
raise ValueError("No frames were extracted for GIF")
|
|
122
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
frames[0].save(output_path, save_all=True, append_images=frames[1:], duration=int(1000 / fps), loop=0)
|
|
124
|
+
for frame in frames:
|
|
125
|
+
frame.close()
|
|
126
|
+
return output_path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def clip_video(
|
|
130
|
+
video_path: Path,
|
|
131
|
+
output_path: Path,
|
|
132
|
+
start: float | None = None,
|
|
133
|
+
end: float | None = None,
|
|
134
|
+
start_frame: int | None = None,
|
|
135
|
+
end_frame: int | None = None,
|
|
136
|
+
) -> Path:
|
|
137
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
138
|
+
if not cap.isOpened():
|
|
139
|
+
raise RuntimeError(f"Cannot open video: {video_path}")
|
|
140
|
+
|
|
141
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
142
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
143
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
144
|
+
s_frame = start_frame if start_frame is not None else int((start or 0) * fps)
|
|
145
|
+
e_frame = end_frame if end_frame is not None else (int(end * fps) if end is not None else None)
|
|
146
|
+
|
|
147
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
|
|
149
|
+
if not writer.isOpened():
|
|
150
|
+
raise RuntimeError(f"Cannot create video writer: {output_path}")
|
|
151
|
+
|
|
152
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, s_frame)
|
|
153
|
+
frame_index = s_frame
|
|
154
|
+
try:
|
|
155
|
+
while True:
|
|
156
|
+
if e_frame is not None and frame_index > e_frame:
|
|
157
|
+
break
|
|
158
|
+
ok, frame = cap.read()
|
|
159
|
+
if not ok:
|
|
160
|
+
break
|
|
161
|
+
writer.write(frame)
|
|
162
|
+
frame_index += 1
|
|
163
|
+
finally:
|
|
164
|
+
cap.release()
|
|
165
|
+
writer.release()
|
|
166
|
+
return output_path
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resize_video(video_path: Path, output_path: Path, size: str) -> Path:
|
|
170
|
+
target_size = parse_size(size)
|
|
171
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
172
|
+
if not cap.isOpened():
|
|
173
|
+
raise RuntimeError(f"Cannot open video: {video_path}")
|
|
174
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
175
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, target_size)
|
|
177
|
+
try:
|
|
178
|
+
while True:
|
|
179
|
+
ok, frame = cap.read()
|
|
180
|
+
if not ok:
|
|
181
|
+
break
|
|
182
|
+
writer.write(cv2.resize(frame, target_size))
|
|
183
|
+
finally:
|
|
184
|
+
cap.release()
|
|
185
|
+
writer.release()
|
|
186
|
+
return output_path
|
devbits/project.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .utils import ensure_dir, iter_project_tree, list_files, readable_size
|
|
7
|
+
|
|
8
|
+
DEFAULT_IGNORES = [".git", "__pycache__", ".venv", "venv", "node_modules", "build", "dist"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_tree(root: Path, depth: int = 3, ignore: list[str] | None = None) -> list[str]:
|
|
12
|
+
return iter_project_tree(root, ignore or DEFAULT_IGNORES, depth)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def folder_size(path: Path) -> int:
|
|
16
|
+
if path.is_file():
|
|
17
|
+
return path.stat().st_size
|
|
18
|
+
total = 0
|
|
19
|
+
for item in path.rglob("*"):
|
|
20
|
+
if item.is_file():
|
|
21
|
+
total += item.stat().st_size
|
|
22
|
+
return total
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def top_sizes(root: Path, top: int = 20) -> list[tuple[Path, int, str]]:
|
|
26
|
+
items = [(p, folder_size(p)) for p in root.iterdir()]
|
|
27
|
+
items.sort(key=lambda item: item[1], reverse=True)
|
|
28
|
+
return [(path, size, readable_size(size)) for path, size in items[:top]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rename_files(folder: Path, prefix: str = "file", digits: int = 6, start: int = 1, dry_run: bool = False) -> list[tuple[Path, Path]]:
|
|
32
|
+
files = list_files(folder)
|
|
33
|
+
mappings: list[tuple[Path, Path]] = []
|
|
34
|
+
for index, old_path in enumerate(files, start=start):
|
|
35
|
+
new_path = folder / f"{prefix}_{index:0{digits}d}{old_path.suffix.lower()}"
|
|
36
|
+
mappings.append((old_path, new_path))
|
|
37
|
+
if dry_run:
|
|
38
|
+
return mappings
|
|
39
|
+
temp_mappings: list[tuple[Path, Path]] = []
|
|
40
|
+
for index, (old_path, _) in enumerate(mappings):
|
|
41
|
+
temp_path = folder / f".__devbits_tmp_{index}{old_path.suffix}"
|
|
42
|
+
old_path.rename(temp_path)
|
|
43
|
+
temp_mappings.append((temp_path, mappings[index][1]))
|
|
44
|
+
for temp_path, new_path in temp_mappings:
|
|
45
|
+
temp_path.rename(new_path)
|
|
46
|
+
return mappings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sample_files(folder: Path, output_folder: Path, num: int, copy: bool = True) -> list[Path]:
|
|
50
|
+
files = list_files(folder)
|
|
51
|
+
ensure_dir(output_folder)
|
|
52
|
+
selected = files[:num]
|
|
53
|
+
outputs: list[Path] = []
|
|
54
|
+
for source in selected:
|
|
55
|
+
dest = output_folder / source.name
|
|
56
|
+
if copy:
|
|
57
|
+
shutil.copy2(source, dest)
|
|
58
|
+
else:
|
|
59
|
+
shutil.move(str(source), str(dest))
|
|
60
|
+
outputs.append(dest)
|
|
61
|
+
return outputs
|
devbits/scripts.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from .cli import main as devbits_main
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run(command: str, argv: Sequence[str] | None = None) -> int:
|
|
10
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
11
|
+
return devbits_main([command, *args])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def clearcache() -> int:
|
|
15
|
+
return _run("clearcache")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def images2video() -> int:
|
|
19
|
+
return _run("images2video")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def video2images() -> int:
|
|
23
|
+
return _run("video2images")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def images2gif() -> int:
|
|
27
|
+
return _run("images2gif")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def video2gif() -> int:
|
|
31
|
+
return _run("video2gif")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clipvideo() -> int:
|
|
35
|
+
return _run("clipvideo")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resizevideo() -> int:
|
|
39
|
+
return _run("resizevideo")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def image2ico() -> int:
|
|
43
|
+
return _run("image2ico")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resizeimage() -> int:
|
|
47
|
+
return _run("resizeimage")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def batchimages() -> int:
|
|
51
|
+
return _run("batchimages")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def checkimages() -> int:
|
|
55
|
+
return _run("checkimages")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def contactsheet() -> int:
|
|
59
|
+
return _run("contactsheet")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tree() -> int:
|
|
63
|
+
return _run("tree")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def size() -> int:
|
|
67
|
+
return _run("size")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def renamefiles() -> int:
|
|
71
|
+
return _run("renamefiles")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def samplefiles() -> int:
|
|
75
|
+
return _run("samplefiles")
|
devbits/utils.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}
|
|
8
|
+
VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_exists(path: Path) -> Path:
|
|
12
|
+
if not path.exists():
|
|
13
|
+
raise FileNotFoundError(f"Path does not exist: {path}")
|
|
14
|
+
return path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_dir(path: Path) -> Path:
|
|
18
|
+
if not path.exists():
|
|
19
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
if not path.is_dir():
|
|
21
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def natural_key(value: str) -> list[object]:
|
|
26
|
+
return [int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", value)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_files(folder: Path, pattern: str = "*", recursive: bool = False) -> list[Path]:
|
|
30
|
+
iterator = folder.rglob(pattern) if recursive else folder.glob(pattern)
|
|
31
|
+
return sorted([p for p in iterator if p.is_file()], key=lambda p: natural_key(p.name))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_images(folder: Path, pattern: str = "*", recursive: bool = False) -> list[Path]:
|
|
35
|
+
return [p for p in list_files(folder, pattern, recursive) if p.suffix.lower() in IMAGE_EXTS]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_size(size: str) -> tuple[int, int]:
|
|
39
|
+
try:
|
|
40
|
+
width, height = size.replace("x", ",").split(",")
|
|
41
|
+
width_i, height_i = int(width), int(height)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise ValueError("Size must be WIDTH,HEIGHT or WIDTHxHEIGHT, e.g. 640,480") from exc
|
|
44
|
+
if width_i <= 0 or height_i <= 0:
|
|
45
|
+
raise ValueError("Width and height must be positive integers")
|
|
46
|
+
return width_i, height_i
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_int_tuple(value: str, expected: int, name: str) -> tuple[int, ...]:
|
|
50
|
+
try:
|
|
51
|
+
items = tuple(int(x.strip()) for x in value.split(","))
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
raise ValueError(f"{name} must be comma-separated integers") from exc
|
|
54
|
+
if len(items) != expected:
|
|
55
|
+
raise ValueError(f"{name} must contain {expected} comma-separated integers")
|
|
56
|
+
return items
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def readable_size(num_bytes: int) -> str:
|
|
60
|
+
value = float(num_bytes)
|
|
61
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
62
|
+
if value < 1024 or unit == "TB":
|
|
63
|
+
return f"{value:.1f} {unit}"
|
|
64
|
+
value /= 1024
|
|
65
|
+
return f"{num_bytes} B"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def iter_project_tree(root: Path, ignore: Iterable[str], max_depth: int) -> list[str]:
|
|
69
|
+
ignore_set = set(ignore)
|
|
70
|
+
lines: list[str] = []
|
|
71
|
+
|
|
72
|
+
def walk(path: Path, prefix: str = "", depth: int = 0) -> None:
|
|
73
|
+
if depth > max_depth:
|
|
74
|
+
return
|
|
75
|
+
children = [p for p in sorted(path.iterdir(), key=lambda p: (p.is_file(), natural_key(p.name))) if p.name not in ignore_set]
|
|
76
|
+
for index, child in enumerate(children):
|
|
77
|
+
connector = "└── " if index == len(children) - 1 else "├── "
|
|
78
|
+
lines.append(f"{prefix}{connector}{child.name}{'/' if child.is_dir() else ''}")
|
|
79
|
+
if child.is_dir():
|
|
80
|
+
extension = " " if index == len(children) - 1 else "│ "
|
|
81
|
+
walk(child, prefix + extension, depth + 1)
|
|
82
|
+
|
|
83
|
+
lines.append(f"{root.name}/")
|
|
84
|
+
walk(root, max_depth=max_depth)
|
|
85
|
+
return lines
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devbits
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight CLI toolkit for daily development utilities.
|
|
5
|
+
Author: Bruce Chuang
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/<your-github-username>/devbits
|
|
8
|
+
Project-URL: Repository, https://github.com/<your-github-username>/devbits
|
|
9
|
+
Project-URL: Issues, https://github.com/<your-github-username>/devbits/issues
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: opencv-python>=4.8.0
|
|
14
|
+
Requires-Dist: pillow>=10.0.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# devbits
|
|
18
|
+
|
|
19
|
+
## CLI style
|
|
20
|
+
|
|
21
|
+
`devbits` provides both a main command and standalone commands installed into your Python environment's PATH.
|
|
22
|
+
|
|
23
|
+
Main command style:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
devbits clearcache .
|
|
27
|
+
devbits images2video frames/ -o output.mp4 --fps 30
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Standalone command style:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
clearcache .
|
|
34
|
+
images2video frames/ -o output.mp4 --fps 30
|
|
35
|
+
video2gif input.mp4 -o output.gif
|
|
36
|
+
clipvideo input.mp4 --start 3 --end 10 -o clip.mp4
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
After editable install, reopen the terminal or run `hash -r` if your shell does not immediately find the new commands.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
`devbits` is a lightweight CLI toolkit for daily development utilities, including cache cleanup, media conversion, image helpers, dataset-like file operations, and project maintenance tools.
|
|
43
|
+
|
|
44
|
+
The project supports two CLI styles:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
devbits <command> [options]
|
|
48
|
+
<command> [options]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
### Project cleanup
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
devbits clearcache .
|
|
57
|
+
devbits clearcache . --all
|
|
58
|
+
devbits clearcache . --dry-run
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Removes:
|
|
62
|
+
|
|
63
|
+
- `__pycache__/`
|
|
64
|
+
- `*.pyc`
|
|
65
|
+
- `*.pyo`
|
|
66
|
+
- optionally `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`
|
|
67
|
+
|
|
68
|
+
### Image and video conversion
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
devbits images2video ./frames -o output.mp4 --fps 30
|
|
72
|
+
devbits video2images input.mp4 -o ./frames --every 1
|
|
73
|
+
devbits images2gif ./frames -o output.gif --fps 10
|
|
74
|
+
devbits video2gif input.mp4 -o output.gif --fps 10 --start 2 --end 8
|
|
75
|
+
devbits clipvideo input.mp4 -o clip.mp4 --start 3 --end 10
|
|
76
|
+
devbits clipvideo input.mp4 -o clip.mp4 --start-frame 100 --end-frame 500
|
|
77
|
+
devbits resizevideo input.mp4 -o resized.mp4 --size 1280,720
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Image utilities
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
devbits image2ico logo.png -o logo.ico
|
|
84
|
+
devbits resizeimage input.jpg -o output.jpg --size 640,480
|
|
85
|
+
devbits batchimages ./images -o ./resized --size 640,480 --format jpg
|
|
86
|
+
devbits checkimages ./images --recursive
|
|
87
|
+
devbits contactsheet ./images -o sheet.jpg --cols 5 --labels
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Project utilities
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
devbits tree . --depth 3
|
|
94
|
+
devbits size . --top 20
|
|
95
|
+
devbits renamefiles ./images --prefix frame --digits 6
|
|
96
|
+
devbits renamefiles ./images --prefix frame --digits 6 --dry-run
|
|
97
|
+
devbits samplefiles ./images -o ./sample --num 100
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Installation for local development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git clone https://github.com/yourname/devbits.git
|
|
104
|
+
cd devbits
|
|
105
|
+
python -m venv .venv
|
|
106
|
+
source .venv/bin/activate
|
|
107
|
+
pip install -e ".[dev]"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Check the CLI:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
devbits --help
|
|
114
|
+
devbits images2video --help
|
|
115
|
+
clearcache --help
|
|
116
|
+
images2video --help
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Build package
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python -m pip install --upgrade build twine
|
|
123
|
+
python -m build
|
|
124
|
+
python -m twine check dist/*
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Upload to TestPyPI
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python -m twine upload --repository testpypi dist/*
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Upload to PyPI
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
python -m twine upload dist/*
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Commands
|
|
140
|
+
|
|
141
|
+
| Command | Description |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `clearcache` | Clear Python cache files. |
|
|
144
|
+
| `images2video` | Convert image sequence to MP4 video. |
|
|
145
|
+
| `video2images` | Extract frames from a video. |
|
|
146
|
+
| `images2gif` | Convert image sequence to GIF. |
|
|
147
|
+
| `video2gif` | Convert video to GIF. |
|
|
148
|
+
| `clipvideo` | Clip video by seconds or frame indices. |
|
|
149
|
+
| `resizevideo` | Resize a video. |
|
|
150
|
+
| `image2ico` | Convert image to `.ico`. |
|
|
151
|
+
| `resizeimage` | Resize an image. |
|
|
152
|
+
| `batchimages` | Batch resize or convert images. |
|
|
153
|
+
| `checkimages` | Check broken image files. |
|
|
154
|
+
| `contactsheet` | Create a contact sheet. |
|
|
155
|
+
| `tree` | Print a compact project tree. |
|
|
156
|
+
| `size` | Show top-level folder/file sizes. |
|
|
157
|
+
| `renamefiles` | Batch rename files. |
|
|
158
|
+
| `samplefiles` | Copy or move first N files. |
|
|
159
|
+
|
|
160
|
+
## Roadmap
|
|
161
|
+
|
|
162
|
+
- Interactive `clipvideo --gui`
|
|
163
|
+
- `mergevideos`
|
|
164
|
+
- `comparevideos`
|
|
165
|
+
- `concatframes`
|
|
166
|
+
- `annotatevideo`
|
|
167
|
+
- `watermark`
|
|
168
|
+
- `splitdataset`
|
|
169
|
+
- `countdataset`
|
|
170
|
+
- `dedupimages`
|
|
171
|
+
- Optional ROS helpers under `devbits[ros]`
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
devbits/__init__.py,sha256=zqY4XfgveM5MNPiCnaR8ibIN5LNKsJgNgAcXotHkU3w,97
|
|
2
|
+
devbits/cache.py,sha256=B_KL0XjgwpBGX1ZTZ3Mm1ZSKKyN9d1vMuTzc2crp3CM,939
|
|
3
|
+
devbits/cli.py,sha256=vyfjjyktgDJ2uqN4L0qiJ0KnifJTYvn3LfHI7dInU6g,9612
|
|
4
|
+
devbits/image.py,sha256=5U8hejhx7zONa7Hdgh8vzzA_mgnNm8o5EKP_Up9f29g,3436
|
|
5
|
+
devbits/media.py,sha256=jssw4hsoDjRZXDS-jrkLG54GxbU4DFIoFc6vQ-3rnQg,6177
|
|
6
|
+
devbits/project.py,sha256=uZqOrRTlMVhANq2vDQXwZUObuOLIHQ2J3GCGcUPCbjE,2163
|
|
7
|
+
devbits/scripts.py,sha256=EccTlPhLRau5OwX4KyQiiSnRTDsagPDPauki3MNr7BI,1195
|
|
8
|
+
devbits/utils.py,sha256=4MFbn8biH6WW7x2w_GXnHf3lFbcdTxAEGs9WYkGjFpk,3131
|
|
9
|
+
devbits-0.1.0.dist-info/licenses/LICENSE,sha256=c0Rf85t-tlEpHaWcw2PEO-9h-tBhDLKeU_cBpdYJISA,1069
|
|
10
|
+
devbits-0.1.0.dist-info/METADATA,sha256=NsrCuP9tgmg8k_c4F4Mtwqx17UEwOWxKAk6WS0ey16o,4252
|
|
11
|
+
devbits-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
devbits-0.1.0.dist-info/entry_points.txt,sha256=LA9XpAutCCLCS4fFl8m4j8WNOQoxnHgiyjCEeCHypXY,679
|
|
13
|
+
devbits-0.1.0.dist-info/top_level.txt,sha256=4cDyBdxRwcICa4LrpFjN5159MDxI23MeAtM9RYZ5fS0,8
|
|
14
|
+
devbits-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[console_scripts]
|
|
2
|
+
batchimages = devbits.scripts:batchimages
|
|
3
|
+
checkimages = devbits.scripts:checkimages
|
|
4
|
+
clearcache = devbits.scripts:clearcache
|
|
5
|
+
clipvideo = devbits.scripts:clipvideo
|
|
6
|
+
contactsheet = devbits.scripts:contactsheet
|
|
7
|
+
devbits = devbits.cli:main
|
|
8
|
+
image2ico = devbits.scripts:image2ico
|
|
9
|
+
images2gif = devbits.scripts:images2gif
|
|
10
|
+
images2video = devbits.scripts:images2video
|
|
11
|
+
renamefiles = devbits.scripts:renamefiles
|
|
12
|
+
resizeimage = devbits.scripts:resizeimage
|
|
13
|
+
resizevideo = devbits.scripts:resizevideo
|
|
14
|
+
samplefiles = devbits.scripts:samplefiles
|
|
15
|
+
size = devbits.scripts:size
|
|
16
|
+
tree = devbits.scripts:tree
|
|
17
|
+
video2gif = devbits.scripts:video2gif
|
|
18
|
+
video2images = devbits.scripts:video2images
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruce Chuang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devbits
|