@tidyfactor/design 1.5.0

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.
Files changed (136) hide show
  1. package/.tidyfactor +52 -0
  2. package/AGENTS.md +75 -0
  3. package/CHANGELOG.md +210 -0
  4. package/LICENSE +17 -0
  5. package/README.ar.md +398 -0
  6. package/README.de.md +44 -0
  7. package/README.es.md +44 -0
  8. package/README.fa.md +44 -0
  9. package/README.fr.md +44 -0
  10. package/README.md +418 -0
  11. package/README.pt.md +44 -0
  12. package/README.zh.md +44 -0
  13. package/SKILL-REGISTRY.md +69 -0
  14. package/SKILL.md +46 -0
  15. package/VISION.md +43 -0
  16. package/assets/blog.png +0 -0
  17. package/assets/content_layout.png +0 -0
  18. package/assets/content_output.png +0 -0
  19. package/assets/dark.png +0 -0
  20. package/assets/dashboard_layout.png +0 -0
  21. package/assets/dashboard_output.png +0 -0
  22. package/assets/ecommerce_layout.png +0 -0
  23. package/assets/ecommerce_output.png +0 -0
  24. package/assets/github-social-preview-2.png +0 -0
  25. package/assets/github-social-preview.png +0 -0
  26. package/assets/hero-banner.png +0 -0
  27. package/assets/light.png +0 -0
  28. package/assets/media_layout.png +0 -0
  29. package/assets/media_output.png +0 -0
  30. package/assets/og-default.png +0 -0
  31. package/assets/video.png +0 -0
  32. package/bin/add-skill.js +31 -0
  33. package/bin/create-kit.js +372 -0
  34. package/bin/remove-skill.js +137 -0
  35. package/brand.json +279 -0
  36. package/memory/01-design-schools.md +85 -0
  37. package/memory/02-design-tokens.md +41 -0
  38. package/memory/03-narrative-conversion.md +47 -0
  39. package/memory/04-motion-principles.md +44 -0
  40. package/memory/05-component-anatomy.md +34 -0
  41. package/memory/06-quality-bar.md +83 -0
  42. package/memory/07-consistency-contract.md +33 -0
  43. package/memory/08-arabic-bilingual.md +54 -0
  44. package/memory/09-prototype-flow.md +36 -0
  45. package/memory/10-python-tooling.md +64 -0
  46. package/memory/11-brand-json-v2.md +86 -0
  47. package/memory/12-typography-matrix.md +31 -0
  48. package/memory/13-layout-archetypes.md +30 -0
  49. package/memory/14-nav-footer-catalog.md +26 -0
  50. package/memory/15-performance-budget.md +18 -0
  51. package/memory/16-design-movements-guide.md +1084 -0
  52. package/memory/17-storytelling-industries-ux.md +143 -0
  53. package/memory/18-design-decision-engine.md +81 -0
  54. package/package.json +64 -0
  55. package/references/commands/_template.md +27 -0
  56. package/references/commands/assets.md +35 -0
  57. package/references/commands/audit.md +15 -0
  58. package/references/commands/brand.md +85 -0
  59. package/references/commands/brief.md +107 -0
  60. package/references/commands/clone.md +26 -0
  61. package/references/commands/components.md +46 -0
  62. package/references/commands/dashboard.md +45 -0
  63. package/references/commands/deploy.md +45 -0
  64. package/references/commands/flow.md +44 -0
  65. package/references/commands/handoff.md +53 -0
  66. package/references/commands/i18n.md +64 -0
  67. package/references/commands/init.md +30 -0
  68. package/references/commands/layout.md +81 -0
  69. package/references/commands/motion.md +62 -0
  70. package/references/commands/nav-footer.md +54 -0
  71. package/references/commands/page.md +45 -0
  72. package/references/commands/palette.md +35 -0
  73. package/references/commands/perf.md +57 -0
  74. package/references/commands/retrofit.md +15 -0
  75. package/references/commands/school.md +41 -0
  76. package/references/commands/states.md +48 -0
  77. package/references/commands/study.md +98 -0
  78. package/references/commands/tokens.md +45 -0
  79. package/references/commands/typography.md +87 -0
  80. package/references/foundations/daisyui.md +44 -0
  81. package/references/foundations/hybrid.md +32 -0
  82. package/references/foundations/native.md +31 -0
  83. package/references/foundations/tailwind-utility.md +43 -0
  84. package/references/memory/01-design-schools.md +85 -0
  85. package/references/memory/02-design-tokens.md +41 -0
  86. package/references/memory/03-narrative-conversion.md +47 -0
  87. package/references/memory/04-motion-principles.md +44 -0
  88. package/references/memory/05-component-anatomy.md +34 -0
  89. package/references/memory/06-quality-bar.md +83 -0
  90. package/references/memory/07-consistency-contract.md +33 -0
  91. package/references/memory/08-arabic-bilingual.md +54 -0
  92. package/references/memory/09-prototype-flow.md +36 -0
  93. package/references/memory/10-python-tooling.md +64 -0
  94. package/references/memory/11-brand-json-v2.md +86 -0
  95. package/references/memory/12-typography-matrix.md +31 -0
  96. package/references/memory/13-layout-archetypes.md +30 -0
  97. package/references/memory/14-nav-footer-catalog.md +26 -0
  98. package/references/memory/15-performance-budget.md +18 -0
  99. package/references/memory/16-design-movements-guide.md +1084 -0
  100. package/references/memory/17-storytelling-industries-ux.md +143 -0
  101. package/references/memory/18-design-decision-engine.md +81 -0
  102. package/references/memory/architecture.md +39 -0
  103. package/references/memory/decision-points.md +50 -0
  104. package/references/memory/foundations.md +15 -0
  105. package/references/memory/quality-bar.md +20 -0
  106. package/references/tidyfactor-vision.md +66 -0
  107. package/references/workflow.md +49 -0
  108. package/references/workflows/audit-prototype.md +26 -0
  109. package/references/workflows/brief.md +30 -0
  110. package/references/workflows/clone-prototype.md +27 -0
  111. package/references/workflows/init-prototype.md +28 -0
  112. package/references/workflows/retrofit-prototype.md +25 -0
  113. package/scripts/__pycache__/_utils.cpython-312.pyc +0 -0
  114. package/scripts/_utils.py +60 -0
  115. package/scripts/build.py +194 -0
  116. package/scripts/check_alpha.py +52 -0
  117. package/scripts/extract_palette.py +139 -0
  118. package/scripts/generate_transitions.py +105 -0
  119. package/scripts/inspect_images.py +48 -0
  120. package/scripts/minify_assets.py +140 -0
  121. package/scripts/optimize_assets.py +91 -0
  122. package/scripts/optimize_images.py +176 -0
  123. package/scripts/prepare_images.py +67 -0
  124. package/scripts/remove_backgrounds.py +61 -0
  125. package/scripts/test_build.py +228 -0
  126. package/templates/design-system/base.css +75 -0
  127. package/templates/design-system/components.css +229 -0
  128. package/templates/design-system/interactions.js +31 -0
  129. package/templates/design-system/motion.js +42 -0
  130. package/templates/design-system/tokens.css +81 -0
  131. package/templates/design-system/utilities.css +47 -0
  132. package/templates/index.html +84 -0
  133. package/templates/proto-nav.js +56 -0
  134. package/tools/build-skill.js +126 -0
  135. package/tools/validate-skill.js +144 -0
  136. package/tools/validate_skill.py +121 -0
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Asset Bundler & Minifier Script
4
+ Combines and minifies CSS and JavaScript files relative to the project root.
5
+ """
6
+
7
+ import re
8
+ from pathlib import Path
9
+
10
+ # Project root is the parent directory of this script's directory (i.e. scripts/ -> root)
11
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
12
+
13
+ # --- BUNDLE CONFIGURATION (Relative to PROJECT_ROOT) ---
14
+ CSS_BUNDLES = {
15
+ "assets/css/style.min.css": [
16
+ "assets/css/style.css"
17
+ ]
18
+ }
19
+
20
+ JS_BUNDLES = {
21
+ "assets/js/bundle.min.js": [
22
+ "assets/js/gsap/3.12.5/gsap.min.js",
23
+ "assets/js/gsap/3.12.5/ScrollTrigger.min.js",
24
+ "assets/js/lenis@1.1.13/dist/lenis.min.js",
25
+ "assets/js/main.js"
26
+ ]
27
+ }
28
+
29
+
30
+ def get_size_format(b, factor=1024, suffix="B"):
31
+ for unit in ["", "K", "M", "G"]:
32
+ if b < factor:
33
+ return f"{b:.2f}{unit}{suffix}"
34
+ b /= factor
35
+ return f"{b:.2f}T{suffix}"
36
+
37
+
38
+ def minify_css(css_content):
39
+ # Remove comments
40
+ css = re.sub(r'/\*.*?\*/', '', css_content, flags=re.DOTALL)
41
+ # Remove extra spaces around braces and symbols
42
+ css = re.sub(r'\s*([\{\}:;,])\s*', r'\1', css)
43
+ # Replace multiple spaces with a single space
44
+ css = re.sub(r'\s+', ' ', css)
45
+ # Remove unnecessary semicolons before closing braces
46
+ css = re.sub(r';\}', '}', css)
47
+ return css.strip()
48
+
49
+
50
+ def minify_js(js_content):
51
+ # Save string literals to avoid corrupting comments inside quotes
52
+ strings = []
53
+ def save_str(match):
54
+ strings.append(match.group(0))
55
+ return f"__STR_PLACEHOLDER_{len(strings)-1}__"
56
+
57
+ string_pattern = r'("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|`(?:[^`\\]|\\.)*`)'
58
+ js = re.sub(string_pattern, save_str, js_content)
59
+
60
+ # Remove single-line comments // (protecting http:// style links)
61
+ js = re.sub(r'(?<!:)\/\/.*$', '', js, flags=re.MULTILINE)
62
+ # Remove multi-line comments /* ... */
63
+ js = re.sub(r'/\*.*?\*/', '', js, flags=re.DOTALL)
64
+ # Remove multiple spaces/newlines around operator boundaries
65
+ js = re.sub(r'\s*([=\+\-\*\/\:\?\{\}\(\)\[\]\,\;])\s*', r'\1', js)
66
+ # Replace multiple whitespaces/newlines with single line-break or space
67
+ js = re.sub(r'\n+', '\n', js)
68
+ js = re.sub(r'[ \t]+', ' ', js)
69
+
70
+ # Restore string literals
71
+ for i, s in enumerate(strings):
72
+ js = js.replace(f"__STR_PLACEHOLDER_{i}__", s)
73
+
74
+ return js.strip()
75
+
76
+
77
+ def build_bundle(dest_path_rel, src_files_rel, file_type):
78
+ dest_path = PROJECT_ROOT / dest_path_rel
79
+ print(f"Building bundle -> {dest_path.relative_to(PROJECT_ROOT)}")
80
+ combined_content = []
81
+ total_src_size = 0
82
+
83
+ for src_file in src_files_rel:
84
+ src_path = PROJECT_ROOT / src_file
85
+ if not src_path.exists():
86
+ print(f" [ERROR] Source file not found: {src_file}")
87
+ continue
88
+
89
+ size = src_path.stat().st_size
90
+ total_src_size += size
91
+ print(f" + {src_path.name} ({get_size_format(size)})")
92
+
93
+ with open(src_path, "r", encoding="utf-8") as f:
94
+ content = f.read()
95
+
96
+ is_already_minified = src_path.name.endswith(".min.js") or src_path.name.endswith(".min.css")
97
+
98
+ if is_already_minified:
99
+ combined_content.append(content)
100
+ else:
101
+ if file_type == "css":
102
+ minified = minify_css(content)
103
+ elif file_type == "js":
104
+ minified = minify_js(content)
105
+ else:
106
+ minified = content
107
+ combined_content.append(minified)
108
+
109
+ output_content = "\n".join(combined_content)
110
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
111
+
112
+ with open(dest_path, "w", encoding="utf-8") as f:
113
+ f.write(output_content)
114
+
115
+ final_size = dest_path.stat().st_size
116
+ saved = total_src_size - final_size
117
+ pct = (saved / total_src_size) * 100 if total_src_size > 0 else 0
118
+
119
+ print(f" [SUCCESS] Combined Size: {get_size_format(total_src_size)} -> {get_size_format(final_size)} (-{pct:.1f}%)")
120
+ print(f" Saved: {get_size_format(saved)}\n")
121
+
122
+
123
+ def main():
124
+ print("="*50)
125
+ print("Beginning Asset Bundler and Minifier (Scripts Folder)...")
126
+ print("="*50 + "\n")
127
+
128
+ for dest, sources in CSS_BUNDLES.items():
129
+ build_bundle(dest, sources, "css")
130
+
131
+ for dest, sources in JS_BUNDLES.items():
132
+ build_bundle(dest, sources, "js")
133
+
134
+ print("="*50)
135
+ print("Bundling process completed successfully!")
136
+ print("="*50)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -0,0 +1,91 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import sys
5
+
6
+ try:
7
+ from PIL import Image
8
+ except ImportError:
9
+ print("Required package not installed. Run: pip install Pillow", file=sys.stderr)
10
+ sys.exit(1)
11
+
12
+ def resize_image_max_dim(path, max_dim, output=None):
13
+ if not os.path.exists(path):
14
+ print(f"[skip] {path} not found")
15
+ return
16
+ try:
17
+ img = Image.open(path)
18
+ w, h = img.size
19
+ if w <= max_dim and h <= max_dim:
20
+ print(f"[ok] {path} already within {max_dim}px ({w}x{h})")
21
+ return
22
+ if w > h:
23
+ new_w = max_dim
24
+ new_h = int(h * (max_dim / w))
25
+ else:
26
+ new_h = max_dim
27
+ new_w = int(w * (max_dim / h))
28
+ print(f"[resize] {path} {w}x{h} -> {new_w}x{new_h}")
29
+ img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
30
+ dest = output or path
31
+ img.save(dest)
32
+ print(f"[saved] {dest}")
33
+ except Exception as e:
34
+ print(f"[error] {path}: {e}")
35
+
36
+ def main():
37
+ parser = argparse.ArgumentParser(
38
+ description="Resize images so their largest dimension does not exceed a given pixel limit. "
39
+ "Overwrites in place unless --output is specified.",
40
+ )
41
+ parser.add_argument(
42
+ "inputs",
43
+ nargs="*",
44
+ help="Image file paths. Each can be followed by a colon and a max-dim value "
45
+ "(e.g. assets/logo.png:240). The colon suffix is only recognised when the "
46
+ "trailing segment is purely numeric, so Windows paths like C:\\images\\photo.png "
47
+ "work without ambiguity. If no colon value is given, --max-dim is used.",
48
+ )
49
+ parser.add_argument(
50
+ "--max-dim",
51
+ type=int,
52
+ default=1200,
53
+ help="Default max dimension in pixels (default: 1200)",
54
+ )
55
+ parser.add_argument(
56
+ "--output",
57
+ "-o",
58
+ help="Optional output path (only valid when a single input is given)",
59
+ )
60
+ args = parser.parse_args()
61
+
62
+ if not args.inputs:
63
+ parser.print_help()
64
+ sys.exit(0)
65
+
66
+ if args.output and len(args.inputs) > 1:
67
+ parser.error("--output can only be used with a single input file")
68
+
69
+ output_for_single = args.output if len(args.inputs) == 1 else None
70
+ for spec in args.inputs:
71
+ # Split spec into path + optional max-dim ONLY when the trailing segment
72
+ # after the last colon is purely numeric. This avoids corrupting
73
+ # Windows absolute paths like C:\images\photo.png (which contain a
74
+ # drive-letter colon that is NOT a dimension modifier).
75
+ path = spec
76
+ dim = args.max_dim
77
+ if ":" in spec:
78
+ left, right = spec.rsplit(":", 1)
79
+ if right.isdigit():
80
+ path, dim = left, int(right)
81
+
82
+ dest = output_for_single or path
83
+ if not output_for_single and os.path.exists(path):
84
+ backup = path + ".bak"
85
+ print(f"[warn] overwriting in-place: {path} (backup -> {backup})")
86
+ shutil.copy2(path, backup)
87
+
88
+ resize_image_max_dim(path, dim, output=dest)
89
+
90
+ if __name__ == "__main__":
91
+ main()
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Image Optimization Utility
4
+ Optimizes JPEGs, PNGs, and WebP images in a directory using Python's Pillow library.
5
+ Resolves paths relative to the project root.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ # Project root is parent of scripts/
14
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
15
+
16
+ try:
17
+ from PIL import Image
18
+ except ImportError:
19
+ print("Error: The 'Pillow' library is required to run this script.")
20
+ print("Install it by running: pip install Pillow")
21
+ sys.exit(1)
22
+
23
+
24
+ def get_size_format(b, factor=1024, suffix="B"):
25
+ for unit in ["", "K", "M", "G", "T", "P"]:
26
+ if b < factor:
27
+ return f"{b:.2f}{unit}{suffix}"
28
+ b /= factor
29
+ return f"{b:.2f}Y{suffix}"
30
+
31
+
32
+ def optimize_image(file_path, quality=85, convert_to_webp=False, replace=True, backup=True):
33
+ path = Path(file_path)
34
+ ext = path.suffix.lower()
35
+
36
+ if ext not in [".jpg", ".jpeg", ".png", ".webp"]:
37
+ return None
38
+
39
+ orig_size = path.stat().st_size
40
+
41
+ try:
42
+ # Determine destination path
43
+ if convert_to_webp:
44
+ dest_path = path.with_suffix(".webp")
45
+ else:
46
+ dest_path = path
47
+
48
+ # Backup handling: Rename the original file BEFORE opening it
49
+ # This prevents WinError 32 (file in use) on Windows
50
+ backup_path = None
51
+ source_path = path
52
+
53
+ if replace and dest_path == path and backup:
54
+ bp = path.with_name(f"{path.stem}_backup{ext}")
55
+ if not bp.exists():
56
+ os.rename(path, bp)
57
+ backup_path = bp
58
+ source_path = bp
59
+ else:
60
+ source_path = bp
61
+
62
+ # Now process the image from source_path and write to dest_path
63
+ with Image.open(source_path) as img:
64
+ # Handle conversion for JPEG
65
+ if (not convert_to_webp and ext in [".jpg", ".jpeg"]) or (convert_to_webp and ext in [".jpg", ".jpeg"]):
66
+ if img.mode != "RGB":
67
+ img = img.convert("RGB")
68
+
69
+ if convert_to_webp:
70
+ img.save(dest_path, "WEBP", quality=quality, method=6)
71
+ elif ext in [".jpg", ".jpeg"]:
72
+ img.save(dest_path, "JPEG", optimize=True, quality=quality, progressive=True)
73
+ elif ext == ".png":
74
+ if img.mode != "P" and img.mode in ("RGB", "RGBA"):
75
+ try:
76
+ quantized = img.quantize(colors=256)
77
+ quantized.save(dest_path, "PNG", optimize=True)
78
+ except Exception:
79
+ img.save(dest_path, "PNG", optimize=True, compress_level=9)
80
+ else:
81
+ img.save(dest_path, "PNG", optimize=True, compress_level=9)
82
+ elif ext == ".webp":
83
+ img.save(dest_path, "WEBP", quality=quality, method=6)
84
+
85
+ new_size = dest_path.stat().st_size
86
+ saved_bytes = orig_size - new_size
87
+
88
+ if saved_bytes > 0:
89
+ saving_pct = (saved_bytes / orig_size) * 100
90
+ print(f"[OPTIMIZED] {path.name} -> {dest_path.name}")
91
+ print(f" Size: {get_size_format(orig_size)} -> {get_size_format(new_size)} (-{saving_pct:.1f}%)")
92
+ return orig_size, new_size
93
+ else:
94
+ # If the file didn't compress further (new size >= original), restore original
95
+ if backup_path and backup_path.exists():
96
+ if dest_path.exists():
97
+ os.remove(dest_path)
98
+ os.rename(backup_path, dest_path)
99
+ print(f"[SKIPPED] {path.name} (Already optimized)")
100
+ return orig_size, orig_size
101
+
102
+ except Exception as e:
103
+ print(f"[ERROR] Could not optimize {path.name}: {e}")
104
+ # Restore backup in case of half-saved/failed operations
105
+ if 'backup_path' in locals() and backup_path and backup_path.exists():
106
+ if path.exists():
107
+ os.remove(path)
108
+ os.rename(backup_path, path)
109
+ return None
110
+
111
+
112
+ def main():
113
+ parser = argparse.ArgumentParser(description="Optimize assets in a folder using Pillow.")
114
+ parser.add_argument("directory", help="Path to the directory containing images (relative to project root)")
115
+ parser.add_argument("--quality", type=int, default=85, help="JPEG/WebP quality setting (0-100, default: 85)")
116
+ parser.add_argument("--webp", action="store_true", help="Convert JPEGs and PNGs to WebP format")
117
+ parser.add_argument("--no-backup", action="store_true", help="Do not keep backups of replaced images")
118
+
119
+ args = parser.parse_args()
120
+
121
+ target_dir = PROJECT_ROOT / args.directory
122
+ if not target_dir.exists() or not target_dir.is_dir():
123
+ print(f"Error: '{target_dir}' is not a valid directory.")
124
+ sys.exit(1)
125
+
126
+ print(f"Scanning '{target_dir.relative_to(PROJECT_ROOT)}' for images...")
127
+
128
+ supported_extensions = {".png", ".jpg", ".jpeg", ".webp"}
129
+ image_files = []
130
+
131
+ for root, _, files in os.walk(target_dir):
132
+ for file in files:
133
+ file_path = Path(root) / file
134
+ if file_path.suffix.lower() in supported_extensions and "_backup" not in file_path.name:
135
+ image_files.append(file_path)
136
+
137
+ if not image_files:
138
+ print("No supported images found in the directory.")
139
+ sys.exit(0)
140
+
141
+ print(f"Found {len(image_files)} image files. Beginning optimization...\n")
142
+
143
+ total_original = 0
144
+ total_optimized = 0
145
+ optimized_count = 0
146
+
147
+ for file_path in image_files:
148
+ result = optimize_image(
149
+ file_path,
150
+ quality=args.quality,
151
+ convert_to_webp=args.webp,
152
+ replace=True,
153
+ backup=not args.no_backup
154
+ )
155
+ if result:
156
+ orig, new = result
157
+ total_original += orig
158
+ total_optimized += new
159
+ optimized_count += 1
160
+
161
+ print("\n" + "="*40)
162
+ print("Optimization Summary:")
163
+ print("="*40)
164
+ print(f"Total Images Scanned: {len(image_files)}")
165
+ print(f"Total Optimized/Saved: {optimized_count}")
166
+ if total_original > 0:
167
+ saved = total_original - total_optimized
168
+ pct = (saved / total_original) * 100
169
+ print(f"Total Original Size: {get_size_format(total_original)}")
170
+ print(f"Total Optimized Size: {get_size_format(total_optimized)}")
171
+ print(f"Total Disk Space Saved: {get_size_format(saved)} (-{pct:.1f}%)")
172
+ print("="*40)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
@@ -0,0 +1,67 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+
5
+ try:
6
+ from PIL import Image
7
+ except ImportError:
8
+ print("Required package not installed. Run: pip install Pillow", file=sys.stderr)
9
+ sys.exit(1)
10
+
11
+ PNG_EXTS = {".png", ".webp", ".tga", ".bmp", ".tiff"}
12
+
13
+ def convert_to_jpg(src, dest, quality=87):
14
+ if not os.path.exists(src):
15
+ print(f"[skip] {src} not found")
16
+ return
17
+ try:
18
+ img = Image.open(src)
19
+ rgb_img = img.convert("RGB")
20
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
21
+ rgb_img.save(dest, "JPEG", quality=quality)
22
+ print(f"[ok] {src} -> {dest} (q={quality})")
23
+ except Exception as e:
24
+ print(f"[error] {src}: {e}")
25
+
26
+ def main():
27
+ parser = argparse.ArgumentParser(
28
+ description="Convert PNG/WEBP images to optimised JPEG. "
29
+ "Accepts explicit src:dest pairs or an input/output directory pair.",
30
+ )
31
+ parser.add_argument(
32
+ "pairs",
33
+ nargs="*",
34
+ help="src:dest pairs e.g. assets/hero.png:assets/hero.jpg",
35
+ )
36
+ parser.add_argument(
37
+ "--input-dir",
38
+ help="Scan a directory for PNG/WEBP files (alternative to positional pairs)",
39
+ )
40
+ parser.add_argument("--output-dir", help="Directory for converted JPEGs (default: same as input)")
41
+ parser.add_argument("--quality", "-q", type=int, default=87, help="JPEG quality 1-100 (default: 87)")
42
+ args = parser.parse_args()
43
+
44
+ if args.input_dir and args.pairs:
45
+ parser.error("Use either --input-dir OR positional pairs, not both.")
46
+
47
+ if args.input_dir:
48
+ out_dir = args.output_dir or args.input_dir
49
+ os.makedirs(out_dir, exist_ok=True)
50
+ for f in sorted(os.listdir(args.input_dir)):
51
+ if os.path.splitext(f)[1].lower() in PNG_EXTS:
52
+ src = os.path.join(args.input_dir, f)
53
+ dest = os.path.join(out_dir, os.path.splitext(f)[0] + ".jpg")
54
+ convert_to_jpg(src, dest, args.quality)
55
+ elif args.pairs:
56
+ for pair in args.pairs:
57
+ if ":" not in pair:
58
+ print(f"[skip] '{pair}' — expected format src:dest", file=sys.stderr)
59
+ continue
60
+ src, dest = pair.split(":", 1)
61
+ convert_to_jpg(src.strip(), dest.strip(), args.quality)
62
+ else:
63
+ parser.print_help()
64
+ sys.exit(0)
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,61 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+
5
+ try:
6
+ from rembg import remove
7
+ from PIL import Image
8
+ except ImportError:
9
+ print("Required packages not installed. Run: pip install rembg pillow", file=sys.stderr)
10
+ sys.exit(1)
11
+
12
+ from _utils import split_spec
13
+
14
+ def remove_bg(src, dest):
15
+ if not os.path.exists(src):
16
+ print(f"[skip] {src} not found")
17
+ return
18
+ try:
19
+ print(f"[work] {src} -> {dest}")
20
+ img = Image.open(src)
21
+ out = remove(img)
22
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
23
+ out.save(dest)
24
+ print(f"[ok] {dest}")
25
+ except Exception as e:
26
+ print(f"[error] {src}: {e}")
27
+
28
+ def main():
29
+ parser = argparse.ArgumentParser(
30
+ description="Remove backgrounds from images using rembg. "
31
+ "Accepts src:dest pairs or a single file with --output.",
32
+ )
33
+ parser.add_argument(
34
+ "inputs",
35
+ nargs="*",
36
+ help="Image paths. Use src:dest syntax for explicit output names, "
37
+ "or plain paths (output appends '_cut' before the extension).",
38
+ )
39
+ parser.add_argument("--output", "-o", help="Output path (valid only with a single input)")
40
+ args = parser.parse_args()
41
+
42
+ if not args.inputs:
43
+ parser.print_help()
44
+ sys.exit(0)
45
+
46
+ if args.output and len(args.inputs) > 1:
47
+ parser.error("--output can only be used with a single input file")
48
+
49
+ for spec in args.inputs:
50
+ if ":" in spec:
51
+ parts = split_spec(spec)
52
+ src, dest = parts[0], parts[1] if len(parts) > 1 else parts[0]
53
+ elif args.output:
54
+ src, dest = spec, args.output
55
+ else:
56
+ root, ext = os.path.splitext(spec)
57
+ src, dest = spec, f"{root}_cut{ext}"
58
+ remove_bg(src.strip(), dest.strip())
59
+
60
+ if __name__ == "__main__":
61
+ main()